mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
546 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f638fe6eb | ||
|
|
b8154aa72b | ||
|
|
a2d8ce07d6 | ||
|
|
721f1ee8d3 | ||
|
|
b55aa042d4 | ||
|
|
1f7fc232b3 | ||
|
|
338a41991d | ||
|
|
ee44b536f5 | ||
|
|
efc9c2d345 | ||
|
|
c4e45af565 | ||
|
|
e27734b218 | ||
|
|
686a25fb65 | ||
|
|
0e3a78b308 | ||
|
|
edb0ba888a | ||
|
|
00e7ac9ec8 | ||
|
|
b3c272f720 | ||
|
|
bb5c3dae00 | ||
|
|
ed7944cf9d | ||
|
|
91d257e103 | ||
|
|
e379707a68 | ||
|
|
f6afe0d864 | ||
|
|
952b58c0eb | ||
|
|
9324a5b67a | ||
|
|
ec646db844 | ||
|
|
1812b14898 | ||
|
|
1c38ffe0a9 | ||
|
|
f4a4e0c25c | ||
|
|
a7dd0da2aa | ||
|
|
618b4a30b1 | ||
|
|
4b02daed8a | ||
|
|
667f441cc0 | ||
|
|
5762055213 | ||
|
|
675817f0b2 | ||
|
|
35c582913d | ||
|
|
e309d82b41 | ||
|
|
5ea68dcdfa | ||
|
|
01eb1ad512 | ||
|
|
39094078a9 | ||
|
|
160d861534 | ||
|
|
6cbe4e6a14 | ||
|
|
0d05ff7efd | ||
|
|
dbe24b609d | ||
|
|
bb6403e426 | ||
|
|
9bf36cdb56 | ||
|
|
1f79c4d039 | ||
|
|
5a74970d4a | ||
|
|
eed61d338c | ||
|
|
2f13c83c0e | ||
|
|
12e24f9620 | ||
|
|
37bb168204 | ||
|
|
57db997028 | ||
|
|
29d46c5d92 | ||
|
|
a72c7f6c4b | ||
|
|
1962225647 | ||
|
|
2d3c9101f3 | ||
|
|
f27d39fb63 | ||
|
|
5a3fc96b76 | ||
|
|
fbeda4510e | ||
|
|
7e255a5249 | ||
|
|
a19b780eca | ||
|
|
faa0d93cb8 | ||
|
|
2dd77aae04 | ||
|
|
2e8bced707 | ||
|
|
8296c797ff | ||
|
|
e1ab779fe0 | ||
|
|
eb7fa02ac8 | ||
|
|
44292b2b69 | ||
|
|
05c12a9c5c | ||
|
|
5c7a6397da | ||
|
|
f1e5640a26 | ||
|
|
512702fe46 | ||
|
|
ecc6a269bc | ||
|
|
7a0606952d | ||
|
|
68fae16740 | ||
|
|
bc329a2d85 | ||
|
|
e96cd16a0f | ||
|
|
316eeee9ca | ||
|
|
c3012ea520 | ||
|
|
e2ecae0e72 | ||
|
|
49fa72d70d | ||
|
|
c6534b3a16 | ||
|
|
15631b815b | ||
|
|
d30a3a72da | ||
|
|
6ed7dfc42a | ||
|
|
77dc80b86f | ||
|
|
2bd2e8bc46 | ||
|
|
81697f85b2 | ||
|
|
72293f7f4f | ||
|
|
65d14d4433 | ||
|
|
9eb1ddf29d | ||
|
|
d18bfe9047 | ||
|
|
c741185100 | ||
|
|
ea0bb81100 | ||
|
|
9d7d1e8500 | ||
|
|
16c27d7404 | ||
|
|
b8a3eefd47 | ||
|
|
d24087c10a | ||
|
|
15cab6014c | ||
|
|
47d236a299 | ||
|
|
b9cea49f10 | ||
|
|
ad88682426 | ||
|
|
1ddbdf54f1 | ||
|
|
51d9563352 | ||
|
|
045b373168 | ||
|
|
985ad52cce | ||
|
|
d198c68b9e | ||
|
|
751a07124f | ||
|
|
defb4f82f7 | ||
|
|
4570e65ce8 | ||
|
|
db0d63dd90 | ||
|
|
1a8fdcd388 | ||
|
|
417abed6a5 | ||
|
|
6afdeef84a | ||
|
|
77c82dfdbd | ||
|
|
29277c900d | ||
|
|
ed1943058a | ||
|
|
e0361ddd22 | ||
|
|
ce9474055e | ||
|
|
8f9b4c8828 | ||
|
|
af4e0de9ab | ||
|
|
3acc71b8ad | ||
|
|
0759932dad | ||
|
|
95f45e4e2b | ||
|
|
a5c2b97e1d | ||
|
|
d32462e9ee | ||
|
|
33262843a5 | ||
|
|
1cd02a0e1a | ||
|
|
40ab9e3f20 | ||
|
|
4141c76258 | ||
|
|
7f44323686 | ||
|
|
152b07b599 | ||
|
|
84f36d2e20 | ||
|
|
25d4c5023a | ||
|
|
3f5acfff31 | ||
|
|
b9940e285c | ||
|
|
9993c6c6c3 | ||
|
|
3b7971a463 | ||
|
|
d75d2d857d | ||
|
|
cab42985a5 | ||
|
|
ef892bd27d | ||
|
|
3014576c4c | ||
|
|
b8c77bf0e3 | ||
|
|
17073fe8ff | ||
|
|
6220b47073 | ||
|
|
bf7f8f686b | ||
|
|
93cd4734ce | ||
|
|
e4acd6cb7a | ||
|
|
ca11fc667b | ||
|
|
36e54a097e | ||
|
|
ecd3137d34 | ||
|
|
f881f9ae32 | ||
|
|
3f6b84899a | ||
|
|
5e64a1340c | ||
|
|
2d0ed004e2 | ||
|
|
183cda2b66 | ||
|
|
ed2a97fda8 | ||
|
|
2fed0f09bb | ||
|
|
478aa4b70e | ||
|
|
fd74abcdca | ||
|
|
444e265275 | ||
|
|
2ee9329663 | ||
|
|
b30aafc2bd | ||
|
|
90cb20ce79 | ||
|
|
bd6feac235 | ||
|
|
9f3256a254 | ||
|
|
4165dbe8e9 | ||
|
|
15e15b525f | ||
|
|
371c29fecd | ||
|
|
7d823fd3ec | ||
|
|
ab4525db5b | ||
|
|
73d27bd5fe | ||
|
|
6db5200890 | ||
|
|
9dd5c6e5f3 | ||
|
|
407c4ca554 | ||
|
|
47f26e99db | ||
|
|
263886fc1c | ||
|
|
bb5ac5b1d0 | ||
|
|
9c1c2cea76 | ||
|
|
c0037e56af | ||
|
|
4f16056578 | ||
|
|
a4bcc7b169 | ||
|
|
f17fb77013 | ||
|
|
0079f2a875 | ||
|
|
0abcd21cd3 | ||
|
|
5fb6e0e74d | ||
|
|
d88af28e93 | ||
|
|
73f35537f9 | ||
|
|
e5deb90986 | ||
|
|
d5ffc51f07 | ||
|
|
861917836a | ||
|
|
314322d43d | ||
|
|
cce70d3080 | ||
|
|
43f136deba | ||
|
|
c725352888 | ||
|
|
d1bda25a0b | ||
|
|
d241c24776 | ||
|
|
ca503c1560 | ||
|
|
c34fb26359 | ||
|
|
030e163fb8 | ||
|
|
8b09846f98 | ||
|
|
e3ef88c297 | ||
|
|
3eda7dd15b | ||
|
|
34df55a10a | ||
|
|
0229ef94f1 | ||
|
|
5b884aee0c | ||
|
|
8be2692b8a | ||
|
|
103601705d | ||
|
|
68ceac492c | ||
|
|
ed07452c80 | ||
|
|
c097678cdf | ||
|
|
0364e8dc5e | ||
|
|
650d3148e9 | ||
|
|
511580a47e | ||
|
|
0be9764194 | ||
|
|
c40e1f03db | ||
|
|
3a5d999006 | ||
|
|
832a2031bb | ||
|
|
1042270298 | ||
|
|
436bab0c8a | ||
|
|
4792cba794 | ||
|
|
39099a54b8 | ||
|
|
c9d842bd33 | ||
|
|
ee4d72c706 | ||
|
|
53e477bc00 | ||
|
|
58fc2bb434 | ||
|
|
6e75c72492 | ||
|
|
3ed5567c8a | ||
|
|
ad08868778 | ||
|
|
5b6e974248 | ||
|
|
04b04fe5ee | ||
|
|
9d5a47b1c3 | ||
|
|
5c90449707 | ||
|
|
e490bf1dab | ||
|
|
b131876e69 | ||
|
|
b7d2e6d3ec | ||
|
|
09ed46b997 | ||
|
|
a4d365c8f2 | ||
|
|
13538dd710 | ||
|
|
e60350be08 | ||
|
|
b0c36f2bab | ||
|
|
d708f099be | ||
|
|
cb2fe91998 | ||
|
|
d7820b7a84 | ||
|
|
fb0fbb79f6 | ||
|
|
c97c000d9c | ||
|
|
c4e4a28bc0 | ||
|
|
39e461b872 | ||
|
|
734e15e5a3 | ||
|
|
90bf6571d6 | ||
|
|
90fe71bd54 | ||
|
|
89a500cd3e | ||
|
|
a1ac402154 | ||
|
|
f7eac82593 | ||
|
|
4a9c2450b7 | ||
|
|
27f33be0e7 | ||
|
|
4c11f7c40b | ||
|
|
50405c3b6a | ||
|
|
8406559150 | ||
|
|
f92a296ce0 | ||
|
|
6786024333 | ||
|
|
56855dd6fd | ||
|
|
00a5b27586 | ||
|
|
73ed98c623 | ||
|
|
927309e867 | ||
|
|
2d2ee02ce3 | ||
|
|
4d4fbf7257 | ||
|
|
67c93dba49 | ||
|
|
2d9c7747fb | ||
|
|
4fa1db8d06 | ||
|
|
92be6c0cba | ||
|
|
4f0b264886 | ||
|
|
bad304c2d3 | ||
|
|
0785ee31f0 | ||
|
|
faf664d803 | ||
|
|
6fb2fe2283 | ||
|
|
cdce9a1235 | ||
|
|
285d4edf23 | ||
|
|
5c86956fef | ||
|
|
80bdb4d45b | ||
|
|
7558bd7947 | ||
|
|
16944da45b | ||
|
|
55ec13f153 | ||
|
|
c36f4cfee4 | ||
|
|
ac375f152d | ||
|
|
24bdd3c8ad | ||
|
|
7e8187ddc7 | ||
|
|
2e45650f22 | ||
|
|
6c319d05dd | ||
|
|
9eafab5a50 | ||
|
|
efff5b8454 | ||
|
|
2c2ded7492 | ||
|
|
7932e38d0d | ||
|
|
5e5fc97798 | ||
|
|
868479c493 | ||
|
|
0eacc98e17 | ||
|
|
ea4d6bb7ce | ||
|
|
40543128f7 | ||
|
|
eac392e366 | ||
|
|
5396811c58 | ||
|
|
11383fa356 | ||
|
|
322bb1d962 | ||
|
|
2402620d2f | ||
|
|
3ac3b71ad3 | ||
|
|
02e5b56408 | ||
|
|
60531b5e1b | ||
|
|
2415858d8f | ||
|
|
9f01b4b7a2 | ||
|
|
425aa20747 | ||
|
|
685a3d0ee2 | ||
|
|
53e1be4da6 | ||
|
|
eb6d19969d | ||
|
|
df5c59263e | ||
|
|
059d543c97 | ||
|
|
eb16829f51 | ||
|
|
b1d3867da9 | ||
|
|
006a79ed01 | ||
|
|
0d80394180 | ||
|
|
9721dadbe9 | ||
|
|
9bd1407ca6 | ||
|
|
3905b2e864 | ||
|
|
b92fb9392e | ||
|
|
5fc1fb1761 | ||
|
|
43ef9d77c1 | ||
|
|
1348cf908a | ||
|
|
b3d476ce49 | ||
|
|
53e064c59c | ||
|
|
4d102df2cf | ||
|
|
685e86cffc | ||
|
|
fedf155efd | ||
|
|
634a9f1e9a | ||
|
|
79dac91a7a | ||
|
|
b2af00b037 | ||
|
|
652793e00a | ||
|
|
5da0423e48 | ||
|
|
a978865f39 | ||
|
|
1955fa6371 | ||
|
|
5a63bc56f4 | ||
|
|
cb4051f4ea | ||
|
|
0a84c613f7 | ||
|
|
6bd5d4c410 | ||
|
|
90c7e591e8 | ||
|
|
fd30eafafc | ||
|
|
8d3946c36c | ||
|
|
75247efbd5 | ||
|
|
660b4d3cec | ||
|
|
c8274ba88a | ||
|
|
8b54d27cbc | ||
|
|
d359bffed5 | ||
|
|
b2c88e4312 | ||
|
|
ab9f07dd8a | ||
|
|
f974812dfc | ||
|
|
400934f19c | ||
|
|
82cf11aaae | ||
|
|
3bf8d483d3 | ||
|
|
84d822450e | ||
|
|
cccccca18e | ||
|
|
936deaa869 | ||
|
|
946152d820 | ||
|
|
44d13919d7 | ||
|
|
2b6d7d4ec2 | ||
|
|
f11aea3223 | ||
|
|
2b75df6132 | ||
|
|
b45f0a9ad1 | ||
|
|
4b260eb516 | ||
|
|
14c33aad0b | ||
|
|
e22b497a08 | ||
|
|
91865ba66e | ||
|
|
4d31cd4013 | ||
|
|
0775f59a6f | ||
|
|
5feaa7a101 | ||
|
|
4cd9e76bd2 | ||
|
|
78fe3e4df3 | ||
|
|
72e7c7e1ed | ||
|
|
b0c6631979 | ||
|
|
1e5e17f000 | ||
|
|
20de118373 | ||
|
|
fd4e26ca9b | ||
|
|
f89e5604d2 | ||
|
|
ca3e55813e | ||
|
|
3fad128d02 | ||
|
|
0cb9da17cd | ||
|
|
1571f002c7 | ||
|
|
84478a2dba | ||
|
|
93065b20f7 | ||
|
|
bb8762e122 | ||
|
|
15a2e3bdcb | ||
|
|
64ba05cea5 | ||
|
|
1521ceb381 | ||
|
|
4338f5b46c | ||
|
|
e8a64fb569 | ||
|
|
cf1f849b68 | ||
|
|
bc0886ad7c | ||
|
|
12fcfe53eb | ||
|
|
a198a33ff5 | ||
|
|
7c85341a42 | ||
|
|
c534c857a4 | ||
|
|
6d8c7c4d2d | ||
|
|
f0d96f8e5a | ||
|
|
f8d4758e6b | ||
|
|
09b498da61 | ||
|
|
221b6665a0 | ||
|
|
94dd36a4c9 | ||
|
|
84c1ff560f | ||
|
|
b7f9092e93 | ||
|
|
ade05607d2 | ||
|
|
6d13796b2d | ||
|
|
191cea47a0 | ||
|
|
0aa4868b31 | ||
|
|
e7016ef6b8 | ||
|
|
4332eca9ca | ||
|
|
fc31d31493 | ||
|
|
8ba71cc189 | ||
|
|
38c1b08433 | ||
|
|
21a5d4f061 | ||
|
|
1daa131480 | ||
|
|
f33a5191df | ||
|
|
d6c3896fd9 | ||
|
|
f2da4b861b | ||
|
|
eec9804567 | ||
|
|
f58cfe9008 | ||
|
|
02adadbbc8 | ||
|
|
086bc2beee | ||
|
|
1eb3b31cfa | ||
|
|
831d0bbf6e | ||
|
|
d4fa12c3f6 | ||
|
|
f993b1d856 | ||
|
|
f3de568969 | ||
|
|
2faacc59f1 | ||
|
|
65cfe4bda4 | ||
|
|
fa20f313d3 | ||
|
|
b2247450f9 | ||
|
|
d89ecae615 | ||
|
|
24dff12652 | ||
|
|
d06e3e9494 | ||
|
|
b7802ae271 | ||
|
|
d53d601043 | ||
|
|
ca318f29cf | ||
|
|
ee1c7e6956 | ||
|
|
e3e1c08525 | ||
|
|
71e83dc42f | ||
|
|
1fcca3b32c | ||
|
|
e925aa5438 | ||
|
|
654439005a | ||
|
|
906205f97a | ||
|
|
34b95e7f01 | ||
|
|
49e3011a28 | ||
|
|
771f164e26 | ||
|
|
ce8dc46054 | ||
|
|
b6181c2d31 | ||
|
|
6225ec3008 | ||
|
|
26737d3d70 | ||
|
|
da85814940 | ||
|
|
1b5d082d1a | ||
|
|
03e54a1a21 | ||
|
|
91693aba84 | ||
|
|
f3a4732dbe | ||
|
|
90feb8050c | ||
|
|
d46d92528a | ||
|
|
bb889dae99 | ||
|
|
b6c43088fb | ||
|
|
fdc510d3a0 | ||
|
|
a64be66353 | ||
|
|
2f177dde7a | ||
|
|
8e9134d255 | ||
|
|
84796bc74d | ||
|
|
3dae11d292 | ||
|
|
4173ac0ca0 | ||
|
|
9954eefdee | ||
|
|
9800203a48 | ||
|
|
14d78327c3 | ||
|
|
6eab2a89e9 | ||
|
|
c3d2129472 | ||
|
|
829f3f7460 | ||
|
|
a4a6713550 | ||
|
|
65b814c7dc | ||
|
|
f2a23039cb | ||
|
|
c49743c4a1 | ||
|
|
337820a168 | ||
|
|
43d9e2acc8 | ||
|
|
3efe6dcff0 | ||
|
|
1fdfefc4de | ||
|
|
68d32eb183 | ||
|
|
aa547cf66b | ||
|
|
a9ea9f3e37 | ||
|
|
cb319736a2 | ||
|
|
51f7ef3171 | ||
|
|
af38c6e135 | ||
|
|
1c4c22b060 | ||
|
|
e3295290ac | ||
|
|
84f5818e09 | ||
|
|
67707f2716 | ||
|
|
ed70827f39 | ||
|
|
111935f4eb | ||
|
|
fa0af97aa6 | ||
|
|
eee4a772d7 | ||
|
|
c783b7b093 | ||
|
|
4d840dafd4 | ||
|
|
39f100b4bb | ||
|
|
088a6c14c0 | ||
|
|
30f7842e8f | ||
|
|
c2ad4a02ef | ||
|
|
93c6c3e5f8 | ||
|
|
92afb826d9 | ||
|
|
4bcf98a9e3 | ||
|
|
2025dee9d0 | ||
|
|
5bb177d33b | ||
|
|
4cada20e38 | ||
|
|
f9e6a1c5ea | ||
|
|
dec47d72d9 | ||
|
|
5d9dc0c4d5 | ||
|
|
0b78379b77 | ||
|
|
6a61066e2c | ||
|
|
f0a8f207f8 | ||
|
|
ae43b6c4cd | ||
|
|
f286a5beea | ||
|
|
3f2c33ad46 | ||
|
|
461387636a | ||
|
|
0d2a5c04fa | ||
|
|
9c3c0f9140 | ||
|
|
a7e1753834 | ||
|
|
0aa7c768ba | ||
|
|
bdb8f6f535 | ||
|
|
c1cfc7c743 | ||
|
|
76c5ec56b5 | ||
|
|
a1db839306 | ||
|
|
3f1c841570 | ||
|
|
1a0249bf5d | ||
|
|
e0cb0302b9 | ||
|
|
4853433ad7 | ||
|
|
47223b375f | ||
|
|
98f2736233 | ||
|
|
f19007cd83 | ||
|
|
2f5f974a0e | ||
|
|
88141ab456 | ||
|
|
354f64faa8 | ||
|
|
17b1d8f732 | ||
|
|
283390e6d5 | ||
|
|
dcb04a4802 | ||
|
|
0dfd909646 | ||
|
|
00219cfd04 | ||
|
|
fbf10ba4c6 | ||
|
|
2a4945ad0b | ||
|
|
3ba4d09294 | ||
|
|
013e53881b | ||
|
|
9fb87b9d86 | ||
|
|
b557688ab3 |
121
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
Normal file
121
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
name: Bug report
|
||||
description: Something is broken or doesn't behave the way it should.
|
||||
title: "bug: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Before opening, please:
|
||||
|
||||
- search existing issues for the same symptom
|
||||
- try to reproduce on the latest version
|
||||
- if it's a UI bug, capture a screenshot or short video. text descriptions of UI bugs almost always lose detail.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What's broken
|
||||
description: What happened, and what did you expect to happen instead?
|
||||
placeholder: |
|
||||
I tried to X, expected Y, got Z.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: The shortest sequence that triggers the bug. If you can't reproduce on demand, say so.
|
||||
placeholder: |
|
||||
1. Open the desktop app
|
||||
2. Pair a daemon
|
||||
3. Click X
|
||||
4. ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: surface
|
||||
attributes:
|
||||
label: Where did this happen
|
||||
description: The surface you saw the bug on. Pick the closest match.
|
||||
options:
|
||||
- iOS app
|
||||
- Android app
|
||||
- Web (browser)
|
||||
- Desktop (Electron)
|
||||
- CLI
|
||||
- Daemon
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: paseo-version
|
||||
attributes:
|
||||
label: Paseo version
|
||||
description: Settings → About in the app, or `paseo --version` from the CLI.
|
||||
placeholder: "0.1.71"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os-version
|
||||
attributes:
|
||||
label: OS version
|
||||
description: Only relevant for desktop, CLI, or daemon issues. Skip for mobile or web.
|
||||
placeholder: "macOS 15.2, Windows 11, Ubuntu 24.04"
|
||||
|
||||
- type: dropdown
|
||||
id: provider
|
||||
attributes:
|
||||
label: Agent provider
|
||||
description: If the bug involves a specific agent provider, pick which one.
|
||||
options:
|
||||
- Not relevant
|
||||
- Claude Code
|
||||
- Codex
|
||||
- OpenCode
|
||||
- Custom provider
|
||||
|
||||
- type: textarea
|
||||
id: provider-details
|
||||
attributes:
|
||||
label: Provider configuration
|
||||
description: |
|
||||
If the bug involves an agent, what version are you on and what API are you using? Provider behavior changes a lot across versions and API backends.
|
||||
placeholder: |
|
||||
Claude Code v1.2.3 with Anthropic API
|
||||
Codex CLI v0.5.0 with OpenAI API
|
||||
OpenCode v0.3.1
|
||||
(or paste the relevant section of ~/.paseo/config.json for custom providers)
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: |
|
||||
Paste relevant log output. Strongly preferred for crashes and daemon issues.
|
||||
|
||||
- **Daemon log:** `~/.paseo/daemon.log` (override with `$PASEO_HOME`)
|
||||
- **Electron log (macOS):** `~/Library/Logs/Paseo/main.log`
|
||||
- **Electron log (Windows):** `%APPDATA%\Paseo\logs\main.log`
|
||||
- **Electron log (Linux):** `~/.config/Paseo/logs/main.log`
|
||||
|
||||
Paste the **full log around the time of the bug**, not a summary. If you used an AI to investigate, paste the raw log it read, not the AI's interpretation. AI summaries skew the signal and waste my time.
|
||||
render: text
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots or video
|
||||
description: |
|
||||
**Required for UI bugs.** Drag and drop directly into this field. Short videos beat screenshots for anything involving interaction or animation.
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
---
|
||||
|
||||
**A note on AI-assisted reports.** Using an agent to gather information (logs, repro steps, version checks) is fine and useful. Using an agent to *diagnose* the bug and then submitting only that diagnosis is not. Agents routinely correlate adjacent log lines as cause-and-effect when they aren't related, and once a report is filtered through an AI summary I lose the signal I need to actually fix the bug. Paste the raw inputs.
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discord
|
||||
url: https://discord.gg/jz8T2uahpH
|
||||
about: Quick questions, sharing a video of a bug, or anything that's better as a chat. A lot of issues start better here.
|
||||
43
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
43
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Feature request
|
||||
description: Propose a new feature or a change to existing behavior.
|
||||
title: "feat: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Paseo is opinionated and maintained by one person. Feature requests are welcome, but they get evaluated against product fit, not just usefulness, and the bar is whether the change keeps the product lean enough for one person to maintain.
|
||||
|
||||
Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first. And please don't open a feature request and a PR at the same time, get alignment on the idea before writing code.
|
||||
|
||||
- type: checkboxes
|
||||
id: prior-search
|
||||
attributes:
|
||||
label: Prior search
|
||||
options:
|
||||
- label: I searched existing issues and discussions, and this isn't already proposed.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What's the problem
|
||||
description: What are you actually trying to do, and why is the current behavior in the way?
|
||||
placeholder: |
|
||||
When I'm doing X, I want to Y, but Paseo currently Z.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: What would solve it
|
||||
description: A rough sketch of the change. Mockups, screenshots from other apps, or a short video are very welcome, especially for UI proposals.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives you considered
|
||||
description: Optional. What else did you try, and why doesn't it work?
|
||||
43
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
43
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
<!--
|
||||
Please follow this template. The PR template applies whether you opened the PR via the web UI, `gh pr create`, or any other tool.
|
||||
|
||||
If you're fixing an objective bug or a small focused issue, this should be quick. Big PRs without a prior issue or design discussion are likely to be closed or scoped down. See CONTRIBUTING.md.
|
||||
-->
|
||||
|
||||
### Linked issue
|
||||
|
||||
Closes #
|
||||
|
||||
<!-- Bug fixes and behavior changes should reference an issue. Pure docs and refactors can skip this. -->
|
||||
|
||||
### Type of change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature (with prior issue + design alignment)
|
||||
- [ ] Refactor / code improvement
|
||||
- [ ] Docs
|
||||
|
||||
### What does this PR do
|
||||
|
||||
<!-- A short description of the change in your own words. What was wrong, what you changed, why it works. If you can't explain this briefly, the PR is probably too big. -->
|
||||
|
||||
### How did you verify it
|
||||
|
||||
<!--
|
||||
This is the section I read most carefully. I need to see that *you* tested this, not that the diff looks plausible.
|
||||
|
||||
- For UI changes: a screenshot or short video on every affected platform (mobile, web, desktop). UI claims without visual proof are not enough.
|
||||
- For behavior changes: the actual steps you ran, and what you observed.
|
||||
- For bug fixes: how you reproduced the bug before, and confirmed it's fixed after.
|
||||
|
||||
AI-generated PR descriptions are fine in principle. AI-generated *verification claims* with no actual testing behind them are not, and they're easy to spot.
|
||||
-->
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] One focused change. Unrelated cleanups split out.
|
||||
- [ ] `npm run typecheck` passes
|
||||
- [ ] `npm run lint` passes
|
||||
- [ ] `npm run format` ran (Biome)
|
||||
- [ ] UI changes include screenshots or video for every affected platform
|
||||
- [ ] Tests added or updated where it made sense
|
||||
106
.github/workflows/ci.yml
vendored
106
.github/workflows/ci.yml
vendored
@@ -5,8 +5,18 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
# CI does not use the CUDA execution provider, and the onnxruntime-node
|
||||
# postinstall download from NuGet is large enough to make npm ci flaky.
|
||||
ONNXRUNTIME_NODE_INSTALL: skip
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -15,11 +25,11 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Check formatting
|
||||
run: npx oxfmt --check .
|
||||
@@ -31,11 +41,17 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Lint lockfile
|
||||
run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity
|
||||
|
||||
- name: Verify dependency signatures
|
||||
run: npm audit signatures
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
@@ -47,11 +63,11 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
@@ -66,7 +82,12 @@ jobs:
|
||||
run: npm run typecheck
|
||||
|
||||
server-tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: server-tests (${{ matrix.os }})
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -74,14 +95,17 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Fetch origin/main (worktree tests)
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
@@ -95,20 +119,22 @@ jobs:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
server-tests-windows:
|
||||
runs-on: windows-latest
|
||||
desktop-tests:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
@@ -116,24 +142,11 @@ jobs:
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Run Windows-critical server tests
|
||||
working-directory: packages/server
|
||||
run: >
|
||||
npx vitest run
|
||||
src/utils/executable.test.ts
|
||||
src/utils/spawn.launch-regression.test.ts
|
||||
src/utils/spawn.test.ts
|
||||
src/utils/run-git-command.test.ts
|
||||
src/utils/checkout-git-rev-parse.test.ts
|
||||
src/server/agent/provider-registry.test.ts
|
||||
src/server/agent/provider-launch-config.test.ts
|
||||
src/server/agent/provider-snapshot-manager.test.ts
|
||||
src/server/agent/providers/claude-agent.spawn.test.ts
|
||||
src/server/agent/providers/provider-windows-launch.test.ts
|
||||
src/server/agent/providers/provider-availability.test.ts
|
||||
src/server/workspace-registry-model.test.ts
|
||||
src/server/persisted-config.test.ts
|
||||
src/server/bootstrap-provider-availability.test.ts
|
||||
- name: Build server dependency
|
||||
run: npm run build --workspace=@getpaseo/server
|
||||
|
||||
- name: Run desktop tests
|
||||
run: npm run test --workspace=@getpaseo/desktop
|
||||
|
||||
app-tests:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -142,11 +155,11 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
@@ -164,11 +177,11 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
@@ -183,7 +196,7 @@ jobs:
|
||||
run: npm run build --workspace=@getpaseo/server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @openai/codex@0.105.0 opencode-ai
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
run: npm run test:e2e --workspace=@getpaseo/app
|
||||
@@ -207,11 +220,11 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Build relay
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
@@ -220,20 +233,25 @@ jobs:
|
||||
run: npm run test --workspace=@getpaseo/relay
|
||||
|
||||
cli-tests:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
runs-on: ubuntu-latest
|
||||
name: cli-tests (shard ${{ matrix.shard }}/3)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @openai/codex@0.105.0 opencode-ai
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
@@ -244,3 +262,5 @@ jobs:
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
||||
PASEO_DICTATION_ENABLED: "0"
|
||||
PASEO_VOICE_MODE_ENABLED: "0"
|
||||
PASEO_CLI_TEST_SHARD: ${{ matrix.shard }}
|
||||
PASEO_CLI_TEST_SHARD_TOTAL: "3"
|
||||
|
||||
2
.github/workflows/deploy-app.yml
vendored
2
.github/workflows/deploy-app.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --workspace=@getpaseo/app --include-workspace-root
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
2
.github/workflows/deploy-relay.yml
vendored
2
.github/workflows/deploy-relay.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --workspace=@getpaseo/relay --include-workspace-root
|
||||
run: npm ci --workspace=@getpaseo/relay --include-workspace-root
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/relay
|
||||
|
||||
2
.github/workflows/deploy-website.yml
vendored
2
.github/workflows/deploy-website.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --workspace=@getpaseo/website --include-workspace-root
|
||||
run: npm ci --workspace=@getpaseo/website --include-workspace-root
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/website
|
||||
|
||||
237
.github/workflows/desktop-release.yml
vendored
237
.github/workflows/desktop-release.yml
vendored
@@ -40,7 +40,7 @@ on:
|
||||
rollout_hours:
|
||||
description: "Linear rollout duration in hours. Use 0 for instant rollout."
|
||||
required: false
|
||||
default: "24"
|
||||
default: "36"
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
@@ -51,8 +51,7 @@ env:
|
||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
CHECKOUT_REF: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.checkout_ref || github.ref_name) || github.ref_name }}
|
||||
SHOULD_PUBLISH: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false' }}
|
||||
ROLLOUT_HOURS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.rollout_hours || '24' }}
|
||||
DESKTOP_WORKSPACE: "@getpaseo/desktop"
|
||||
ROLLOUT_HOURS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.rollout_hours || '36' }}
|
||||
DESKTOP_PACKAGE_PATH: "packages/desktop"
|
||||
|
||||
jobs:
|
||||
@@ -148,9 +147,6 @@ jobs:
|
||||
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build web app for desktop
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
env:
|
||||
@@ -164,16 +160,28 @@ jobs:
|
||||
PASEO_DESKTOP_SMOKE: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish_mode="never"
|
||||
build_args=(-- --publish "$publish_mode" --mac --${{ matrix.electron_arch }})
|
||||
if [[ "$SHOULD_PUBLISH" == "true" && "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
build_args=(-- --publish "$publish_mode" --mac --${{ matrix.electron_arch }})
|
||||
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
fi
|
||||
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
|
||||
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
npm run build:desktop "${build_args[@]}"
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
|
||||
- name: Upload desktop artifacts to release
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
release_dir="${DESKTOP_PACKAGE_PATH}/release"
|
||||
files=()
|
||||
while IFS= read -r -d '' f; do
|
||||
files+=("$f")
|
||||
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
|
||||
if (( ${#files[@]} == 0 )); then
|
||||
echo "::error::No release artifacts found in $release_dir"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Upload manifest artifact
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
@@ -183,63 +191,6 @@ jobs:
|
||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-mac.yml
|
||||
retention-days: 1
|
||||
|
||||
finalize-mac-manifest:
|
||||
needs: [publish-macos]
|
||||
if: ${{ needs.publish-macos.result == 'success' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
package.json
|
||||
package-lock.json
|
||||
scripts
|
||||
ref: ${{ env.CHECKOUT_REF }}
|
||||
|
||||
- name: Resolve release tag
|
||||
shell: bash
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: package-lock.json
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Download manifest artifacts
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: mac-manifest-*
|
||||
|
||||
- name: Merge manifests
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
manifest_name="${RELEASE_CHANNEL}-mac.yml"
|
||||
node scripts/merge-mac-manifest.mjs \
|
||||
"mac-manifest-arm64/${manifest_name}" \
|
||||
"mac-manifest-x64/${manifest_name}" \
|
||||
"${manifest_name}"
|
||||
|
||||
- name: Upload merged manifest to release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "$RELEASE_TAG" "$RELEASE_CHANNEL-mac.yml" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
publish-linux:
|
||||
needs: [create-release]
|
||||
if: ${{ always() && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') && ((github.event_name == 'workflow_dispatch' && (github.event.inputs.platform == 'all' || github.event.inputs.platform == 'linux')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-linux-v')))) }}
|
||||
@@ -288,9 +239,6 @@ jobs:
|
||||
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build web app for desktop
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Install Linux smoke display
|
||||
run: sudo apt-get update && sudo apt-get install -y xvfb
|
||||
|
||||
@@ -302,16 +250,36 @@ jobs:
|
||||
PASEO_DESKTOP_SMOKE: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish_mode="never"
|
||||
build_args=(-- --publish "$publish_mode" --linux --x64)
|
||||
if [[ "$SHOULD_PUBLISH" == "true" && "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
build_args=(-- --publish "$publish_mode" --linux --x64)
|
||||
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
fi
|
||||
build_args=(-- --publish never --linux --x64)
|
||||
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
npm run build:desktop "${build_args[@]}"
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
|
||||
- name: Upload desktop artifacts to release
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
release_dir="${DESKTOP_PACKAGE_PATH}/release"
|
||||
files=()
|
||||
while IFS= read -r -d '' f; do
|
||||
files+=("$f")
|
||||
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
|
||||
if (( ${#files[@]} == 0 )); then
|
||||
echo "::error::No release artifacts found in $release_dir"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Upload manifest artifact
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-manifest
|
||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}-linux.yml
|
||||
retention-days: 1
|
||||
|
||||
publish-windows:
|
||||
needs: [create-release]
|
||||
@@ -361,17 +329,6 @@ jobs:
|
||||
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build workspace dependencies
|
||||
run: npm run build:workspace-deps --workspace=@getpaseo/app
|
||||
|
||||
- name: Build web app for desktop
|
||||
shell: pwsh
|
||||
run: |
|
||||
$patchPath = (Get-Item "$env:GITHUB_WORKSPACE/scripts/metro-config-windows-loader-patch.cjs").FullName
|
||||
$env:NODE_OPTIONS = "--require=$patchPath"
|
||||
npx expo export --platform web
|
||||
working-directory: packages/app
|
||||
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
env:
|
||||
@@ -380,20 +337,40 @@ jobs:
|
||||
PASEO_DESKTOP_SMOKE: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish_mode="never"
|
||||
build_args=(-- --publish "$publish_mode" --win --x64)
|
||||
if [[ "$SHOULD_PUBLISH" == "true" && "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
build_args=(-- --publish "$publish_mode" --win --x64)
|
||||
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
fi
|
||||
build_args=(-- --publish never --win --x64 --arm64)
|
||||
build_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||
npm run build:desktop "${build_args[@]}"
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
|
||||
- name: Upload desktop artifacts to release
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
release_dir="${DESKTOP_PACKAGE_PATH}/release"
|
||||
files=()
|
||||
while IFS= read -r -d '' f; do
|
||||
files+=("$f")
|
||||
done < <(find "$release_dir" -maxdepth 1 -type f ! -name '*.yml' -print0 | sort -z)
|
||||
if (( ${#files[@]} == 0 )); then
|
||||
echo "::error::No release artifacts found in $release_dir"
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Upload manifest artifact
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-manifest
|
||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/${{ env.RELEASE_CHANNEL }}.yml
|
||||
retention-days: 1
|
||||
|
||||
finalize-rollout:
|
||||
needs: [publish-macos, finalize-mac-manifest, publish-linux, publish-windows]
|
||||
if: ${{ always() && (needs.publish-macos.result == 'success' || needs.publish-macos.result == 'skipped') && (needs.finalize-mac-manifest.result == 'success' || needs.finalize-mac-manifest.result == 'skipped') && (needs.publish-linux.result == 'success' || needs.publish-linux.result == 'skipped') && (needs.publish-windows.result == 'success' || needs.publish-windows.result == 'skipped') && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }}
|
||||
needs: [publish-macos, publish-linux, publish-windows]
|
||||
if: ${{ always() && (needs.publish-macos.result == 'success' || needs.publish-macos.result == 'skipped') && (needs.publish-linux.result == 'success' || needs.publish-linux.result == 'skipped') && (needs.publish-windows.result == 'success' || needs.publish-windows.result == 'skipped') && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
@@ -428,20 +405,58 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Stamp rollout metadata
|
||||
- name: Download mac manifest artifacts
|
||||
if: env.IS_SMOKE_TAG != 'true' && needs.publish-macos.result == 'success'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: mac-manifest-*
|
||||
path: release-manifests
|
||||
|
||||
- name: Download Linux manifest artifact
|
||||
if: env.IS_SMOKE_TAG != 'true' && needs.publish-linux.result == 'success'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: linux-manifest
|
||||
path: release-manifests/linux-manifest
|
||||
|
||||
- name: Download Windows manifest artifact
|
||||
if: env.IS_SMOKE_TAG != 'true' && needs.publish-windows.result == 'success'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-manifest
|
||||
path: release-manifests/windows-manifest
|
||||
|
||||
- name: Assemble and upload stamped manifests
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir release-manifests
|
||||
cd release-manifests
|
||||
gh release download "$RELEASE_TAG" --repo "${{ github.repository }}" --pattern "${RELEASE_CHANNEL}*.yml"
|
||||
manifests_dir="$PWD/final"
|
||||
mkdir -p "$manifests_dir"
|
||||
|
||||
if [[ "${{ needs.publish-macos.result }}" == "success" ]]; then
|
||||
manifest_name="${RELEASE_CHANNEL}-mac.yml"
|
||||
node ../scripts/merge-mac-manifest.mjs \
|
||||
"mac-manifest-arm64/${manifest_name}" \
|
||||
"mac-manifest-x64/${manifest_name}" \
|
||||
"$manifests_dir/${manifest_name}"
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.publish-linux.result }}" == "success" ]]; then
|
||||
cp "linux-manifest/${RELEASE_CHANNEL}-linux.yml" "$manifests_dir/"
|
||||
fi
|
||||
|
||||
if [[ "${{ needs.publish-windows.result }}" == "success" ]]; then
|
||||
cp "windows-manifest/${RELEASE_CHANNEL}.yml" "$manifests_dir/"
|
||||
fi
|
||||
|
||||
shopt -s nullglob
|
||||
files=( ./*.yml )
|
||||
files=( "$manifests_dir"/*.yml )
|
||||
if (( ${#files[@]} == 0 )); then
|
||||
echo "::error::No manifests matched ${RELEASE_CHANNEL}*.yml on $RELEASE_TAG"
|
||||
echo "::error::No manifest artifacts were available to publish"
|
||||
exit 1
|
||||
fi
|
||||
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
|
||||
|
||||
45
.github/workflows/fix-nix-hash.yml
vendored
45
.github/workflows/fix-nix-hash.yml
vendored
@@ -1,45 +0,0 @@
|
||||
name: Fix Nix hash
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
pull_request:
|
||||
paths:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
fix-nix-hash:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref || github.ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Fix lockfile and update hash
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Commit changes
|
||||
run: |
|
||||
git diff --quiet package-lock.json nix/package.nix && exit 0
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add package-lock.json nix/package.nix
|
||||
git commit -m "fix: update lockfile signatures and Nix hash"
|
||||
git push
|
||||
59
.github/workflows/nix-build.yml
vendored
59
.github/workflows/nix-build.yml
vendored
@@ -1,59 +0,0 @@
|
||||
name: Nix Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Verify lockfile is complete
|
||||
# npm silently omits resolved/integrity fields in workspace monorepos.
|
||||
# Nix needs them for offline builds. See https://github.com/npm/cli/issues/4460
|
||||
run: |
|
||||
node scripts/fix-lockfile.mjs package-lock.json
|
||||
git diff --exit-code package-lock.json || {
|
||||
echo "ERROR: package-lock.json has missing resolved/integrity fields."
|
||||
echo "This is a known npm bug: https://github.com/npm/cli/issues/4460"
|
||||
echo "Run 'node scripts/fix-lockfile.mjs' and commit the result."
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Check npmDepsHash is up to date
|
||||
run: ./scripts/update-nix.sh --check
|
||||
60
.github/workflows/nix-update-hash.yml
vendored
Normal file
60
.github/workflows/nix-update-hash.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Nix Update Hash
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix-update-hash.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-hash:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.PASEO_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.PASEO_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Update lockfile + Nix hash if stale
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Commit hash/lockfile updates
|
||||
run: |
|
||||
git diff --quiet package-lock.json nix/npm-deps.hash && exit 0
|
||||
git config user.name "paseo-ai[bot]"
|
||||
git config user.email "266920839+paseo-ai[bot]@users.noreply.github.com"
|
||||
git add package-lock.json nix/npm-deps.hash
|
||||
git commit -m "fix: update lockfile signatures and Nix hash [skip ci]"
|
||||
git push
|
||||
99
.github/workflows/nix.yml
vendored
Normal file
99
.github/workflows/nix.yml
vendored
Normal file
@@ -0,0 +1,99 @@
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Update lockfile + Nix hash if stale
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Smoke Nix daemon
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
export PASEO_HOME
|
||||
PASEO_HOME="$(mktemp -d)"
|
||||
export PASEO_LISTEN=127.0.0.1:6767
|
||||
|
||||
WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log"
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
|
||||
kill "$DAEMON_PID"
|
||||
wait "$DAEMON_PID" || true
|
||||
fi
|
||||
rm -rf "$PASEO_HOME"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
|
||||
DAEMON_PID=$!
|
||||
|
||||
deadline=$((SECONDS + 30))
|
||||
while (( SECONDS < deadline )); do
|
||||
if STATUS_JSON="$(./result/bin/paseo daemon status --json)" \
|
||||
&& jq -e '.connectedDaemon == "reachable"' <<<"$STATUS_JSON" >/dev/null; then
|
||||
echo "$STATUS_JSON"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
|
||||
echo "Nix daemon exited before becoming reachable."
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "::group::daemon.log"
|
||||
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::paseo-server stdout/stderr"
|
||||
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::paseo daemon status"
|
||||
./result/bin/paseo daemon status || true
|
||||
echo "::endgroup::"
|
||||
|
||||
exit 1
|
||||
|
||||
- name: Build Nix desktop package
|
||||
run: nix build .#desktop -o result-desktop
|
||||
52
.github/workflows/server-ci.yml
vendored
52
.github/workflows/server-ci.yml
vendored
@@ -1,52 +0,0 @@
|
||||
name: Server CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/server/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".github/workflows/server-ci.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/server/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".github/workflows/server-ci.yml"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Fetch origin/main (worktree tests)
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
|
||||
- name: Install server dependencies
|
||||
run: npm install --workspace=@getpaseo/server --include-workspace-root
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/server
|
||||
|
||||
- name: Test
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -6,6 +6,7 @@ build/
|
||||
dist/
|
||||
.next/
|
||||
out/
|
||||
result
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
@@ -13,6 +14,8 @@ out/
|
||||
.env.local
|
||||
.env.test.local
|
||||
.env*.local
|
||||
.dev.vars
|
||||
**/.dev.vars
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -64,6 +67,7 @@ CLAUDE.local.md
|
||||
.paseo/
|
||||
.wrangler/
|
||||
**/.wrangler/
|
||||
**/.tanstack/
|
||||
|
||||
# Local agent/tooling artifacts (do not commit)
|
||||
PLAN.md
|
||||
@@ -83,3 +87,4 @@ packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript
|
||||
/artifacts
|
||||
packages/desktop/.cache/
|
||||
packages/desktop/src-tauri/resources/managed-runtime/
|
||||
app.json
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"arrowParens": "always",
|
||||
"bracketSameLine": false,
|
||||
"bracketSpacing": true,
|
||||
"ignorePatterns": ["*.lock"]
|
||||
"ignorePatterns": ["*.lock", "**/*.gen.ts", "**/*.gen.tsx"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"options": {
|
||||
"typeAware": false
|
||||
},
|
||||
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
@@ -53,6 +56,7 @@
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/prefer-as-const": "error",
|
||||
"typescript/no-this-alias": "error",
|
||||
"typescript/no-unnecessary-type-assertion": "error",
|
||||
"typescript/consistent-type-definitions": ["error", "interface"],
|
||||
|
||||
"import/no-unassigned-import": [
|
||||
|
||||
315
CHANGELOG.md
315
CHANGELOG.md
@@ -1,5 +1,318 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.77 - 2026-05-18
|
||||
|
||||
### Added
|
||||
|
||||
- **Slash commands to end and restart an agent.**
|
||||
- **Syntax highlighting for code blocks in chat.**
|
||||
- **Copy button on code blocks in chat.**
|
||||
- **Configurable terminal scrollback.** ([#1021](https://github.com/getpaseo/paseo/pull/1021) by [@32r4](https://github.com/32r4))
|
||||
- Assistant file links open at a specific line range when one is included.
|
||||
- Mode icons appear in the agent status menu. ([#1059](https://github.com/getpaseo/paseo/pull/1059) by [@32r4](https://github.com/32r4))
|
||||
- MCP exposes schedule update, logs, and run-once tools. ([#1032](https://github.com/getpaseo/paseo/pull/1032) by [@skevetter](https://github.com/skevetter))
|
||||
- Self-hosted relays support a separate TLS setting for the public endpoint, so the daemon can reach the relay over the LAN while the phone reaches it over the public secure address. ([#1045](https://github.com/getpaseo/paseo/pull/1045) by [@yzx9](https://github.com/yzx9))
|
||||
|
||||
### Improved
|
||||
|
||||
- User messages now have a distinct bubble fill for clearer chat hierarchy.
|
||||
- Closing a tab returns to its parent tab.
|
||||
- Diff rows show the full file path on hover. ([#1061](https://github.com/getpaseo/paseo/pull/1061) by [@Myriad-Dreamin](https://github.com/Myriad-Dreamin))
|
||||
- The CLI shows the remote daemon host when `ls` cannot connect. ([#1043](https://github.com/getpaseo/paseo/pull/1043) by [@mturac](https://github.com/mturac))
|
||||
- Nix install of the daemon is smaller. ([#966](https://github.com/getpaseo/paseo/pull/966) by [@ixxie](https://github.com/ixxie))
|
||||
- Nix install honors home-manager profile paths when inheriting the user PATH. ([#1040](https://github.com/getpaseo/paseo/pull/1040) by [@ixxie](https://github.com/ixxie))
|
||||
|
||||
### Fixed
|
||||
|
||||
- OpenCode probes no longer create empty sessions.
|
||||
- OpenCode custom commands no longer hang.
|
||||
- OpenCode session imports succeed across more environments.
|
||||
- Native diff rows expand correctly. ([#940](https://github.com/getpaseo/paseo/pull/940) by [@bolasblack](https://github.com/bolasblack))
|
||||
- Mobile sidebar interactions work correctly on web. ([#900](https://github.com/getpaseo/paseo/pull/900) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Mobile web drag gestures fire reliably. ([#1048](https://github.com/getpaseo/paseo/pull/1048) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Mobile web drag-and-drop activates correctly. ([#1048](https://github.com/getpaseo/paseo/pull/1048) by [@nikuscs](https://github.com/nikuscs))
|
||||
- iOS Safari no longer zooms when focusing the composer. ([#1048](https://github.com/getpaseo/paseo/pull/1048) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Enter behavior in the mobile web composer is consistent. ([#1048](https://github.com/getpaseo/paseo/pull/1048) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Composer no longer flickers when resizing with long prompts.
|
||||
- Inline code links in assistant messages open the correct file.
|
||||
- Host switcher popover is wide enough to show host names. ([#981](https://github.com/getpaseo/paseo/pull/981) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
|
||||
- Windows: importing existing sessions matches paths correctly. ([#1012](https://github.com/getpaseo/paseo/pull/1012) by [@kj1534](https://github.com/kj1534))
|
||||
|
||||
## 0.1.76 - 2026-05-15
|
||||
|
||||
### Added
|
||||
|
||||
- **Chat timestamps and turn durations.** Every message shows when it was sent, and each turn surfaces how long the agent took.
|
||||
- **Auto Review permission mode for Claude Code and Codex.** Agents stop after each assistant turn for review instead of running unattended. ([#928](https://github.com/getpaseo/paseo/pull/928), [#963](https://github.com/getpaseo/paseo/pull/963) by [@bolasblack](https://github.com/bolasblack))
|
||||
- Surface Codex's context compaction events and the `/compact` command in chat.
|
||||
- Optional auto-archive for worktrees once their PR merges.
|
||||
- Paste a GitHub PR or issue URL into the composer to attach it as context.
|
||||
- Surface GitHub auto-merge actions in the PR hover card.
|
||||
- Show all PR check counts in the PR hover card.
|
||||
- Rename a project to disambiguate duplicates that share a folder name.
|
||||
- Confirm before archiving a worktree with uncommitted or unpushed work.
|
||||
- Claude Code now picks up models from `~/.claude/settings.json` so custom model lists show up in the model picker.
|
||||
- Local Claude Code settings (`.claude/settings.local.json`) apply per workspace.
|
||||
- Diagnostics for generic ACP providers surface in the model picker.
|
||||
- Allow setting fast mode for Paseo subagents ([#909](https://github.com/getpaseo/paseo/pull/909), [#910](https://github.com/getpaseo/paseo/pull/910) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
|
||||
|
||||
### Improved
|
||||
|
||||
- Surface Claude error messages in chat instead of ending the turn silently.
|
||||
- Workspace checkout picker auto-selects when a single PR is attached.
|
||||
- New workspace flow honors the currently checked-out branch when branching off. ([#909](https://github.com/getpaseo/paseo/pull/908) by [@sbtobb](https://github.com/sbtobb))
|
||||
- OpenCode models from console subscription providers now appear in the model picker. ([#917](https://github.com/getpaseo/paseo/pull/917) by [@t2o2](https://github.com/t2o2))
|
||||
- Cursor model picker reflects the models advertised by the Cursor ACP client. ([#958](https://github.com/getpaseo/paseo/pull/958) by [@chrisbanes](https://github.com/chrisbanes))
|
||||
|
||||
### Fixed
|
||||
|
||||
- iPad hardware Enter submits the composer. ([#919](https://github.com/getpaseo/paseo/pull/919) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
|
||||
- PR status falls back to a non-checks query for fine-grained GitHub tokens. ([#932](https://github.com/getpaseo/paseo/pull/932) by [@32r4](https://github.com/32r4))
|
||||
- ACP errors display as readable text instead of `[object Object]`.
|
||||
- OpenCode no longer hangs on retry when the upstream provider stalls.
|
||||
- Worktree ahead count is correct when the upstream branch has been deleted.
|
||||
- Branch-off worktrees track the correct upstream.
|
||||
- File changes view works on empty repositories with no commits yet.
|
||||
- Assistant message file links open the correct file.
|
||||
- Default thinking option matches the selected model's capabilities.
|
||||
- Shift+Enter works again in terminal input modes.
|
||||
- Duplicate project entries no longer appear after reopening a project.
|
||||
- Pi-backed sessions recover after a Copilot 413 instead of staying stuck.
|
||||
- Skip probing unrelated executable candidates when launching agents.
|
||||
- Relay E2EE reconnects cleanly under racing connect/disconnect.
|
||||
- Workspace kind stays in sync with project kind after reconfiguration.
|
||||
- zsh integration files install with usable runtime modes.
|
||||
- MCP worktree cache refreshes after create and archive. ([#911](https://github.com/getpaseo/paseo/pull/911) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
|
||||
|
||||
## 0.1.75 - 2026-05-12
|
||||
|
||||
### Added
|
||||
|
||||
- Set the speech-to-text language used by dictation and voice mode from settings. ([#941](https://github.com/getpaseo/paseo/pull/941))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Codex resume failures now surface as explicit errors instead of leaving the agent silently stuck. ([#947](https://github.com/getpaseo/paseo/pull/947))
|
||||
- Custom providers extending Codex now route correctly when they set a custom `OPENAI_BASE_URL`. ([#915](https://github.com/getpaseo/paseo/pull/915))
|
||||
- Fixed Copilot's **Allow All** mode (renamed from Autopilot) ([#935](https://github.com/getpaseo/paseo/pull/935))
|
||||
- Desktop: daemon startup no longer fails when a stale PID file is left next to a still-running daemon. ([#913](https://github.com/getpaseo/paseo/pull/913) by [@biaoma-ty](https://github.com/biaoma-ty))
|
||||
- iPhone HEIC photos now attach correctly from the image picker ([#934](https://github.com/getpaseo/paseo/pull/934))
|
||||
- Scheduled agents now archive automatically after each run ([#945](https://github.com/getpaseo/paseo/pull/945))
|
||||
- Windows: Codex command summaries trim `pwsh`, `powershell`, or `cmd` wrappers. ([#931](https://github.com/getpaseo/paseo/pull/931) by [@32r4](https://github.com/32r4))
|
||||
- iPad: settings sidebar and main sidebar respect the top safe area in wide layouts. ([#922](https://github.com/getpaseo/paseo/pull/922), [#937](https://github.com/getpaseo/paseo/pull/937) by [@kongjiadongyuan](https://github.com/kongjiadongyuan))
|
||||
|
||||
## 0.1.74 - 2026-05-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- **OpenCode agent turns no longer stall.** Paseo now follows OpenCode's global event stream, so turns stream reliably without falling back to fragile recovery paths. ([#916](https://github.com/getpaseo/paseo/pull/916))
|
||||
|
||||
## 0.1.73 - 2026-05-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- **OpenCode agents work again on OpenCode 1.14.42+.** ([#895](https://github.com/getpaseo/paseo/pull/895), [#902](https://github.com/getpaseo/paseo/pull/902), [#904](https://github.com/getpaseo/paseo/pull/904) by [@atomlink-ye](https://github.com/atomlink-ye), [@plutofog](https://github.com/plutofog))
|
||||
- Web: opening a workspace no longer hangs in browsers without `crypto.randomUUID`. ([#858](https://github.com/getpaseo/paseo/pull/858) by [@cokekitten](https://github.com/cokekitten))
|
||||
- Codex sub-agent child tool calls now report a final failure state instead of staying as "running". ([#899](https://github.com/getpaseo/paseo/pull/899))
|
||||
- Old relay pairing URLs without an explicit TLS flag work again. ([#896](https://github.com/getpaseo/paseo/pull/896))
|
||||
- macOS: the tab-jump shortcut no longer collides with system shortcuts. ([#859](https://github.com/getpaseo/paseo/pull/859) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Web: the composer no longer triggers a bottom-sheet keyboard on desktop browsers. ([#898](https://github.com/getpaseo/paseo/pull/898) by [@nikuscs](https://github.com/nikuscs))
|
||||
- Windows: git operations no longer flash a console window on each invocation. ([#897](https://github.com/getpaseo/paseo/pull/897))
|
||||
- File explorer no longer follows symlinks outside the workspace root. ([#847](https://github.com/getpaseo/paseo/pull/847) by [@joaosa](https://github.com/joaosa))
|
||||
- Desktop only opens external URLs via http(s) and mailto schemes. ([#845](https://github.com/getpaseo/paseo/pull/845) by [@joaosa](https://github.com/joaosa))
|
||||
- MCP debug request logs now redact request bodies. ([#842](https://github.com/getpaseo/paseo/pull/842) by [@joaosa](https://github.com/joaosa))
|
||||
|
||||
## 0.1.72 - 2026-05-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Codex approval prompts no longer hang.** Fixes a regression introduced in 0.1.70 where Codex agents would wait forever on command and file approvals — the prompt never reached the app and the agent stayed stuck in "running". ([#866](https://github.com/getpaseo/paseo/pull/866), [#869](https://github.com/getpaseo/paseo/pull/869))
|
||||
- **Windows: daemon no longer crashes when Codex emits non-JSON output.** Localized stdout lines from the Codex CLI are now ignored instead of taking down the daemon worker. ([#866](https://github.com/getpaseo/paseo/pull/866))
|
||||
- Drag-and-drop images onto the new workspace screen now works. ([#850](https://github.com/getpaseo/paseo/pull/850))
|
||||
- Archiving a worktree from the toolbar redirects you immediately instead of leaving you on the dead screen for a beat. ([#852](https://github.com/getpaseo/paseo/pull/852))
|
||||
- Pi-backed sessions now shut down cleanly when you close them, releasing extension resources on the Pi side. ([#863](https://github.com/getpaseo/paseo/pull/863))
|
||||
|
||||
## 0.1.71 - 2026-05-09
|
||||
|
||||
### Added
|
||||
|
||||
- **Import existing Claude, Codex, and OpenCode sessions** into Paseo — pick up a conversation you started in the terminal and keep going from the app, with the full timeline. ([#766](https://github.com/getpaseo/paseo/pull/766), [#833](https://github.com/getpaseo/paseo/pull/833))
|
||||
- **Subagents now appear in a collapsible section above the composer** so you can jump into agents your main agent spawned. ([#532](https://github.com/getpaseo/paseo/pull/532))
|
||||
- Merge a pull request directly from the checkout pane. ([#814](https://github.com/getpaseo/paseo/pull/814))
|
||||
- Customize the per-project prompts Paseo uses to auto-generate agent titles, branch names, commit messages, and pull request descriptions. ([#836](https://github.com/getpaseo/paseo/pull/836))
|
||||
- Open an empty workspace without typing a prompt first. ([#834](https://github.com/getpaseo/paseo/pull/834))
|
||||
- Project settings are now grouped with inline links to the relevant docs. ([#837](https://github.com/getpaseo/paseo/pull/837))
|
||||
- Rich context menu on desktop — copy link, copy image, and spellcheck suggestions.
|
||||
- Archiving a Codex-backed agent now archives the underlying native Codex thread too. ([#827](https://github.com/getpaseo/paseo/pull/827) by [@32r4](https://github.com/32r4))
|
||||
|
||||
### Improved
|
||||
|
||||
- Opening a workspace auto-focuses the agent that needs your attention. ([#828](https://github.com/getpaseo/paseo/pull/828))
|
||||
- An unattended agent that spawns a sub-agent on a different provider via MCP now starts the sub-agent in unattended mode too.
|
||||
|
||||
### Fixed
|
||||
|
||||
- iOS project picker now submits the typed path. ([#831](https://github.com/getpaseo/paseo/pull/831))
|
||||
- System messages and chat mentions routed to multiple agents now reach every recipient consistently. ([#830](https://github.com/getpaseo/paseo/pull/830))
|
||||
- Clicking a Markdown link in agent output no longer reloads the desktop app on top of opening the link.
|
||||
- macOS desktop tab-jump shortcuts now use Cmd+Option+1-9, avoiding conflicts with Option-based international keyboard characters such as `@`.
|
||||
|
||||
### Security
|
||||
|
||||
- Local state files (daemon keypair, stored credentials, persisted config) are now readable only by the owning user. ([#825](https://github.com/getpaseo/paseo/pull/825) by [@joaosa](https://github.com/joaosa))
|
||||
|
||||
## 0.1.70 - 2026-05-08
|
||||
|
||||
### Breaking
|
||||
|
||||
- **Claude agents now require `claude` on your PATH.** Install Claude Code globally (`npm install -g @anthropic-ai/claude-code`) before running a Claude agent — Paseo no longer ships a bundled fallback binary. Same posture as Codex and OpenCode, and shrinks the desktop install by ~210 MB per platform.
|
||||
|
||||
### Added
|
||||
|
||||
- **One-click ACP providers** — add Cursor, Hermes, Qwen Coder, Kimi Code, and other ACP agents from a built-in catalog instead of writing config by hand.
|
||||
- Codex `/goal` slash command — set or update the goal mid-turn while a Codex agent is running.
|
||||
- Claude's Sonnet 4.6 1M context model is now selectable in the model picker.
|
||||
- Detect GitHub issue and PR URLs pasted into the composer search.
|
||||
- `paseo worktree create` CLI command, with parity to the MCP `create_worktree` tool.
|
||||
- `paseo schedule update` to edit a schedule in place without recreating it.
|
||||
- `paseo schedule run-once` for cron-style triggers, plus `--mode` on `schedule` and `loop`. Background runs now default to unattended mode.
|
||||
- Projects settings now lists workspaces from any remote — GitLab, Gitea, Bitbucket, self-hosted, and SSH-style URLs, not just GitHub. ([#681](https://github.com/getpaseo/paseo/pull/681) by [@krumpyzoid](https://github.com/krumpyzoid))
|
||||
|
||||
### Improved
|
||||
|
||||
- Skills now install, update, and uninstall on demand instead of silently auto-syncing on every desktop launch.
|
||||
- Self-hosted relays can opt into `wss://` for TLS connections.
|
||||
- Workspace open targets only show options reachable from the current daemon.
|
||||
- Combobox search matches model descriptions, not just names.
|
||||
- Codex image attachments render inline as path markdown.
|
||||
- Subagent task notifications no longer clutter the parent agent's timeline.
|
||||
- Voice mode: quieter thinking tone and small UI polish.
|
||||
- Settings sidebar order: Projects now appears after General.
|
||||
- Electron upgraded to 41.2.0 for the desktop app.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Claude agent: daemon no longer crashes mid-turn** when the underlying SDK fires a stray control message after the connection has been torn down.
|
||||
- **Windows:** Terminals start reliably and shut down cleanly without leaving stuck processes behind.
|
||||
- **Linux:** Workspace file watchers no longer storm with events on busy working trees, fixing CPU spikes on large repos. ([#794](https://github.com/getpaseo/paseo/pull/794) by [@312223105](https://github.com/312223105))
|
||||
- ACP-based agents launch terminal shell commands reliably. ([#793](https://github.com/getpaseo/paseo/pull/793) by [@ebg1223](https://github.com/ebg1223))
|
||||
- Checkout shortstat now counts untracked files. ([#608](https://github.com/getpaseo/paseo/issues/608), [#762](https://github.com/getpaseo/paseo/pull/762) by [@somus](https://github.com/somus))
|
||||
- Relay endpoints on port 443 use TLS automatically. ([#774](https://github.com/getpaseo/paseo/pull/774) by [@caoer](https://github.com/caoer))
|
||||
- Desktop CLI passthrough TTY handling — interactive commands now behave correctly when launched from the desktop app.
|
||||
- The CLI honors the `PASEO_PASSWORD` environment variable for password-protected daemons.
|
||||
- Daemon shutdown terminates all child processes cleanly using tree-kill.
|
||||
- Agent spawn paths handle missing executables and unusual install layouts more reliably.
|
||||
- OpenCode now forwards provider retry errors instead of silently swallowing them.
|
||||
- Codex import no longer reverts to the wrong default mode.
|
||||
- Pane keyboard shortcuts no longer fire while you're typing in an editable field.
|
||||
- Cold workspace URL navigation now lands in the correct sidebar entry on web.
|
||||
- Workspace navigation regression on web fixed.
|
||||
- Duplicate workspace shell navigation eliminated.
|
||||
- The 'Update installed' callout no longer flashes incorrectly.
|
||||
- Browser pane reload focus and devtools handling.
|
||||
- MCP terminal capture now includes scrollback.
|
||||
- Worktree branches no longer get renamed when an agent is created against an existing worktree from MCP.
|
||||
- Creating an agent in a subdirectory of a registered workspace now runs in that subdirectory instead of jumping up to the parent. ([#551](https://github.com/getpaseo/paseo/issues/551))
|
||||
- Non-GitHub project display names are derived from the remote owner/repo instead of the local path.
|
||||
- Desktop IPC wrapped in shared mutation/query hooks, fixing stale state and intermittent failures. ([#761](https://github.com/getpaseo/paseo/issues/761))
|
||||
- `paseo schedule create --host` now requires `--cwd` to avoid running schedules in the wrong directory.
|
||||
- `paseo schedule create --every` runs once immediately by default, then on the configured interval.
|
||||
- MCP `create_agent` validates the requested mode and refuses silent cross-provider inheritance.
|
||||
|
||||
## 0.1.69 - 2026-05-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Paseo now recovers automatically when an internal daemon process crashes — your agents stay connected instead of getting stuck and you don't have to restart anything.
|
||||
- Answering an interactive question from a Claude agent now reaches Claude correctly instead of being dropped. ([#760](https://github.com/getpaseo/paseo/pull/760) by [@somus](https://github.com/somus))
|
||||
|
||||
## 0.1.68 - 2026-05-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- The desktop app no longer fails on first launch after a fresh install.
|
||||
|
||||
## 0.1.67 - 2026-05-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- Archiving a worktree or workspace feels instant instead of waiting on the daemon, with automatic rollback if it fails.
|
||||
- The built-in daemon toggle in desktop settings now actually takes effect.
|
||||
- Desktop settings no longer reset on app launch after a legacy migration.
|
||||
- Desktop daemon startup failures now surface on the splash screen and respond to retry, instead of leaving the app silently stuck.
|
||||
- Internal LLM calls (branch names, commit messages, PR text) no longer leave behind ephemeral agent sessions in your provider history.
|
||||
|
||||
## 0.1.66 - 2026-05-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- Streaming markdown preserves trailing newlines so paragraph spacing stays correct while the agent is still typing.
|
||||
- Agent initialization failures surface within 30 seconds instead of 5 minutes.
|
||||
- Terminals reply to ANSI cursor-position queries, so tools that ask for cursor location no longer hang.
|
||||
|
||||
## 0.1.65 - 2026-05-03
|
||||
|
||||
### Added
|
||||
|
||||
- **In-app browser** — open a real web browser in any workspace to test your app. ([#670](https://github.com/getpaseo/paseo/pull/670) by [@jasonkneen](https://github.com/jasonkneen))
|
||||
- Inline review comments in the git diff pane. Tap a line number to start a comment. ([#530](https://github.com/getpaseo/paseo/pull/530))
|
||||
- Sub-agent activity is now shown for Codex, OpenCode, and Claude. ([#672](https://github.com/getpaseo/paseo/pull/672), [#658](https://github.com/getpaseo/paseo/pull/658) by [@thisisryanswift](https://github.com/thisisryanswift))
|
||||
- Pull and push your branch in one step from the git actions menu in the changes pane.
|
||||
- Resume existing agent sessions with `paseo import --provider <name> <id>`. ([#632](https://github.com/getpaseo/paseo/pull/632))
|
||||
- Password authentication and SSL support for daemon connections. ([#635](https://github.com/getpaseo/paseo/pull/635))
|
||||
- Connect to a daemon via relay using a pairing offer URL from the CLI. ([#639](https://github.com/getpaseo/paseo/pull/639))
|
||||
- **Windows:** Native ARM64 builds are now available.
|
||||
- Bundled Paseo skills now refresh automatically on desktop app launch.
|
||||
|
||||
### Improved
|
||||
|
||||
- Codex streaming feels more responsive — message boundaries are preserved and output arrives sooner.
|
||||
- Terminal sessions run in a dedicated worker process for better stability.
|
||||
- New worktree branch names are derived from your prompt and attachments instead of a generic placeholder.
|
||||
- Review comment UI is cleaner and easier to scan.
|
||||
- The daemon's `/api/status` endpoint is now protected by password auth when one is configured.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Apple Silicon Mac:** The desktop update pipeline now publishes manifests atomically, closing a race that could install the Intel build on Apple Silicon Macs and cause 100%+ renderer CPU usage. Affected users will self-heal — electron-updater's Rosetta detection migrates back to arm64 on the next update poll. ([#555](https://github.com/getpaseo/paseo/issues/555))
|
||||
- **Linux:** `.deb` and `.rpm` packages now show as `Paseo` in the dock and process list instead of `Paseo.bin`. `--no-sandbox` is now scoped to AppImage only, matching VS Code's sandbox handling. ([#602](https://github.com/getpaseo/paseo/issues/602))
|
||||
- **Windows:** Git diff commands no longer break on paths with special characters. ([#629](https://github.com/getpaseo/paseo/pull/629))
|
||||
- Cursor CLI and other ACP custom providers launch reliably. ([#628](https://github.com/getpaseo/paseo/pull/628))
|
||||
- Daemon stays up when WebSocket clients disconnect mid-stream, and crashes now write a fatal log entry instead of disappearing silently. ([#613](https://github.com/getpaseo/paseo/pull/613) by [@yuruiz](https://github.com/yuruiz))
|
||||
- Long agent timelines reconnect cleanly over the relay instead of looping through disconnects while catching up. ([#657](https://github.com/getpaseo/paseo/pull/657) by [@fireblue](https://github.com/fireblue))
|
||||
- Agent timelines refresh with smaller catch-up requests when you reopen an agent.
|
||||
- Terminal snapshots flush reliably before clients reconnect.
|
||||
- Workspace reconnects avoid unnecessary refresh work when the focused workspace is already current.
|
||||
- Voice dictation keeps recording when the agent tab loses focus.
|
||||
- OpenCode mode picker now lists agents available in every mode. ([#606](https://github.com/getpaseo/paseo/pull/606) by [@thisisryanswift](https://github.com/thisisryanswift))
|
||||
- Codex plan approval panels no longer duplicate.
|
||||
- Imported agents display the correct title immediately.
|
||||
- OpenCode surfaces invalid mode/model errors instead of hanging.
|
||||
- Archived worktrees stay hidden without flashing back into the list. ([#640](https://github.com/getpaseo/paseo/pull/640))
|
||||
- Web dropdown menus no longer resize unexpectedly.
|
||||
- The visible changes pane keeps in sync with the working tree diff.
|
||||
- Tool detail rows on the timeline are selectable again.
|
||||
- `paseo.json` parse errors in setup, teardown, and terminal actions now surface a clear error instead of failing silently.
|
||||
- Diff gutter line numbers were shifted one row out of alignment in some cases on web.
|
||||
- Streamed agent output reconciles cleanly when the timeline hydrates mid-turn. ([#663](https://github.com/getpaseo/paseo/pull/663))
|
||||
- Images in assistant messages show a loading spinner while they load and an "Image unavailable" fallback if they fail, instead of a blank space.
|
||||
- Isolated bottom sheet modals close and re-open without getting stuck.
|
||||
|
||||
## 0.1.64 - 2026-04-28
|
||||
|
||||
### Added
|
||||
|
||||
- OpenCode now has a Full Access mode that auto-approves tool calls. ([#595](https://github.com/getpaseo/paseo/pull/595) by [@tmih06](https://github.com/tmih06))
|
||||
- OpenCode supports executable slash commands. ([#597](https://github.com/getpaseo/paseo/pull/597) by [@tmih06](https://github.com/tmih06))
|
||||
|
||||
### Improved
|
||||
|
||||
- `@`-mention stays responsive on very large projects. ([#600](https://github.com/getpaseo/paseo/pull/600) by [@yuruiz](https://github.com/yuruiz))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Workspaces still load when `paseo.json` has a parse error.
|
||||
|
||||
## 0.1.63 - 2026-04-28
|
||||
|
||||
### Added
|
||||
@@ -174,7 +487,7 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Provider profiles — define custom providers in your Paseo config that appear alongside built-ins. Override a built-in's binary, env, or models, or create entirely new providers. See the [configuration guide](https://github.com/getpaseo/paseo/blob/main/docs/CUSTOM-PROVIDERS.md).
|
||||
- Provider profiles — define custom providers in your Paseo config that appear alongside built-ins. Override a built-in's binary, env, or models, or create entirely new providers. See the [configuration guide](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md).
|
||||
- ACP agent support — add any ACP-compatible agent to Paseo with `extends: "acp"` in your provider config. No code changes needed.
|
||||
- Choose provider and model when creating scheduled agents.
|
||||
- Max reasoning effort option for Opus 4.6 models.
|
||||
|
||||
72
CLAUDE.md
72
CLAUDE.md
@@ -15,19 +15,34 @@ This is an npm workspace monorepo:
|
||||
- `packages/desktop` — Electron desktop wrapper
|
||||
- `packages/website` — Marketing site (paseo.sh)
|
||||
|
||||
## Documentation
|
||||
## Docs
|
||||
|
||||
| Doc | What's in it |
|
||||
| ---------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||
| [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||
| [docs/TESTING.md](docs/TESTING.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/RELEASE.md](docs/RELEASE.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/CUSTOM-PROVIDERS.md](docs/CUSTOM-PROVIDERS.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/ANDROID.md](docs/ANDROID.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/DESIGN.md](docs/DESIGN.md) | How to design features before implementation |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
`docs/` is the source of truth for system-level and process-level knowledge. **"The docs", "check the docs", or "check the X docs" always mean this directory — not the web.** Look here before fetching anything online; the docs capture gotchas and conventions you cannot derive from the code or external sources.
|
||||
|
||||
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
|
||||
|
||||
| Doc | What's in it |
|
||||
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
||||
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
||||
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
||||
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
|
||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -41,7 +56,7 @@ npm run format # Auto-format with Biome
|
||||
npm run format:check # Check formatting without writing
|
||||
```
|
||||
|
||||
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
|
||||
See [docs/development.md](docs/development.md) for full setup, build sync requirements, and debugging.
|
||||
|
||||
## Critical rules
|
||||
|
||||
@@ -55,16 +70,27 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
|
||||
- Never re-run a test suite that another agent already ran and reported green — trust the result.
|
||||
- For full suite verification, push to CI and check GitHub Actions instead.
|
||||
- **Always run typecheck and lint after every change.**
|
||||
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace (especially CLI depending on server/daemon types), rebuild the owning package first so `dist` declarations are current:
|
||||
- `npm run build:daemon` — rebuild highlight, relay, server, and CLI when daemon/server/CLI types may be stale.
|
||||
- Do not patch inferred callback parameters or add local duplicate types just to silence stale declaration errors.
|
||||
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
|
||||
- **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script:
|
||||
- `npm run lint -- packages/app/src/components/message.tsx`
|
||||
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
|
||||
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
|
||||
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
|
||||
- Never change a field from optional to required.
|
||||
- Never remove a field — deprecate it (keep accepting it, stop sending it).
|
||||
- Never narrow a field's type (e.g. `string` → `enum`, `nullable` → non-null).
|
||||
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
||||
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
|
||||
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
|
||||
- New fields: `.optional()` with a sensible default or `.transform()` fallback.
|
||||
- Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null).
|
||||
- Removed fields stay accepted (we stop sending them, not stop reading them).
|
||||
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
||||
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
|
||||
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
|
||||
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
|
||||
- **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site.
|
||||
- Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal.
|
||||
- **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones.
|
||||
|
||||
- **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable.
|
||||
|
||||
## Platform gating
|
||||
|
||||
@@ -100,6 +126,14 @@ The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is
|
||||
use-audio-recorder.native.ts ← uses expo-audio
|
||||
```
|
||||
Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically.
|
||||
- **Use `.electron.ts` / `.electron.tsx` for Electron-only web modules.** Electron is still the Metro `web` platform, but desktop dev/build sets `PASEO_WEB_PLATFORM=electron`, so Metro first looks for `.electron.*` files and falls back to normal `.web.*` files. Use this when the implementation depends on Electron-only behavior such as `webviewTag`, desktop preload APIs, or the Electron bridge. Keep plain browser web in `.web.*`, and keep native fallbacks in the base file or `.native.*`.
|
||||
```
|
||||
components/
|
||||
browser-pane.electron.tsx ← Electron <webview> implementation
|
||||
browser-pane.web.tsx ← plain web fallback
|
||||
browser-pane.tsx ← native fallback
|
||||
```
|
||||
Import as `@/components/browser-pane` — Electron desktop gets the `.electron.tsx` file, browser web gets `.web.tsx`, and native gets the native/base implementation.
|
||||
- **NEVER use raw DOM APIs without `isWeb` guard.** DOM APIs crash native. Casting a RN ref to `HTMLElement` is a red flag — ensure the block is web-only.
|
||||
- **NEVER use `onPointerEnter`/`onPointerLeave`.** They don't fire on native iOS.
|
||||
- **Hover only works on web.** React Native's `onHoverIn`/`onHoverOut` on `Pressable` does NOT fire on native iOS/iPad — the underlying W3C pointer events are behind disabled experimental flags. For hover-to-show UI (kebab menus, action buttons), use `isHovered || isNative || isCompact` so the controls are always visible on native and hover-to-show on web.
|
||||
|
||||
178
CONTRIBUTING.md
178
CONTRIBUTING.md
@@ -1,168 +1,32 @@
|
||||
# Contributing to Paseo
|
||||
|
||||
Thanks for taking the time to contribute.
|
||||
Paseo is an opinionated product maintained by one person.
|
||||
|
||||
## How this project works
|
||||
I read every issue and PR myself, and I am selective about what contributions I accept.
|
||||
|
||||
Paseo is a BDFL project. Product direction, scope, and what ships are the maintainer's call.
|
||||
Good ideas still need to fit the shape of the product: a PR can be technically correct and still not belong in Paseo.
|
||||
|
||||
This means:
|
||||
Core product, design, architecture, and workflow changes are not accepted.
|
||||
|
||||
- PRs submitted without prior discussion will likely be rejected, heavily modified, or scoped down.
|
||||
- The maintainer may rewrite, split, cherry-pick from, or close any PR at their discretion.
|
||||
- There is no obligation to merge a PR as-submitted, regardless of code quality.
|
||||
Follow these rules if you want your PR to be merged:
|
||||
|
||||
This is not meant to discourage contributions. It is meant to set clear expectations so nobody wastes their time.
|
||||
- Keep it to one focused change
|
||||
- Link to an issue
|
||||
- Explain the problem you're solving
|
||||
- Include repro steps if it's a bug
|
||||
- Include QA/testing evidence
|
||||
- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web
|
||||
- If you only tested one platform, say that clearly
|
||||
|
||||
## How to contribute
|
||||
Your PR will be closed if you do any of these:
|
||||
|
||||
1. **Open an issue first.** Describe the problem or improvement. Get a thumbs up before writing code.
|
||||
2. **Keep it small.** One bug, one flow, one focused change.
|
||||
3. **Open a PR** once there is alignment on scope.
|
||||
- Bundle unrelated changes
|
||||
- Fail basic checks like typecheck, formatting or linting
|
||||
- Make product, design, or architecture changes without prior discussion
|
||||
- Submit no evidence of testing
|
||||
- Skip the linked issue
|
||||
- Clearly fully AI-generated PR
|
||||
|
||||
If you want to propose a direction change, start a conversation.
|
||||
## AI assistance
|
||||
|
||||
## Before you start
|
||||
|
||||
Please read these first:
|
||||
|
||||
- [README.md](README.md)
|
||||
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
||||
- [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)
|
||||
- [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md)
|
||||
- [docs/TESTING.md](docs/TESTING.md)
|
||||
- [CLAUDE.md](CLAUDE.md)
|
||||
|
||||
## What is most helpful
|
||||
|
||||
The most useful contributions right now are:
|
||||
|
||||
- bug fixes
|
||||
- windows and linux specific fixes
|
||||
- regression fixes
|
||||
- doc improvements
|
||||
- packaging / platform fixes
|
||||
- focused UX improvements that fit the existing product direction
|
||||
- tests that lock down important behavior
|
||||
|
||||
## Scope expectations
|
||||
|
||||
Please keep PRs narrow.
|
||||
|
||||
Good:
|
||||
|
||||
- fix one bug
|
||||
- improve one flow
|
||||
- add one focused panel or command
|
||||
- tighten one piece of UI
|
||||
|
||||
Bad:
|
||||
|
||||
- combine multiple product ideas in one PR
|
||||
- bundle unrelated refactors with a feature
|
||||
- sneak in roadmap decisions
|
||||
|
||||
If a contribution contains multiple ideas, split it up.
|
||||
|
||||
## Product fit matters
|
||||
|
||||
Paseo is an opinionated product.
|
||||
|
||||
When reviewing contributions, the bar is not just:
|
||||
|
||||
- is this useful?
|
||||
- is this well implemented?
|
||||
|
||||
It is also:
|
||||
|
||||
- does this fit Paseo?
|
||||
- does this add product surface that will be hard to maintain?
|
||||
- does the value justify the maintenance surface it adds?
|
||||
- does this solve a common need or over-serve an edge case?
|
||||
- does this preserve the product's current direction?
|
||||
|
||||
## Development setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js matching `.tool-versions`
|
||||
- npm workspaces
|
||||
|
||||
### Start local development
|
||||
|
||||
```bash
|
||||
# runs both daemon and expo app
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
npm run dev:server
|
||||
npm run dev:app
|
||||
npm run dev:desktop
|
||||
npm run dev:website
|
||||
npm run cli -- ls -a -g
|
||||
```
|
||||
|
||||
Read [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for build-sync gotchas, local state, ports, and daemon details.
|
||||
|
||||
## Multi-platform testing
|
||||
|
||||
Paseo ships to mobile (iOS/Android), web, and desktop (Electron). Every UI change must be tested on mobile and web at minimum, and desktop if relevant. Things that look fine on one surface regularly break on another.
|
||||
|
||||
Common checks:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run test --workspaces --if-present
|
||||
```
|
||||
|
||||
Important rules:
|
||||
|
||||
- always run `npm run typecheck` after changes
|
||||
- tests should be deterministic
|
||||
- prefer real dependencies over mocks when possible
|
||||
- do not make breaking WebSocket / protocol changes
|
||||
- app and daemon versions in the wild lag each other, so compatibility matters
|
||||
|
||||
If you touch protocol or shared client/server behavior, read the compatibility notes in [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## Coding standards
|
||||
|
||||
Paseo has explicit standards. Follow them.
|
||||
|
||||
The full guide lives in [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md).
|
||||
|
||||
## PR checklist
|
||||
|
||||
Before opening a PR, make sure:
|
||||
|
||||
- there was prior discussion and alignment on scope (issue or conversation)
|
||||
- the change is focused, one idea per PR
|
||||
- the PR description explains what changed and why
|
||||
- **UI changes include screenshots or videos** for every affected platform (mobile, web, desktop)
|
||||
- UI changes have been tested on mobile and web at minimum
|
||||
- typecheck passes
|
||||
- tests pass, or you clearly explain what could not be run
|
||||
- relevant docs were updated if needed
|
||||
|
||||
## Communication
|
||||
|
||||
If you are unsure whether something fits, ask first.
|
||||
|
||||
That is especially true for:
|
||||
|
||||
- new core UX
|
||||
- naming / terminology changes
|
||||
- new extension points
|
||||
- new orchestration models
|
||||
- anything that would be hard to remove later
|
||||
|
||||
Early alignment saves everyone time.
|
||||
|
||||
## Forks are fine
|
||||
|
||||
If you want to explore a different product direction, a fork is completely fine.
|
||||
|
||||
Paseo is open source on purpose. Not every idea needs to land in the main repo to be valuable.
|
||||
AI in the loop is fine. The bar is whether _you_ tested the change and can explain why it works. A confident wall of AI prose with no evidence of testing is a red flag and will get closed.
|
||||
|
||||
78
README.md
78
README.md
@@ -91,9 +91,9 @@ paseo --host workstation.local:6767 run "run the full test suite"
|
||||
|
||||
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
|
||||
|
||||
## Orchestration skills (Unstable)
|
||||
## Skills
|
||||
|
||||
Experimental skills that teach agents how to use the Paseo CLI to orchestrate other agents. I am updating these very frequently as I learn new things, expect changes without notice, might be coupled to my own setup, use at your own risk.
|
||||
Skills teach your agent to use Paseo to orchestrate other agents.
|
||||
|
||||
```bash
|
||||
npx skills add getpaseo/paseo
|
||||
@@ -101,18 +101,10 @@ npx skills add getpaseo/paseo
|
||||
|
||||
Then use them in any agent conversation:
|
||||
|
||||
```bash
|
||||
# Use handoff when you discuss something with an agent but want another one to implement.
|
||||
# I use this to plan with Claude and then handoff to Codex to implement.
|
||||
/paseo-handoff hand off the authentication fix to codex 5.4 in a worktree
|
||||
|
||||
# Use loops when you have clear acceptance criteria (aka Ralph loops).
|
||||
/paseo-loop loop a codex agent to fix the backend tests, use sonnet to verify, max 10 iterations
|
||||
|
||||
# Orchestrator teaches the agent how to create teams and manage them via a chat room.
|
||||
# Very opinionated and expects both Codex and Claude to work.
|
||||
/paseo-orchestrator spin up a team to implement the database refactor, use chat to coordinate. use claude to plan and codex to implement and review
|
||||
```
|
||||
- `/paseo-handoff` — hand off work between agents. I use this to plan with Claude and then handoff to Codex to implement.
|
||||
- `/paseo-loop` — loop an agent against clear acceptance criteria (aka Ralph loops), optionally with a verifier.
|
||||
- `/paseo-advisor` — spin up a single agent as an advisor for a second opinion, without delegating the work itself.
|
||||
- `/paseo-committee` — form a committee of two contrasting agents to step back, do root cause analysis, and produce a plan.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -144,9 +136,65 @@ npm run build:daemon
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## Community
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
|
||||
|
||||
### Self-hosted relay TLS
|
||||
|
||||
Self-hosted relays use `ws://` unless TLS is opted in. For a relay behind nginx on 443, start the daemon with:
|
||||
|
||||
```bash
|
||||
PASEO_RELAY_ENDPOINT=127.0.0.1:8080 \
|
||||
PASEO_RELAY_PUBLIC_ENDPOINT=relay.example.com:443 \
|
||||
PASEO_RELAY_USE_TLS=true \
|
||||
paseo daemon start
|
||||
```
|
||||
|
||||
Equivalent config:
|
||||
|
||||
```json
|
||||
{
|
||||
"daemon": {
|
||||
"relay": {
|
||||
"enabled": true,
|
||||
"endpoint": "127.0.0.1:8080",
|
||||
"publicEndpoint": "relay.example.com:443",
|
||||
"useTls": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Minimal nginx WebSocket proxy:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name relay.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/relay.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/relay.example.com/privkey.pem;
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://star-history.com/#getpaseo/paseo&Date">
|
||||
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="100%">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date&theme=dark">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date">
|
||||
<img src="https://api.star-history.com/svg?repos=getpaseo/paseo&type=Date" alt="Star history chart for getpaseo/paseo" width="600" style="max-width: 100%;">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
21
SECURITY.md
21
SECURITY.md
@@ -19,18 +19,19 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
|
||||
|
||||
### How it works
|
||||
|
||||
1. The daemon generates a persistent ECDH keypair and stores it locally
|
||||
2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key
|
||||
3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes.
|
||||
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl box).
|
||||
1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600`
|
||||
2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key.
|
||||
3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes.
|
||||
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The wire format is `[24-byte nonce][ciphertext]`, base64-encoded as a WebSocket text frame.
|
||||
|
||||
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
|
||||
The relay sees only: IP addresses, timing, message sizes, session IDs, and the plaintext `e2ee_hello` / `e2ee_ready` handshake frames (which contain only public keys). It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
|
||||
|
||||
### Why the relay can't attack you
|
||||
|
||||
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
|
||||
|
||||
- **Send commands** — Without your phone's private key, it cannot complete the handshake
|
||||
- **Impersonate the daemon to your phone** — Without the daemon's secret key, it cannot derive the shared key, so any traffic it injects fails authenticated decryption on the phone
|
||||
- **Send commands as you** — The daemon only accepts traffic that decrypts and authenticates under a shared key derived with its own secret key. The phone's keypair is ephemeral per connection, so there is no persistent phone-side secret to steal; protection comes from the daemon's secret key never leaving the daemon.
|
||||
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
|
||||
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
|
||||
- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
|
||||
@@ -41,11 +42,11 @@ The QR code or pairing link is the trust anchor. It contains the daemon's public
|
||||
|
||||
## Local daemon trust boundary
|
||||
|
||||
By default, the daemon binds to `127.0.0.1`. The local control plane is trusted by network reachability, not by an additional authentication token.
|
||||
By default, the daemon binds to `127.0.0.1`. With no password configured, the local control plane is trusted by network reachability — anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
|
||||
|
||||
Anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
|
||||
The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer <password>` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.<password>` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks.
|
||||
|
||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access.
|
||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
|
||||
|
||||
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
|
||||
|
||||
@@ -55,7 +56,7 @@ Host header validation and CORS origin checks are defense-in-depth controls for
|
||||
|
||||
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
|
||||
|
||||
Paseo validates the `Host` header on incoming requests against configured hostnames. Requests with unrecognized hosts are rejected.
|
||||
Paseo validates the `Host` header on every HTTP request and every WebSocket upgrade against an allowlist (Vite-style semantics). By default, only `localhost`, `*.localhost`, and any literal IP address (IPv4 or IPv6) are accepted. Additional hostnames can be configured via `hostnames` in `config.json` or the `PASEO_HOSTNAMES` env var (comma-separated; entries beginning with `.` match a domain and its subdomains; the value `true` disables the allowlist entirely). Requests with unrecognized hosts are rejected with `403 Host not allowed`.
|
||||
|
||||
## Agent authentication
|
||||
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
# Architecture
|
||||
|
||||
Paseo is a client-server system for monitoring and controlling local AI coding agents. The daemon runs on your machine, manages agent processes, and streams their output in real time over WebSocket. Clients (mobile app, CLI, desktop app) connect to the daemon to observe and interact with agents.
|
||||
|
||||
Your code never leaves your machine. Paseo is local-first.
|
||||
|
||||
## System overview
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Mobile App │ │ CLI │ │ Desktop App │
|
||||
│ (Expo) │ │ (Commander) │ │ (Electron) │
|
||||
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
│ WebSocket │ WebSocket │ Managed subprocess
|
||||
│ (direct or │ (direct) │ + WebSocket
|
||||
│ via relay) │ │
|
||||
└───────────┬───────┴──────────────────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ Daemon │
|
||||
│ (Node.js) │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌────────────┼────────────┐
|
||||
│ │ │
|
||||
┌─────▼─────┐ ┌───▼────┐ ┌────▼─────┐
|
||||
│ Claude │ │ Codex │ │ OpenCode │
|
||||
│ Agent │ │ Agent │ │ Agent │
|
||||
│ SDK │ │ Server │ │ │
|
||||
└───────────┘ └────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## Components at a glance
|
||||
|
||||
- **Daemon:** Local server that spawns and manages agent processes and exposes the WebSocket API.
|
||||
- **App:** Cross-platform Expo client for iOS, Android, web, and the shared UI used by desktop.
|
||||
- **CLI:** Terminal interface for agent workflows that can also start and manage the daemon.
|
||||
- **Desktop app:** Electron wrapper around the web app that bundles and auto-manages its own daemon.
|
||||
- **Relay:** Optional encrypted bridge for remote access without opening ports directly.
|
||||
|
||||
## Packages
|
||||
|
||||
### `packages/server` — The daemon
|
||||
|
||||
The heart of Paseo. A Node.js process that:
|
||||
|
||||
- Listens for WebSocket connections from clients
|
||||
- Manages agent lifecycle (create, run, stop, resume, archive)
|
||||
- Streams agent output in real time via a timeline model
|
||||
- Exposes an MCP server for agent-to-agent control
|
||||
- Optionally connects outbound to a relay for remote access
|
||||
|
||||
**Key modules:**
|
||||
|
||||
| Module | Responsibility |
|
||||
| ------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
|
||||
| `websocket-server.ts` | WebSocket connection management, hello/welcome handshake, binary multiplexing |
|
||||
| `session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
||||
| `agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
|
||||
| `agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
||||
| `agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
|
||||
| `providers/` | Provider adapters: Claude (Agent SDK), Codex (AppServer), OpenCode |
|
||||
| `relay-transport.ts` | Outbound relay connection with E2E encryption |
|
||||
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
|
||||
|
||||
### `packages/app` — Mobile + web client (Expo)
|
||||
|
||||
Cross-platform React Native app that connects to one or more daemons.
|
||||
|
||||
- Expo Router navigation (`/h/[serverId]/agents`, etc.)
|
||||
- `DaemonRegistryContext` manages saved daemon connections
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
- `Stream` model handles timeline with compaction, gap detection, sequence-based deduplication
|
||||
- Voice features: dictation (STT) and voice agent (realtime)
|
||||
|
||||
### `packages/cli` — Command-line client
|
||||
|
||||
Commander.js CLI with Docker-style commands:
|
||||
|
||||
- `paseo agent ls/run/stop/logs/inspect/wait/send/attach`
|
||||
- `paseo daemon start/stop/restart/status/pair`
|
||||
- `paseo permit allow/deny/ls`
|
||||
- `paseo provider ls/models`
|
||||
- `paseo worktree ls/archive`
|
||||
|
||||
Communicates with the daemon via the same WebSocket protocol as the app.
|
||||
|
||||
### `packages/relay` — E2E encrypted relay
|
||||
|
||||
Enables remote access when the daemon is behind a firewall.
|
||||
|
||||
- ECDH key exchange + AES-256-GCM encryption
|
||||
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
|
||||
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
|
||||
- Pairing via QR code transfers the daemon's public key to the client
|
||||
|
||||
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
||||
|
||||
### `packages/desktop` — Desktop app (Electron)
|
||||
|
||||
Electron wrapper for macOS, Linux, and Windows.
|
||||
|
||||
- Can spawn the daemon as a managed subprocess
|
||||
- Native file access for workspace integration
|
||||
- Same WebSocket client as mobile app
|
||||
|
||||
### `packages/website` — Marketing site
|
||||
|
||||
TanStack Router + Cloudflare Workers. Serves paseo.sh.
|
||||
|
||||
## WebSocket protocol
|
||||
|
||||
All clients speak the same binary-multiplexed WebSocket protocol.
|
||||
|
||||
**Handshake:**
|
||||
|
||||
```
|
||||
Client → Server: WSHelloMessage { id, clientId, version, timestamp }
|
||||
Server → Client: WSWelcomeMessage { clientId, daemonVersion, sessionId, capabilities }
|
||||
```
|
||||
|
||||
**Message types:**
|
||||
|
||||
- `agent_update` — Agent state changed (status, title, labels)
|
||||
- `agent_stream` — New timeline event from a running agent
|
||||
- `workspace_update` — Workspace state changed
|
||||
- `agent_permission_request` — Agent needs user approval for a tool call
|
||||
- Command-response pairs for fetch, list, create, etc.
|
||||
|
||||
**Binary multiplexing:**
|
||||
|
||||
Terminal I/O and agent streaming share the same connection via `BinaryMuxFrame`:
|
||||
|
||||
- Channel 0: control messages
|
||||
- Channel 1: terminal data
|
||||
- 1-byte channel ID + 1-byte flags + variable payload
|
||||
|
||||
## Agent lifecycle
|
||||
|
||||
```
|
||||
initializing → idle → running → idle (or error → closed)
|
||||
↑ │
|
||||
└────────┘ (agent completes a turn, awaits next prompt)
|
||||
```
|
||||
|
||||
- **AgentManager** tracks up to 200 timeline items per agent
|
||||
- Timeline is append-only with epochs (each run starts a new epoch)
|
||||
- Events stream to all subscribed clients in real time
|
||||
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json`
|
||||
|
||||
## Agent providers
|
||||
|
||||
Each provider implements a common `AgentClient` interface:
|
||||
|
||||
| Provider | Wraps | Session format |
|
||||
| -------- | ------------------- | -------------------------------------------------- |
|
||||
| Claude | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
|
||||
| Codex | CodexAppServer | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
|
||||
| OpenCode | OpenCode CLI | Provider-managed |
|
||||
|
||||
All providers:
|
||||
|
||||
- Handle their own authentication (Paseo does not manage API keys)
|
||||
- Support session resume via persistence handles
|
||||
- Map tool calls to a normalized `ToolCallDetail` type
|
||||
- Expose provider-specific modes (plan, default, full-access)
|
||||
|
||||
## Data flow: running an agent
|
||||
|
||||
1. Client sends `CreateAgentRequestMessage` with config (prompt, cwd, provider, model, mode)
|
||||
2. Session routes to `AgentManager.create()`
|
||||
3. AgentManager creates a `ManagedAgent`, initializes provider session
|
||||
4. Provider runs the agent → emits `AgentStreamEvent` items
|
||||
5. Events append to the agent timeline, broadcast to all subscribed clients
|
||||
6. Tool calls are normalized to `ToolCallDetail` (shell, read, edit, write, search, etc.)
|
||||
7. Permission requests flow: agent → server → client → user decision → server → agent
|
||||
|
||||
## Storage
|
||||
|
||||
```
|
||||
$PASEO_HOME/
|
||||
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent state + config
|
||||
├── projects/projects.json # Project registry
|
||||
├── projects/workspaces.json # Workspace registry
|
||||
└── daemon.log # Daemon trace logs
|
||||
```
|
||||
|
||||
## Deployment models
|
||||
|
||||
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
||||
2. **Managed desktop**: Electron app spawns daemon as subprocess
|
||||
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
|
||||
@@ -1,158 +0,0 @@
|
||||
# Attachment-Based Review Context Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Use structured attachments as the source of truth for review context during agent creation.
|
||||
|
||||
This covers two related behaviors:
|
||||
|
||||
1. Provider-facing context for the agent prompt
|
||||
2. Worktree checkout behavior when creating a worktree from a review item
|
||||
|
||||
The design should stay compatible with future forge support such as GitLab.
|
||||
|
||||
## Core Direction
|
||||
|
||||
- Do not parse the prompt to discover issue/PR intent.
|
||||
- Do not introduce deeply nested transport objects for this flow.
|
||||
- Make the attachment itself the discriminated union.
|
||||
- Keep forge-specific checkout logic below the attachment layer.
|
||||
- Let each agent provider translate attachments into its own prompt/input format.
|
||||
|
||||
## Initial Attachment Types
|
||||
|
||||
Two initial structured attachment types:
|
||||
|
||||
- `github_pr`
|
||||
- `github_issue`
|
||||
|
||||
With separate MIME types:
|
||||
|
||||
- `application/github-pr`
|
||||
- `application/github-issue`
|
||||
|
||||
Proposed wire shape:
|
||||
|
||||
```ts
|
||||
type AgentAttachment =
|
||||
| {
|
||||
type: "github_pr";
|
||||
mimeType: "application/github-pr";
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
body?: string | null;
|
||||
baseRefName?: string | null;
|
||||
headRefName?: string | null;
|
||||
}
|
||||
| {
|
||||
type: "github_issue";
|
||||
mimeType: "application/github-issue";
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
body?: string | null;
|
||||
};
|
||||
```
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- Add new optional `attachments` fields; keep existing `images` fields.
|
||||
- Unknown attachment discriminators must be dropped during schema normalization instead of failing the full request.
|
||||
- Malformed attachment entries should also be ignored safely.
|
||||
- Old clients continue working because `attachments` is optional.
|
||||
- New clients can send `attachments` without breaking older flows that still rely on `images`.
|
||||
|
||||
## Request-Level Changes
|
||||
|
||||
Add optional `attachments` to:
|
||||
|
||||
- `create_agent_request`
|
||||
- `send_agent_message_request`
|
||||
|
||||
Existing `initialPrompt` remains plain user text.
|
||||
|
||||
The selected GitHub PR/issue becomes a structured attachment instead of being injected into prompt text as the primary source of truth.
|
||||
|
||||
## App Responsibilities
|
||||
|
||||
When the user selects a GitHub item:
|
||||
|
||||
- If it is a PR, create a `github_pr` attachment
|
||||
- If it is an issue, create a `github_issue` attachment
|
||||
- Include the attachment in the create-agent request
|
||||
|
||||
The UI can still render friendly labels and previews from the same data.
|
||||
|
||||
The app should stop treating PR/issue context as prompt-only metadata.
|
||||
|
||||
## Worktree Creation Behavior
|
||||
|
||||
During agent creation, if worktree creation is requested:
|
||||
|
||||
- Inspect normalized attachments
|
||||
- If a `github_pr` attachment is present, use it to drive checkout for the worktree
|
||||
|
||||
The important rule:
|
||||
|
||||
- attachment type identifies the review object
|
||||
- server-side git logic decides how to check it out
|
||||
|
||||
This keeps the attachment contract simple while allowing fork-safe implementation details.
|
||||
|
||||
## Checkout Resolution
|
||||
|
||||
The attachment itself should not encode the full checkout strategy.
|
||||
|
||||
Instead, the server should resolve checkout from the `github_pr` attachment using forge-aware logic.
|
||||
|
||||
Examples of possible implementation strategies:
|
||||
|
||||
- `gh pr checkout`
|
||||
- `git fetch origin refs/pull/<number>/head:<local-branch>`
|
||||
- another GitHub-aware resolver if needed later
|
||||
|
||||
This is intentionally a server implementation detail, not part of the attachment schema.
|
||||
|
||||
## Provider Responsibilities
|
||||
|
||||
Each provider adapter should receive normalized attachments and decide how to represent them for the model.
|
||||
|
||||
Examples:
|
||||
|
||||
- Claude: render attachment into text blocks
|
||||
- Codex: inject as blocks or text
|
||||
- OpenCode: send as file/resource-like input where appropriate
|
||||
- ACP: convert to resource/text forms as supported
|
||||
|
||||
The session layer should not hardcode one translation strategy for all providers.
|
||||
|
||||
## Suggested Implementation Order
|
||||
|
||||
1. Add tolerant attachment schema and normalization in shared/server message handling.
|
||||
2. Thread `attachments` through app -> daemon client -> session layer.
|
||||
3. Update create-agent UI flows to emit `github_pr` / `github_issue` attachments from GitHub selection.
|
||||
4. Teach worktree creation to inspect attachments and special-case `github_pr`.
|
||||
5. Update provider adapters to translate attachments into provider-specific prompt/input forms.
|
||||
6. Optionally migrate image transport into the same attachment mechanism later.
|
||||
|
||||
## Files Likely Involved
|
||||
|
||||
- `packages/server/src/shared/messages.ts`
|
||||
- `packages/server/src/client/daemon-client.ts`
|
||||
- `packages/server/src/server/session.ts`
|
||||
- `packages/server/src/server/worktree-session.ts`
|
||||
- `packages/server/src/server/agent/agent-sdk-types.ts`
|
||||
- `packages/server/src/server/agent/providers/claude-agent.ts`
|
||||
- `packages/server/src/server/agent/providers/codex-app-server-agent.ts`
|
||||
- `packages/server/src/server/agent/providers/opencode-agent.ts`
|
||||
- `packages/server/src/server/agent/providers/acp-agent.ts`
|
||||
- `packages/app/src/screens/new-workspace-screen.tsx`
|
||||
- `packages/app/src/screens/agent/draft-agent-screen.tsx`
|
||||
- `packages/app/src/contexts/session-context.tsx`
|
||||
|
||||
## Notes
|
||||
|
||||
- The current image path can remain in place initially.
|
||||
- The plan intentionally keeps checkout concerns separate from provider prompt translation.
|
||||
- Future forge support should add new attachment discriminators rather than expanding GitHub-only nested fields.
|
||||
@@ -1,188 +0,0 @@
|
||||
# Coding Standards
|
||||
|
||||
These standards apply to all code changes: features, bug fixes, refactors, and performance work.
|
||||
|
||||
## Core principles
|
||||
|
||||
- **Zero complexity budget** — justify every abstraction with specific benefits
|
||||
- **Fully typed TypeScript** — no `any`, no untyped boundaries
|
||||
- **YAGNI** — build features and abstractions only when needed
|
||||
- **Functional and declarative** over object-oriented
|
||||
- **`interface`** over `type` when possible
|
||||
- **`function` declarations** over arrow function assignments
|
||||
- **Single-purpose functions** — one function, one job
|
||||
- **Design for edge cases through types** rather than explicit handling
|
||||
- **Don't catch errors** unless there's a strong reason to
|
||||
- **No index.ts barrel files** that only re-export — they create unnecessary indirection
|
||||
- **No "while I'm at it" improvements** — stay focused on the task
|
||||
|
||||
## Type hygiene
|
||||
|
||||
### Infer from schemas
|
||||
|
||||
Never hand-write a TypeScript type that can be inferred from a Zod schema.
|
||||
|
||||
```typescript
|
||||
// Bad: duplicate type that can drift
|
||||
const schema = z.object({ procedure: z.string(), args: z.record(z.unknown()) });
|
||||
type RPCArgs = { procedure: string; args: Record<string, unknown> };
|
||||
|
||||
// Good: infer from schema
|
||||
type RPCArgs = z.infer<typeof schema>;
|
||||
```
|
||||
|
||||
### Named types over inline
|
||||
|
||||
No complex inline types in public function signatures.
|
||||
|
||||
```typescript
|
||||
// Bad
|
||||
function enqueueJob(input: { userId: string; priority: "low" | "normal" | "high" }) {}
|
||||
|
||||
// Good
|
||||
interface EnqueueJobInput {
|
||||
userId: string;
|
||||
priority: "low" | "normal" | "high";
|
||||
}
|
||||
function enqueueJob(input: EnqueueJobInput) {}
|
||||
```
|
||||
|
||||
### Object parameters
|
||||
|
||||
If a function needs more than one argument, use a single object parameter.
|
||||
|
||||
```typescript
|
||||
// Bad: positional args
|
||||
function createToolCall(provider: string, toolName: string, payload: unknown) {}
|
||||
|
||||
// Good: object param
|
||||
interface CreateToolCallInput {
|
||||
provider: string;
|
||||
toolName: string;
|
||||
payload: unknown;
|
||||
}
|
||||
function createToolCall(input: CreateToolCallInput) {}
|
||||
```
|
||||
|
||||
### One canonical type per concept
|
||||
|
||||
Don't redefine the same concept in different layer-specific shapes (`RpcX`, `DbX`, `UiX`). Keep one canonical type and add explicit layer wrappers that reference it.
|
||||
|
||||
```typescript
|
||||
// Bad: duplicated fields across layers
|
||||
type RpcToolCall = { toolName: string; args: Record<string, unknown>; requestId: string };
|
||||
type DbToolCall = { toolName: string; args: Record<string, unknown>; id: string; createdAt: Date };
|
||||
|
||||
// Good: canonical type + wrappers
|
||||
type ToolCall = { toolName: string; args: Record<string, unknown> };
|
||||
type ToolCallRequest = { requestId: string; toolCall: ToolCall };
|
||||
type ToolCallRecord = { id: string; createdAt: Date; toolCall: ToolCall };
|
||||
```
|
||||
|
||||
## Make impossible states impossible
|
||||
|
||||
Use discriminated unions instead of bags of booleans and optionals.
|
||||
|
||||
```typescript
|
||||
// Bad
|
||||
interface FetchState {
|
||||
isLoading: boolean;
|
||||
error?: Error;
|
||||
data?: Data;
|
||||
}
|
||||
|
||||
// Good
|
||||
type FetchState =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: Error }
|
||||
| { status: "success"; data: Data };
|
||||
```
|
||||
|
||||
## Optionality is a design decision
|
||||
|
||||
Don't mark fields optional to avoid migrations. Decide deliberately:
|
||||
|
||||
1. Is optionality actually needed?
|
||||
2. If there are distinct valid states → discriminated union
|
||||
3. If value can be intentionally empty → explicit `null`
|
||||
4. Keep optionality at real boundaries (external input), then resolve it
|
||||
|
||||
## Validate at boundaries, trust internally
|
||||
|
||||
Parse external data once at the boundary with schema validation. Then use typed values everywhere else.
|
||||
|
||||
```typescript
|
||||
// Bad: optional chaining because shape is unclear
|
||||
const value = response?.data?.items?.[0]?.name;
|
||||
|
||||
// Good: validate at boundary, trust the types
|
||||
const parsed = responseSchema.parse(rawResponse);
|
||||
const value = parsed.data.items[0].name;
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
- **Fail explicitly** — if caller requests X and X is unavailable, throw rather than silently returning Y
|
||||
- **Use typed domain errors** — not plain `Error`. Carry structured metadata for handling, logging, and user messaging
|
||||
- **Preserve error semantics** — don't collapse meaningful typed errors into generic `Error`
|
||||
|
||||
```typescript
|
||||
class TimeoutError extends Error {
|
||||
constructor(
|
||||
public readonly operation: string,
|
||||
public readonly waitedMs: number,
|
||||
) {
|
||||
super(`${operation} timed out after ${waitedMs}ms`);
|
||||
this.name = "TimeoutError";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Keep logic density low
|
||||
|
||||
Avoid packing branching, lookup, and transformation into single dense expressions.
|
||||
|
||||
```typescript
|
||||
// Bad: nested ternaries + inline lookups
|
||||
const billing = shouldUseLegacy(account)
|
||||
? getLegacy(account)
|
||||
: buildBilling(
|
||||
account,
|
||||
rates.find((r) => r.region === account.region),
|
||||
);
|
||||
|
||||
// Good: named steps, then assemble
|
||||
const rate = rates.find((r) => r.region === account.region);
|
||||
if (!rate) throw new MissingRateError(account.region);
|
||||
const billing = shouldUseLegacy(account) ? getLegacy(account) : buildBilling(account, rate);
|
||||
```
|
||||
|
||||
## Centralize policy
|
||||
|
||||
When the same discriminator (`plan`, `provider`, `kind`, `status`) is checked across multiple files, centralize it into a policy model. A new case should require editing one place, not many.
|
||||
|
||||
## React: keep components dumb
|
||||
|
||||
- Components render state and dispatch events — they don't compute transitions
|
||||
- If a component has more than two interacting `useState` calls, extract a state machine or reducer
|
||||
- `useRef` for mutable coordination state (flags, timers) is a smell — model states explicitly
|
||||
- Never mirror a source of truth into local state; derive from it
|
||||
- Test state logic as pure functions without rendering
|
||||
|
||||
## File organization
|
||||
|
||||
- Organize by domain first (`providers/claude/`), not by technical type (`tool-parsers/`)
|
||||
- Name files after the main export (`create-toolcall.ts`)
|
||||
- Use `index.ts` as an entrypoint, not a dumping ground
|
||||
- Collocate tests with implementation (`thing.ts` + `thing.test.ts`)
|
||||
|
||||
## Refactoring contract
|
||||
|
||||
Refactoring is structure work, not feature work.
|
||||
|
||||
- Preserve behavior by default, especially user-facing behavior
|
||||
- Do not remove features to simplify code without explicit approval
|
||||
- Have a verification strategy before you start
|
||||
- Fully migrate callers and remove old paths in the same refactor
|
||||
- No fallback behavior by default — prefer explicit error over silent degradation
|
||||
@@ -1,73 +0,0 @@
|
||||
# Designing Features
|
||||
|
||||
How to think through a feature before writing code.
|
||||
|
||||
## Start from the user
|
||||
|
||||
Even for backend work, start from the user's perspective:
|
||||
|
||||
- What problem does this solve?
|
||||
- What triggers it? User action, schedule, event?
|
||||
- What does success look like from the user's perspective?
|
||||
- What data does it need? Where does that data come from?
|
||||
|
||||
## Map existing code
|
||||
|
||||
Before designing anything new, understand what exists:
|
||||
|
||||
- Where does similar functionality live?
|
||||
- What patterns does the codebase already use?
|
||||
- What layers exist? (See [ARCHITECTURE.md](./ARCHITECTURE.md))
|
||||
- What types and data shapes are already defined?
|
||||
|
||||
New features rarely mean only new code. Usually they require modifying existing interfaces, extending existing types, or refactoring to accommodate the new functionality. Identify what needs to change, not just what needs to be added.
|
||||
|
||||
## Define verification before implementation
|
||||
|
||||
Before designing the solution, define how you'll know it works:
|
||||
|
||||
- What tests will prove this feature is correct?
|
||||
- At what layer? Unit, integration, E2E?
|
||||
- What's the simplest way to verify the core behavior?
|
||||
|
||||
If you can't define verification, you don't understand the feature well enough yet.
|
||||
|
||||
## Design the shape
|
||||
|
||||
### Data
|
||||
|
||||
- What types are needed?
|
||||
- Use discriminated unions — make impossible states impossible
|
||||
- One canonical type per concept (see [CODING_STANDARDS.md](./CODING_STANDARDS.md))
|
||||
|
||||
### Layers
|
||||
|
||||
- What belongs in each layer?
|
||||
- Where are the boundaries?
|
||||
- What does each layer expose to the layer above?
|
||||
|
||||
### Interactions
|
||||
|
||||
- How does data flow through the system?
|
||||
- What triggers what?
|
||||
- Where do side effects happen?
|
||||
|
||||
### Refactoring
|
||||
|
||||
- What existing code needs to change?
|
||||
- Is existing code testable enough? If not, that's part of the plan.
|
||||
|
||||
## Create a concrete plan
|
||||
|
||||
Once the design is clear:
|
||||
|
||||
1. **Acceptance criteria** — specific, verifiable outcomes (not "should work well" but "returns X when given Y")
|
||||
2. **Ordered steps** — what to build first (usually: types, then lowest layer, then up)
|
||||
3. **What to refactor** before adding new code
|
||||
4. **How to verify** each step
|
||||
|
||||
## Principles
|
||||
|
||||
- **Fit, don't force** — new code should fit existing patterns, or refactor first
|
||||
- **Simple** — the best design is the simplest one that works
|
||||
- **Verify early** — define how to test before designing the implementation
|
||||
@@ -1,198 +0,0 @@
|
||||
# Development
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js (see `.tool-versions` for exact version)
|
||||
- npm workspaces (comes with Node)
|
||||
|
||||
## Running the dev server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The dev script automatically picks an available port. Both the server and Expo app run in a Tmux session — see `CLAUDE.local.md` for system-specific session details.
|
||||
|
||||
### Running alongside the main checkout
|
||||
|
||||
Set `PASEO_HOME` to isolate state when running a second instance (e.g., in a worktree):
|
||||
|
||||
```bash
|
||||
PASEO_HOME=~/.paseo-blue npm run dev
|
||||
```
|
||||
|
||||
- `PASEO_HOME` — path for runtime state (agents, sockets, etc.). Defaults to `~/.paseo`.
|
||||
- In git worktrees, `npm run dev` derives a stable home like `~/.paseo-<worktree-name>`.
|
||||
On first run, it seeds that home from `~/.paseo` by copying agent/project JSON metadata
|
||||
and `config.json`; actual checkout/worktree directories are not copied.
|
||||
- `PASEO_DEV_SEED_HOME=/path/to/home npm run dev` seeds from a different source home.
|
||||
- `PASEO_DEV_RESET_HOME=1 npm run dev` clears and reseeds the derived worktree home.
|
||||
|
||||
### Default ports
|
||||
|
||||
In the main checkout:
|
||||
|
||||
- Daemon: `localhost:6767`
|
||||
- Expo app: `localhost:8081`
|
||||
|
||||
In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
|
||||
|
||||
### Daemon logs
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
||||
|
||||
### Database queries
|
||||
|
||||
Run arbitrary SQL against the SQLite database:
|
||||
|
||||
```bash
|
||||
# Show table row counts
|
||||
npm run db:query
|
||||
|
||||
# Run any SQL
|
||||
npm run db:query -- "SELECT agent_id, title, last_status FROM agent_snapshots"
|
||||
npm run db:query -- "SELECT agent_id, seq, item_kind FROM agent_timeline_rows ORDER BY committed_at DESC LIMIT 10"
|
||||
|
||||
# Point at a specific DB directory
|
||||
npm run db:query -- --db /path/to/db "SELECT ..."
|
||||
```
|
||||
|
||||
Auto-detects the running dev daemon's database from `/tmp/paseo-dev.*`, `PASEO_HOME`, or `~/.paseo/db`.
|
||||
Pass either a DB directory or a `paseo.sqlite` file to `--db`. The script opens the database directly in read-only mode.
|
||||
|
||||
## paseo.json service scripts
|
||||
|
||||
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
||||
of commands. Both run sequentially.
|
||||
|
||||
```json
|
||||
{
|
||||
"worktree": {
|
||||
"setup": "npm ci\ncp \"$PASEO_SOURCE_CHECKOUT_PATH/.env\" .env\nnpm run db:migrate",
|
||||
"teardown": "npm run db:drop || true"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Every `scripts` entry with `"type": "service"` receives these environment variables:
|
||||
|
||||
| Variable | Value |
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
|
||||
| `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. |
|
||||
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
|
||||
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
|
||||
| `HOST` | Bind host for the service process. |
|
||||
|
||||
`<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error.
|
||||
|
||||
`PORT` is not injected by default. If a framework requires `PORT`, set it in the command:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"web": {
|
||||
"type": "service",
|
||||
"command": "PORT=$PASEO_PORT npm run dev:web"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Build sync gotchas
|
||||
|
||||
### Relay → Daemon
|
||||
|
||||
When changing `packages/relay/src/*`, rebuild before running the daemon:
|
||||
|
||||
```bash
|
||||
npm run build --workspace=@getpaseo/relay
|
||||
```
|
||||
|
||||
The Node daemon imports `@getpaseo/relay` from `packages/relay/dist/*`, not `src/*`.
|
||||
|
||||
### Server → CLI
|
||||
|
||||
When changing `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types, rebuild before running CLI commands:
|
||||
|
||||
```bash
|
||||
npm run build --workspace=@getpaseo/server
|
||||
```
|
||||
|
||||
The CLI imports `@getpaseo/server` via package exports resolving to `dist/*`. Stale `dist` means the CLI speaks an old protocol and fails with handshake warnings or timeouts.
|
||||
|
||||
## CLI reference
|
||||
|
||||
Use `npm run cli` to run the local CLI (instead of the globally installed `paseo` which points to the main checkout).
|
||||
|
||||
```bash
|
||||
npm run cli -- ls -a -g # List all agents globally
|
||||
npm run cli -- ls -a -g --json # Same, as JSON
|
||||
npm run cli -- inspect <id> # Show detailed agent info
|
||||
npm run cli -- logs <id> # View agent timeline
|
||||
npm run cli -- daemon status # Check daemon status
|
||||
```
|
||||
|
||||
Use `--host <host:port>` to point the CLI at a different daemon:
|
||||
|
||||
```bash
|
||||
npm run cli -- --host localhost:7777 ls -a
|
||||
```
|
||||
|
||||
## Agent state
|
||||
|
||||
Agent data lives at:
|
||||
|
||||
```
|
||||
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
```
|
||||
|
||||
Find an agent by ID:
|
||||
|
||||
```bash
|
||||
find $PASEO_HOME/agents -name "{agent-id}.json"
|
||||
```
|
||||
|
||||
Find by content:
|
||||
|
||||
```bash
|
||||
rg -l "some title text" $PASEO_HOME/agents/
|
||||
```
|
||||
|
||||
## Provider session files
|
||||
|
||||
Get the session ID from the agent JSON (`persistence.sessionId`), then:
|
||||
|
||||
**Claude:**
|
||||
|
||||
```
|
||||
~/.claude/projects/{cwd-with-dashes}/{session-id}.jsonl
|
||||
```
|
||||
|
||||
**Codex:**
|
||||
|
||||
```
|
||||
~/.codex/sessions/{YYYY}/{MM}/{DD}/rollout-{timestamp}-{session-id}.jsonl
|
||||
```
|
||||
|
||||
## Testing with Playwright MCP
|
||||
|
||||
Use Playwright MCP connecting to Metro at `http://localhost:8081` for UI testing.
|
||||
|
||||
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.
|
||||
|
||||
## Expo troubleshooting
|
||||
|
||||
```bash
|
||||
npx expo-doctor
|
||||
```
|
||||
|
||||
Diagnoses version mismatches and native module issues.
|
||||
|
||||
## Typecheck
|
||||
|
||||
Always run typecheck after changes:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
@@ -1,45 +0,0 @@
|
||||
# Paseo Glossary
|
||||
|
||||
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
|
||||
|
||||
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add a project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:17`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:88`). Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/server/src/shared/messages.ts:2128`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:9`).
|
||||
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/server/src/shared/messages.ts:597`). Forbidden: "Task", "Job", "Run".
|
||||
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` (`packages/server/src/shared/messages.ts:1886`), `DaemonClient` (`packages/server/src/client/daemon-client.ts`).
|
||||
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:36`). Forbidden: "Connection" (means `HostConnection`, not host).
|
||||
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/server/src/shared/messages.ts:2063`).
|
||||
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` (`packages/server/src/shared/messages.ts:2027`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/server/src/shared/messages.ts:2042`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
|
||||
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
|
||||
- **Session** — Per-client connection to a daemon. Internal. Code: `Session` (`packages/server/src/server/session.ts`). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:36`). Never user-facing.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, OpenCode). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:193`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/server/src/shared/messages.ts:182`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/server/src/shared/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/server/src/shared/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/server/src/shared/messages.ts:249`).
|
||||
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/server/src/shared/messages.ts:736`).
|
||||
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:576`); (b) **git merge conflict** (no current UI string).
|
||||
|
||||
## Open question — per-host project entry (TBD)
|
||||
|
||||
A project aggregates workspaces across daemons. The projects screen and project-settings screen need a name for "one row in the project list per (project, daemon)". `ProjectPlacementPayload` is **per-workspace**, so it isn't this thing — but the noun "placement" could naturally extend (`ProjectDaemonPlacement`, or just "placements grouped by daemon"). The in-progress `ProjectCheckout` type (`packages/app/src/utils/projects.ts:4`) is also per-workspace today, even though the settings UI selector treats it per-daemon (matched on `serverId` alone) — so the code is currently inconsistent with itself.
|
||||
|
||||
Candidates: (1) extend "placement" to the (project, daemon) bundle, (2) drop the wrapper type and use `{ host, project, workspaces[] }` plus descriptive UI copy ("project · host X · 2 online"). Recommendation: option (2) — no new noun, use existing terms compositionally. Do not introduce "Checkout" / `ProjectCheckout` as the canonical name.
|
||||
|
||||
## Rename before landing (in-progress `Checkout*` references)
|
||||
|
||||
- `packages/app/src/utils/projects.ts:4,20,22,23,46,85,104,120,124,126,146,148,155` — `ProjectCheckout`, `checkouts`, `checkoutCount`, `onlineCheckoutCount`, `buildCheckoutTarget`, `compareCheckouts`.
|
||||
- `packages/app/src/screens/projects-screen.tsx:73,74,77,90` — `checkoutLabel`, `${checkoutCount} checkout(s)`, `onlineSuffix`.
|
||||
- `packages/app/src/screens/project-settings-screen.tsx:17,41,46,55,76,81,110-152,323,443-600` — `ProjectCheckout` import, `CheckoutSelection`, `CheckoutSelector`, `CheckoutOption`, `usableCheckouts`, `onlineUsableCheckouts`, `selectedCheckout`, `selectedCheckoutKey`, "no online checkout", "no checkout with a valid server", `testID="checkout-selector"`, `testID="checkout-option-*"`, a11y `"Edit X checkout"`.
|
||||
- `packages/app/src/screens/project-settings-screen.test.tsx:185,195,196,366,369,378,389,412,415,431,432,456,464,478,481,490,501,504,521` — `checkouts`, `checkoutCount`, `onlineCheckoutCount`, "no checkouts are online", "checkout selector", "online checkout".
|
||||
- `packages/app/src/utils/projects.test.ts:107,108,111,259` — `checkoutCount`, `onlineCheckoutCount`, `checkouts`.
|
||||
|
||||
(Out of scope for this rename: `ProjectCheckoutLite*Payload` and the git-`checkout` family in `packages/server/src/utils/checkout-*.ts`. Those refer to _git_ checkout state, not the rejected (project, daemon) sense.)
|
||||
|
||||
## Inconsistencies (documented, not papered over)
|
||||
|
||||
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/commands/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
|
||||
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/server/src/shared/messages.ts:2137`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:9`).
|
||||
- In-progress `ProjectCheckout` (`packages/app/src/utils/projects.ts:4`) is per-workspace, but `project-settings-screen.tsx` selects by `serverId` alone — same daemon with multiple workspaces will produce duplicate React keys in the selector.
|
||||
@@ -1,6 +1,10 @@
|
||||
# Ad-hoc daemon testing
|
||||
|
||||
Spin up an isolated daemon programmatically without touching the main daemon on port 6767.
|
||||
Spin up an isolated in-process daemon test harness without touching the main daemon on port 6767.
|
||||
|
||||
This is for test code only. Executable daemon processes must start through
|
||||
`scripts/supervisor-entrypoint.ts` or `dist/scripts/supervisor-entrypoint.js`;
|
||||
do not use `createPaseoDaemon` as a product launch path.
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -44,7 +48,7 @@ const port = target!.type === "tcp" ? target!.port : null;
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${port}/ws`,
|
||||
appVersion: "0.1.54", // see gotcha #1
|
||||
appVersion: "0.1.70", // see gotcha #1
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||
@@ -74,7 +78,7 @@ import { DaemonClient } from "./test-utils/daemon-client.js";
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.54",
|
||||
appVersion: "0.1.70",
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||
@@ -85,7 +89,7 @@ await client.close();
|
||||
await daemon.close(); // stops daemon + cleans up temp dirs
|
||||
```
|
||||
|
||||
The test helper does **not** expose `providerOverrides`. Use `createPaseoDaemon` directly when you need it (see quick start above).
|
||||
The test helper does **not** expose `providerOverrides`. In test harnesses, use `createPaseoDaemon` directly when you need it (see quick start above).
|
||||
|
||||
## Common client methods
|
||||
|
||||
@@ -112,7 +116,7 @@ Always pass `appVersion`:
|
||||
```typescript
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${port}/ws`,
|
||||
appVersion: "0.1.54",
|
||||
appVersion: "0.1.70",
|
||||
});
|
||||
```
|
||||
|
||||
107
docs/agent-lifecycle.md
Normal file
107
docs/agent-lifecycle.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Agent lifecycle
|
||||
|
||||
How an agent is created, runs, becomes a subagent, gets archived, and disappears from the UI. The model spans the daemon (lifecycle, archive) and the client (tabs, the subagents track).
|
||||
|
||||
## States
|
||||
|
||||
```
|
||||
initializing → idle → running → idle (or error → closed)
|
||||
↑ │
|
||||
└────────┘ (agent completes a turn, awaits next prompt)
|
||||
```
|
||||
|
||||
Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, `error`, or `closed`. State transitions persist to disk and stream to subscribed clients via WebSocket.
|
||||
|
||||
## Relationships
|
||||
|
||||
Agents can launch other agents via the `create_agent` MCP tool. When they do, the daemon stamps the new agent with a label `paseo.parent-agent-id` pointing back at the caller (`packages/server/src/server/agent/mcp-server.ts:804`). The client surfaces that as `agent.parentAgentId`.
|
||||
|
||||
There is exactly one relationship type today: `parentAgentId`. The daemon does not distinguish between:
|
||||
|
||||
- **Subagents** — children that exist as part of the parent's work (e.g. orchestration tasks the parent delegates and waits on)
|
||||
- **Detached agents** — children launched to take over from the parent (e.g. handoffs, fire-and-forget delegations)
|
||||
|
||||
Both look the same in storage. This is an accepted limitation — see [Limitations](#limitations).
|
||||
|
||||
## Archive
|
||||
|
||||
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
|
||||
|
||||
Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`):
|
||||
|
||||
1. Snapshot the current session into the registry
|
||||
2. Set `archivedAt` and normalize `lastStatus` away from `running`/`initializing`
|
||||
3. Notify subscribers
|
||||
4. Close the runtime (kills the process if still running)
|
||||
5. **Cascade-archive children** — any agent whose `paseo.parent-agent-id` label matches the archived agent gets archived too, recursively
|
||||
|
||||
Cascade is what keeps subagent fleets from outliving their orchestrator.
|
||||
|
||||
## Tabs vs archive
|
||||
|
||||
These are two distinct concepts that used to be conflated:
|
||||
|
||||
| Concept | Scope | Triggers |
|
||||
| -------------------------- | ---------- | -------------------------- |
|
||||
| **Tab** (workspace layout) | Per-client | User opens/closes a view |
|
||||
| **Archive** (lifecycle) | Global | Explicit lifecycle gesture |
|
||||
|
||||
Closing a tab on a **root agent** still archives — the tab is the agent's home, so closing it means "I'm done with this agent." A confirm dialog protects against archiving a running agent by accident.
|
||||
|
||||
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The agent stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`).
|
||||
|
||||
The asymmetry is intentional: a subagent's home is the parent's track, not the tab. Tabs are ephemeral viewing slots; the track is the persistent record of the parent's children.
|
||||
|
||||
## The subagents track
|
||||
|
||||
The collapsible section above the composer in an agent's pane (`packages/app/src/subagents/subagents-section.tsx`). Membership rule (`packages/app/src/subagents/subagents.ts`):
|
||||
|
||||
```
|
||||
parentAgentId === thisAgent.id AND !archivedAt
|
||||
```
|
||||
|
||||
Archived subagents disappear from the track, by design. To remove a subagent from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. That same archive shows the subagent leave the track on every connected client.
|
||||
|
||||
## Why this shape
|
||||
|
||||
The decision was to **decouple "close tab" from "archive" only for subagents**, rather than universally:
|
||||
|
||||
- **Closing a tab on a root agent still archives** — preserves the existing UX users are trained on
|
||||
- **Closing a tab on a subagent is layout-only** — fixes the lossy "click to read, close to dismiss view, lose the row" flow
|
||||
- **Archive button on track rows** — gives subagents an explicit lifecycle gesture in their home surface
|
||||
- **Cascade archive on parent** — keeps subagents from leaking when the parent is archived
|
||||
|
||||
We considered universal decoupling (no tab close ever archives, archive is always explicit) but rejected it: it changes a behavior root-agent users rely on.
|
||||
|
||||
## Limitations
|
||||
|
||||
### Detached agents are cascade-archived
|
||||
|
||||
The daemon can't tell a "subagent" apart from a "detached agent" — both carry `paseo.parent-agent-id`. So when you archive an agent that previously launched a detached child (e.g. via `/paseo-handoff`), cascade will archive the detached child too, even though semantically it should outlive the originator.
|
||||
|
||||
Until a richer relation model lands (e.g. a `relation: "subagent" | "detached"` field on creation, or a separate channel for handoff launches), this trade-off stands. Workaround: don't archive an agent whose work was handed off, or unarchive the detached child afterward.
|
||||
|
||||
### Subagent accumulation under long-lived parents
|
||||
|
||||
A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem.
|
||||
|
||||
### Cross-client tab dismissal
|
||||
|
||||
Closing a subagent's tab on one client doesn't affect other clients' layouts. This is the expected behavior of decoupled tabs and is consistent with how layouts have always worked. Archive remains the global gesture for cross-client cleanup.
|
||||
|
||||
## Storage
|
||||
|
||||
```
|
||||
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
```
|
||||
|
||||
Each agent is a single JSON file. Fields relevant to this doc:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------------- | ------------- | ------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` MCP tool |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
|
||||
See [`docs/data-model.md`](./data-model.md) for the full agent record.
|
||||
@@ -27,12 +27,12 @@ Or from `packages/app`:
|
||||
|
||||
```bash
|
||||
# Debug
|
||||
APP_VARIANT=development npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=development npx expo run:android --variant=debug
|
||||
npx cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive
|
||||
npx cross-env APP_VARIANT=development expo run:android --variant=debug
|
||||
|
||||
# Release
|
||||
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=production npx expo run:android --variant=release
|
||||
npx cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive
|
||||
npx cross-env APP_VARIANT=production expo run:android --variant=release
|
||||
|
||||
# Clear generated Android project
|
||||
rm -rf android
|
||||
@@ -53,6 +53,8 @@ Stable tag pushes like `v0.1.0` trigger:
|
||||
|
||||
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||
|
||||
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. Both workflows also support `workflow_dispatch`; the GitHub APK one takes an existing `tag` input so you can rebuild without cutting a new tag.
|
||||
|
||||
### Useful commands
|
||||
|
||||
```bash
|
||||
270
docs/architecture.md
Normal file
270
docs/architecture.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# Architecture
|
||||
|
||||
Paseo is a client-server system for monitoring and controlling local AI coding agents. The daemon runs on your machine, manages agent processes, and streams their output in real time over WebSocket. Clients (mobile app, CLI, desktop app) connect to the daemon to observe and interact with agents.
|
||||
|
||||
Your code never leaves your machine. Paseo is local-first.
|
||||
|
||||
## System overview
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Mobile App │ │ CLI │ │ Desktop App │
|
||||
│ (Expo) │ │ (Commander) │ │ (Electron) │
|
||||
└──────┬───────┘ └──────┬──────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
│ WebSocket │ WebSocket │ Managed subprocess
|
||||
│ (direct or │ (direct) │ + WebSocket
|
||||
│ via relay) │ │
|
||||
└───────────┬───────┴──────────────────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ Daemon │
|
||||
│ (Node.js) │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌────────────┼────────────┐
|
||||
│ │ │
|
||||
┌─────▼─────┐ ┌───▼────┐ ┌────▼─────┐
|
||||
│ Claude │ │ Codex │ │ OpenCode │
|
||||
│ Agent │ │ Agent │ │ Agent │
|
||||
│ SDK │ │ Server │ │ │
|
||||
└───────────┘ └────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## Components at a glance
|
||||
|
||||
- **Daemon:** Local server that spawns and manages agent processes and exposes the WebSocket API.
|
||||
- **App:** Cross-platform Expo client for iOS, Android, web, and the shared UI used by desktop.
|
||||
- **CLI:** Terminal interface for agent workflows that can also start and manage the daemon.
|
||||
- **Desktop app:** Electron wrapper around the web app that bundles and auto-manages its own daemon.
|
||||
- **Relay:** Optional encrypted bridge for remote access without opening ports directly.
|
||||
|
||||
## Packages
|
||||
|
||||
### `packages/server` — The daemon
|
||||
|
||||
The heart of Paseo. A Node.js process that:
|
||||
|
||||
- Listens for WebSocket connections from clients
|
||||
- Manages agent lifecycle (create, run, stop, resume, archive)
|
||||
- Streams agent output in real time via a timeline model
|
||||
- Exposes an MCP server for agent-to-agent control
|
||||
- Optionally connects outbound to a relay for remote access
|
||||
|
||||
All paths are under `packages/server/src/`.
|
||||
|
||||
**Key modules:**
|
||||
|
||||
| Module | Responsibility |
|
||||
| ------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
|
||||
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
|
||||
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
||||
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
|
||||
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
||||
| `server/agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
|
||||
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
|
||||
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
|
||||
| `server/schedule/` | Cron-based scheduled agents |
|
||||
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
|
||||
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
|
||||
| `client/daemon-client.ts` | Client library for connecting to the daemon (used by CLI and app) |
|
||||
| `shared/messages.ts` | Zod schemas for the entire wire protocol |
|
||||
| `shared/binary-frames/` | Terminal stream and file transfer binary frame codecs |
|
||||
|
||||
### `packages/app` — Mobile + web client (Expo)
|
||||
|
||||
Cross-platform React Native app that connects to one or more daemons.
|
||||
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.)
|
||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
|
||||
- Voice features: dictation (STT) and voice agent (realtime)
|
||||
|
||||
### `packages/cli` — Command-line client
|
||||
|
||||
Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`).
|
||||
|
||||
- `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode`
|
||||
- `paseo daemon start/stop/restart/status/pair/set-password`
|
||||
- `paseo chat ls/create/inspect/post/read/wait/delete`
|
||||
- `paseo terminal ls/create/capture/send-keys/kill`
|
||||
- `paseo loop run/ls/inspect/logs/stop`
|
||||
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
|
||||
- `paseo permit allow/deny/ls`
|
||||
- `paseo provider ls/models`
|
||||
- `paseo worktree create/ls/archive`
|
||||
- `paseo speech …`
|
||||
|
||||
Communicates with the daemon via the same WebSocket protocol as the app.
|
||||
|
||||
### `packages/relay` — E2E encrypted relay
|
||||
|
||||
Enables remote access when the daemon is behind a firewall.
|
||||
|
||||
- Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl `box`) encryption
|
||||
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
|
||||
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
|
||||
- Pairing via QR code transfers the daemon's public key to the client
|
||||
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS`
|
||||
|
||||
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
||||
|
||||
### `packages/desktop` — Desktop app (Electron)
|
||||
|
||||
Electron wrapper for macOS, Linux, and Windows.
|
||||
|
||||
- Can spawn the daemon as a managed subprocess
|
||||
- Native file access for workspace integration
|
||||
- Same WebSocket client as mobile app
|
||||
|
||||
### `packages/website` — Marketing site
|
||||
|
||||
TanStack Router + Cloudflare Workers. Serves paseo.sh.
|
||||
|
||||
## WebSocket protocol
|
||||
|
||||
All clients speak the same WebSocket protocol over a single connection that mixes JSON text frames and a small binary framing for terminal streams. Schemas live in `packages/server/src/shared/messages.ts`.
|
||||
|
||||
**Handshake:**
|
||||
|
||||
```
|
||||
Client → Server: WSHelloMessage {
|
||||
type: "hello",
|
||||
clientId,
|
||||
clientType: "mobile" | "browser" | "cli" | "mcp",
|
||||
protocolVersion,
|
||||
appVersion?,
|
||||
capabilities?: { voice?, pushNotifications?, ... },
|
||||
}
|
||||
Server → Client: status message with payload { status: "server_info",
|
||||
serverId, hostname, version, capabilities?, features }
|
||||
```
|
||||
|
||||
There is no dedicated welcome message; the server emits a `status` session message after accepting the hello, then begins streaming. The session stores client capabilities from the hello and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
|
||||
|
||||
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
|
||||
|
||||
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
|
||||
|
||||
**Notable session message types:**
|
||||
|
||||
- `agent_update` — Agent state changed (status, title, labels)
|
||||
- `agent_stream` — New timeline event from a running agent
|
||||
- `workspace_update`, `script_status_update`, `workspace_setup_progress` — Workspace state
|
||||
- `agent_permission_request` / `agent_permission_resolved` — Tool-call permission flow
|
||||
- `agent_deleted`, `agent_archived`, `agent_status`, `agent_list`
|
||||
- `checkout_status_update`, `checkout_diff_update`, and the full `checkout_*` request/response set for git operations
|
||||
- Terminal subscribe/input/capture commands
|
||||
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
|
||||
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
|
||||
|
||||
**Binary frames (terminal stream protocol):**
|
||||
|
||||
Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is:
|
||||
|
||||
- 1-byte opcode: `Output (0x01)`, `Input (0x02)`, `Resize (0x03)`, `Snapshot (0x04)`
|
||||
- 1-byte slot: terminal slot id
|
||||
- variable payload: bytes for output/input, JSON-encoded `{ rows, cols }` for resize, terminal snapshot for snapshot
|
||||
|
||||
There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams.
|
||||
|
||||
### Compatibility rules
|
||||
|
||||
- WebSocket schemas are append-only. Add fields, do not remove fields, and never make optional fields required.
|
||||
- New wire enum values must be gated at serialization with `session.supports(CLIENT_CAPS.someCapability)`.
|
||||
- `Session` stores client capabilities from the `hello` handshake and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
|
||||
|
||||
Example: adding a new enum value
|
||||
|
||||
```ts
|
||||
// 1. Add CLIENT_CAPS.newThing = "new_thing"
|
||||
// 2. Let new clients advertise it in WS hello
|
||||
// 3. Keep the shared producer schema strict
|
||||
// 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value"
|
||||
```
|
||||
|
||||
## Agent lifecycle
|
||||
|
||||
The lifecycle states are defined in `shared/agent-lifecycle.ts`:
|
||||
|
||||
```
|
||||
initializing → idle ⇄ running
|
||||
↓ ↓ ↓
|
||||
error
|
||||
↓
|
||||
closed
|
||||
```
|
||||
|
||||
- `initializing` — provider session is being created
|
||||
- `idle` — has a live session, awaiting the next prompt
|
||||
- `running` — provider is currently producing a turn
|
||||
- `error` — last attempt failed; session is still attached
|
||||
- `closed` — terminal state, no live session
|
||||
|
||||
`ManagedAgent` is a discriminated union over those lifecycle tags. Notes:
|
||||
|
||||
- **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers
|
||||
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
|
||||
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
|
||||
- Events stream to all subscribed clients in real time
|
||||
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record)
|
||||
|
||||
## Agent providers
|
||||
|
||||
Each provider implements the `AgentClient` interface in `agent/agent-sdk-types.ts`. Provider implementations live in `agent/providers/`.
|
||||
|
||||
The three first-class, user-facing providers are Claude Code, Codex, and OpenCode. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
|
||||
|
||||
| Provider | Wraps | Session format |
|
||||
| ------------------ | ------------------------------------ | -------------------------------------------------- |
|
||||
| Claude (`claude/`) | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
|
||||
| Codex | Codex AppServer (`codex-app-server`) | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
|
||||
| OpenCode | OpenCode server / CLI | Provider-managed |
|
||||
| Cursor / Copilot | ACP wrapper (`acp-agent`) | Provider-managed |
|
||||
| Generic ACP | ACP wrapper | Provider-managed |
|
||||
| Pi-direct | Direct Anthropic API call | Stateless |
|
||||
| Mock load test | In-process fake | In-memory |
|
||||
|
||||
All providers:
|
||||
|
||||
- Handle their own authentication (Paseo does not manage API keys)
|
||||
- Support session resume via persistence handles
|
||||
- Map tool calls to a normalized `ToolCallDetail` type
|
||||
- Expose provider-specific modes (plan, default, full-access)
|
||||
|
||||
## Data flow: running an agent
|
||||
|
||||
1. Client sends `CreateAgentRequestMessage` with config (prompt, cwd, provider, model, mode)
|
||||
2. Session routes to `AgentManager.create()`
|
||||
3. AgentManager creates a `ManagedAgent`, initializes provider session
|
||||
4. Provider runs the agent → emits `AgentStreamEvent` items
|
||||
5. Events append to the agent timeline, broadcast to all subscribed clients
|
||||
6. Tool calls are normalized to `ToolCallDetail` (shell, read, edit, write, search, etc.)
|
||||
7. Permission requests flow: agent → server → client → user decision → server → agent
|
||||
|
||||
## Storage
|
||||
|
||||
`$PASEO_HOME` defaults to `~/.paseo`. The most important files:
|
||||
|
||||
```
|
||||
$PASEO_HOME/
|
||||
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record + persisted timeline rows
|
||||
├── projects/projects.json # Project registry
|
||||
├── projects/workspaces.json # Workspace registry
|
||||
├── chat/ # Chat rooms
|
||||
├── schedules/ # Scheduled-agent definitions and runs
|
||||
├── loops/ # Loop runs and logs
|
||||
├── config.json # Daemon config (mutable)
|
||||
├── daemon-keypair.json # Daemon identity for relay/E2EE
|
||||
├── push-tokens.json # Mobile push tokens
|
||||
├── paseo.sock / paseo.pid # Local IPC socket and pidfile
|
||||
└── daemon.log # Daemon trace logs (rotated)
|
||||
```
|
||||
|
||||
## Deployment models
|
||||
|
||||
1. **Local daemon** (default): `paseo daemon start` on `127.0.0.1:6767`
|
||||
2. **Managed desktop**: Electron app spawns daemon as subprocess
|
||||
3. **Remote + relay**: Daemon behind firewall, relay bridges with E2E encryption
|
||||
98
docs/coding-standards.md
Normal file
98
docs/coding-standards.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Coding Standards
|
||||
|
||||
The core instinct: AI-generated code hedges — it covers every case, layers over instead of cutting in, scatters uncertainty everywhere, wraps in case. A senior engineer commits — to a shape, a boundary, a name, a happy path, a type — and lets everything else fall into place. Every rule below catches a different form of indecision.
|
||||
|
||||
For testing rules, see [testing.md](testing.md).
|
||||
|
||||
## Core principles
|
||||
|
||||
- **Zero complexity budget** — every abstraction must justify itself with a specific, current benefit.
|
||||
- **YAGNI** — build features and abstractions only when needed. A function called once is indirection, not abstraction.
|
||||
- **No "while I'm at it" cleanups** — make the change you came for. Drive-by edits hide in the diff.
|
||||
- **Functional and declarative** over object-oriented.
|
||||
- **`function` declarations** over arrow function assignments.
|
||||
- **`interface`** over `type` when both work.
|
||||
- **No `index.ts` barrel files** that only re-export — they create indirection and circular-dep risk. Import from the source.
|
||||
|
||||
## Comments and noise
|
||||
|
||||
- Delete any comment where removing it loses zero information. Comments explain _why_, not _what_.
|
||||
- No tutorial comments explaining language features (`// Use destructuring to...`).
|
||||
- No decorative section dividers (`// ===== Helpers =====`). Use files and modules to organize, not ASCII art.
|
||||
- No hedging comments (`// might need to revisit`, `// should work for most cases`). If you're unsure, investigate.
|
||||
- No commented-out code. Git remembers.
|
||||
- No `console.log` / `debugger` left behind. No `TODO: implement` stubs — if it needs to exist, write it.
|
||||
|
||||
## Confidence: commit to a shape
|
||||
|
||||
- Validate at boundaries (network, IPC, user input, file I/O), trust types internally. After the parse, the value is what its type says.
|
||||
- Every `?.` and `??` past the validation boundary is unconfident code — either the boundary should resolve it, or the type should reflect reality.
|
||||
- No defensive checks for conditions the type system already rules out (`if (!agent) return` on a non-nullable parameter).
|
||||
- No `try/catch` "just in case." If you can't say what you're catching and why, don't catch.
|
||||
- Optionality is a design decision, not a migration shortcut. Distinct valid states → discriminated union. Intentionally empty → explicit `null`. Keep optionality at real boundaries.
|
||||
|
||||
## Types
|
||||
|
||||
- No `any`. No `as` casts to bypass errors. No `@ts-ignore` / `@ts-expect-error`. Narrow with `if` / schema validation; let the compiler check harder, not less.
|
||||
- If a Zod schema exists, the TypeScript type is `z.infer<typeof schema>`. Never hand-write a parallel type.
|
||||
- One canonical type per concept. Layer-specific views are `Pick` / `Omit`, not duplicated fields.
|
||||
- Name multi-property object shapes — no inline `Array<{ ... }>` or `Promise<{ ... }>` in signatures, returns, or generic args.
|
||||
- Use string literal unions, not raw `string`, when the value is one of a known set. Catches typos at compile time.
|
||||
- Object parameters past the obvious-name threshold: 3+ args, any boolean arg, any optional arg → object. `(thing, true, false, true)` is unreadable at the call site.
|
||||
- Make impossible states impossible — discriminated unions over `{ isLoading; error?; data? }` bags.
|
||||
|
||||
## Errors
|
||||
|
||||
- Throw typed error classes that carry the fields a caller would want to read. Plain `Error("Provider X not found")` collapses structured info into a string.
|
||||
- Catch blocks branch on `instanceof` for what they can handle; rethrow the rest. No `catch (e) { return null }`.
|
||||
- Separate user-facing copy from log/debug strings — don't make one string serve telemetry, logs, and the UI.
|
||||
- Fail explicitly. If the caller asked for X and X isn't available, throw — don't silently substitute Y.
|
||||
|
||||
## Density
|
||||
|
||||
- Nested ternaries are forbidden. A single ternary is fine only when both branches are a single identifier or trivial access (`x ? a : b`).
|
||||
- Boolean expressions with 2+ clauses or mixed concerns → name the conditions.
|
||||
- Object literals assemble pre-computed values; don't pack branching and lookups into property positions.
|
||||
- Operations wrapping operations (`Object.fromEntries(arr.filter(...).map(...))`, `Math.max(...xs.map(...))`) → break into named intermediates.
|
||||
- Max 3 levels of nesting (callbacks, JSX, control flow). Above that, extract.
|
||||
|
||||
## Structure and modules
|
||||
|
||||
- A directory is a module, not a namespace. One intentional public surface; internal files stay internal.
|
||||
- Path is part of the name — prefer `provider/registry.ts` over `provider/provider-registry.ts`. If the filename has to do double duty, deepen the path.
|
||||
- Filenames ending in `-utils`, `-helpers`, `-manager`, `-handler`, `-controller`, `-formatter`, `-builder` are a smell — the path didn't carry enough domain.
|
||||
- Boundary returns answer the caller's question (`getActiveAgents()`), not "here's my storage" (`getAgents().filter(...)` repeated everywhere).
|
||||
- One adapter means a hypothetical seam; two adapters means a real one. Don't define a port until something actually varies across it.
|
||||
- Pass-through modules fail the deletion test — if removing the module makes callers go straight to what they wanted, delete it.
|
||||
- Centralize policy. The same discriminator (`plan`, `provider`, `kind`, `status`) branched in 3+ files → policy table, not another `else if` per case.
|
||||
- New features get a home before implementation. A feature smeared across 5 shared files is the same slop as a flat-peer namespace.
|
||||
- Don't drop new files at the nearest root just because placement is unclear — say so and ask.
|
||||
|
||||
## Refactoring is a bolt-on test
|
||||
|
||||
- A change should look like a thoughtful edit to existing code, not a new layer next to it. New coordinator wrapping a coordinator, new flag bypassing the normal path, new helper duplicating an existing selector — stop and reshape instead.
|
||||
- Refactors preserve behavior by default. No removing features to simplify code without explicit approval.
|
||||
- Have a verification plan _before_ refactoring — name the invariants, confirm a test holds them, write one if not. See [testing.md](testing.md).
|
||||
- Migrate all callers and remove old paths in the same refactor. No fallback behavior unless explicitly designed.
|
||||
|
||||
## React
|
||||
|
||||
- `useEffect` is for synchronizing with external systems (DOM, network, timers, subscriptions). Not for transforming React state. Derived state → compute in render or `useMemo`.
|
||||
- No effect cascades — chains of effects setting state that triggers more effects almost always want React Query or a reducer.
|
||||
- `useRef` is for DOM refs and non-rendering identities (timer IDs, AbortController, latest-callback caches). If the value affects what renders next, it's state — model it explicitly with `useReducer` and a discriminated union.
|
||||
- Server state goes through React Query. Manual `useState` + `useEffect` + `isLoading` + `error` for fetched data is always worse.
|
||||
- Components render and dispatch — they don't compute transitions. Two-plus interacting `useState`s → extract a reducer.
|
||||
- Never define components inside other components. Module-scope only.
|
||||
- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects.
|
||||
- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary.
|
||||
- Use stable ids for `key`, never array index for reorderable/filterable lists.
|
||||
- Context for stable values (theme, auth). Store with selectors for state that changes.
|
||||
|
||||
## Naming
|
||||
|
||||
- Names describe meaning, not mechanics. `submitForm` over `handleOnClickButtonSubmit`. `running` over `filteredArrayOfRunningAgents`.
|
||||
- The right length is the shortest unambiguous in context. Inside `AgentManager`, methods are `start`, `stop`, `list`.
|
||||
- Match the surrounding code's vocabulary. If the codebase uses `getX`, don't introduce `fetchX` / `retrieveX` for the same shape.
|
||||
- Don't leak implementation into names — `getAgent`, not `queryPostgresForAgent`. If swapping the impl would force a rename, the name is wrong.
|
||||
- Booleans read as yes/no questions: `isX`, `hasX`, `canX`. Avoid negative booleans (`isNotConnected`).
|
||||
- `data`, `result`, `info`, `manager`, `temp` are smells — say what the thing _is_.
|
||||
@@ -59,6 +59,30 @@ Required fields for custom providers:
|
||||
- `extends` — which built-in provider to inherit from (or `"acp"`)
|
||||
- `label` — display name in the UI
|
||||
|
||||
### Codex with an OpenAI-compatible endpoint
|
||||
|
||||
Custom providers that extend `"codex"` can point Codex at an OpenAI-compatible API by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`. Paseo still passes those variables through to the Codex app-server process, and also maps them into Codex's thread config (`model_provider` / `model_providers`) because Codex reads provider routing from config rather than from `OPENAI_BASE_URL`.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-codex": {
|
||||
"extends": "codex",
|
||||
"label": "My Codex",
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "sk-...",
|
||||
"OPENAI_BASE_URL": "https://custom-relay.example.com"
|
||||
},
|
||||
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If the base URL does not end in `/v1`, Paseo appends `/v1` for Codex's OpenAI-compatible provider config. If it already ends in `/v1`, Paseo leaves it as-is.
|
||||
|
||||
---
|
||||
|
||||
## Z.AI (Zhipu) coding plan
|
||||
@@ -83,6 +107,7 @@ Required fields for custom providers:
|
||||
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
|
||||
"API_TIMEOUT_MS": "3000000"
|
||||
},
|
||||
"disallowedTools": ["WebSearch"],
|
||||
"models": [
|
||||
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
|
||||
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
|
||||
@@ -136,6 +161,7 @@ Required fields for custom providers:
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<your-coding-plan-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
|
||||
},
|
||||
"disallowedTools": ["WebSearch"],
|
||||
"models": [
|
||||
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
|
||||
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" },
|
||||
@@ -340,11 +366,19 @@ Required fields for ACP providers:
|
||||
- `label`
|
||||
- `command` — the command to spawn the agent process (must support ACP over stdio)
|
||||
|
||||
### Generic ACP diagnostics
|
||||
|
||||
Paseo diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status.
|
||||
|
||||
For package-runner commands such as `npx -y @google/gemini-cli --acp`, the version probe keeps the package spec and runs `npx -y @google/gemini-cli --version`. This diagnoses the actual agent package instead of only proving that `npx` exists.
|
||||
|
||||
ACP probes use short timeouts and browser-suppression environment variables so agents that enter an auth/browser flow fail as a diagnostic error instead of hanging the provider screen.
|
||||
|
||||
### Example: Google Gemini CLI
|
||||
|
||||
[Gemini CLI](https://github.com/google-gemini/gemini-cli) supports ACP via the `--acp` flag.
|
||||
|
||||
1. Install: `npm install -g @anthropic-ai/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)
|
||||
1. Install: `npm install -g @google/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)
|
||||
2. Authenticate with Google (Gemini CLI handles its own auth)
|
||||
3. Add to config.json:
|
||||
|
||||
@@ -502,6 +536,12 @@ Each entry in the `models` array:
|
||||
| `description` | `string` | No | Short description |
|
||||
| `isDefault` | `boolean` | No | Mark as the default thinking option |
|
||||
|
||||
### Claude settings.json model discovery
|
||||
|
||||
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
||||
|
||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. `agents.providers.claude.models` is still supported and is additive for the built-in Claude provider; duplicate IDs are de-duplicated.
|
||||
|
||||
### Gotcha: `extends: "claude"` with third-party endpoints
|
||||
|
||||
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
|
||||
@@ -550,6 +590,7 @@ A config.json with multiple custom providers:
|
||||
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
|
||||
"API_TIMEOUT_MS": "3000000"
|
||||
},
|
||||
"disallowedTools": ["WebSearch"],
|
||||
"models": [
|
||||
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
|
||||
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
|
||||
@@ -564,6 +605,7 @@ A config.json with multiple custom providers:
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<coding-plan-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
|
||||
},
|
||||
"disallowedTools": ["WebSearch"],
|
||||
"models": [
|
||||
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
|
||||
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" }
|
||||
@@ -1,6 +1,6 @@
|
||||
# Data Model
|
||||
|
||||
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas and written atomically (write to temp file, then rename). There are no migrations — schemas use optional fields with defaults for forward compatibility.
|
||||
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries.
|
||||
|
||||
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||
|
||||
@@ -11,8 +11,12 @@ All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||
```
|
||||
$PASEO_HOME/
|
||||
├── config.json # Daemon configuration
|
||||
├── server-id # Stable daemon identifier (plain text, "srv_<base64url>")
|
||||
├── daemon-keypair.json # E2EE keypair for relay (mode 0600)
|
||||
├── paseo.pid # Daemon PID lock file
|
||||
├── daemon.log # Default log file (path configurable)
|
||||
├── agents/
|
||||
│ └── {project-dir}/
|
||||
│ └── {sanitized-cwd}/
|
||||
│ └── {agentId}.json # One file per agent
|
||||
├── schedules/
|
||||
│ └── {scheduleId}.json # One file per schedule
|
||||
@@ -26,6 +30,8 @@ $PASEO_HOME/
|
||||
└── push-tokens.json # Expo push notification tokens
|
||||
```
|
||||
|
||||
The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` by stripping the filesystem root and replacing path separators with `-` (Windows drive letters become a `C-` style prefix). Atomic writes (temp file + rename): agent records, chat, project/workspace registries, push tokens. Non-atomic (plain `writeFile`): `config.json`, `schedules/*.json`, `loops/loops.json`, `server-id`, `daemon-keypair.json`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Agent Record
|
||||
@@ -34,28 +40,29 @@ $PASEO_HOME/
|
||||
|
||||
Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------------- | ---------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `id` | `string` | UUID, primary key |
|
||||
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
|
||||
| `cwd` | `string` | Working directory the agent operates in |
|
||||
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
|
||||
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
|
||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||
| `title` | `string?` | User-visible title |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`) |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
||||
| `lastModeId` | `string?` | Last active mode ID |
|
||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
|
||||
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
|
||||
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
|
||||
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
||||
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
||||
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
||||
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
|
||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||
| Field | Type | Description |
|
||||
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | UUID, primary key |
|
||||
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
|
||||
| `cwd` | `string` | Working directory the agent operates in |
|
||||
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
|
||||
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
|
||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||
| `title` | `string?` | User-visible title |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
||||
| `lastModeId` | `string?` | Last active mode ID |
|
||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
|
||||
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
|
||||
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
|
||||
| `lastError` | `string?` (nullable) | Last error message, if any |
|
||||
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
||||
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
||||
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
||||
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
|
||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||
|
||||
### Nested: SerializableConfig
|
||||
|
||||
@@ -114,7 +121,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
| `description` | `string?` |
|
||||
| `tooltip` | `string?` |
|
||||
| `icon` | `string?` |
|
||||
| `value` | `string?` |
|
||||
| `value` | `string \| null` |
|
||||
| `options` | `AgentSelectOption[]` |
|
||||
|
||||
---
|
||||
@@ -130,10 +137,11 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
version: 1,
|
||||
daemon: {
|
||||
listen: "127.0.0.1:6767",
|
||||
hostnames: true | string[],
|
||||
mcp: { enabled: boolean },
|
||||
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
|
||||
mcp: { enabled: boolean, injectIntoAgents: boolean },
|
||||
cors: { allowedOrigins: string[] },
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean },
|
||||
auth: { password: string } // bcrypt hash, optional
|
||||
},
|
||||
app: {
|
||||
baseUrl: string
|
||||
@@ -143,16 +151,14 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
local: { modelsDir: string }
|
||||
},
|
||||
agents: {
|
||||
providers: {
|
||||
[provider: string]: {
|
||||
command: { mode: "default" } | { mode: "append", args: string[] } | { mode: "replace", argv: string[] },
|
||||
env: Record<string, string>
|
||||
}
|
||||
}
|
||||
// ProviderOverrideSchema; legacy entries with `command: { mode, ... }` are migrated to the
|
||||
// current shape on load via `migrateProviderSettings`. Custom provider IDs must declare
|
||||
// `extends` (one of the built-ins or `"acp"`) and `label`. See `provider-launch-config.ts`.
|
||||
providers: Record<providerId, ProviderOverride>
|
||||
},
|
||||
features: {
|
||||
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
|
||||
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
|
||||
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||
},
|
||||
log: {
|
||||
level, format,
|
||||
@@ -170,7 +176,7 @@ All fields are optional with sensible defaults.
|
||||
|
||||
**Path:** `$PASEO_HOME/schedules/{id}.json`
|
||||
|
||||
One file per schedule. ID is 8 hex characters.
|
||||
One file per schedule. ID is 8 hex characters. Writes are direct (not atomic).
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------- | ------------------------------------- | -------------------------------- |
|
||||
@@ -255,7 +261,7 @@ Single file containing all rooms and messages.
|
||||
|
||||
**Path:** `$PASEO_HOME/loops/loops.json`
|
||||
|
||||
Single file containing an array of all loop records.
|
||||
Single file containing an array of all loop records. Writes are direct (not atomic) and serialized through an in-memory queue. On daemon startup any record with `status: "running"` is recovered as `"stopped"` with an interruption log entry.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------------------- | --------------------------------------------------- | ------------------------------------------ |
|
||||
@@ -265,10 +271,12 @@ Single file containing an array of all loop records.
|
||||
| `cwd` | `string` | Working directory |
|
||||
| `provider` | `string` | Default provider |
|
||||
| `model` | `string?` | Default model |
|
||||
| `modeId` | `string?` | Default mode ID |
|
||||
| `workerProvider` | `string?` | Override provider for workers |
|
||||
| `workerModel` | `string?` | Override model for workers |
|
||||
| `verifierProvider` | `string?` | Override provider for verifiers |
|
||||
| `verifierModel` | `string?` | Override model for verifiers |
|
||||
| `verifierModeId` | `string?` | Override mode ID for verifiers |
|
||||
| `verifyPrompt` | `string?` | LLM verification prompt |
|
||||
| `verifyChecks` | `string[]` | Shell commands to run as checks |
|
||||
| `archive` | `boolean` | Whether to archive worker agents after use |
|
||||
@@ -344,15 +352,20 @@ Single file containing an array of all loop records.
|
||||
|
||||
Array of project records.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | -------------------- | ------------------------------ |
|
||||
| `projectId` | `string` | Primary key |
|
||||
| `rootPath` | `string` | Filesystem root of the project |
|
||||
| `kind` | `"git" \| "non_git"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||
| Field | Type | Description |
|
||||
| ------------- | --------------------------- | ---------------------------------------- |
|
||||
| `projectId` | `string` | Primary key |
|
||||
| `rootPath` | `string` | Filesystem root of the project |
|
||||
| `kind` | `"git" \| "non_git"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
|
||||
|
||||
Active git projects are unique by normalized `rootPath`. Startup reconciliation repairs older bad
|
||||
states by moving workspaces from duplicate path-keyed projects onto the canonical project,
|
||||
preferring remote-keyed project IDs such as `remote:github.com/owner/repo`, then archiving the
|
||||
emptied duplicate.
|
||||
|
||||
---
|
||||
|
||||
@@ -362,16 +375,16 @@ Array of project records.
|
||||
|
||||
Array of workspace records. A workspace is a specific working directory within a project.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | ----------------------------------------------- | ----------------------- |
|
||||
| `workspaceId` | `string` | Primary key |
|
||||
| `projectId` | `string` | FK to Project.projectId |
|
||||
| `cwd` | `string` | Filesystem path |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||
| Field | Type | Description |
|
||||
| ------------- | ----------------------------------------------- | ------------------------------ |
|
||||
| `workspaceId` | `string` | Primary key |
|
||||
| `projectId` | `string` | FK to Project.projectId |
|
||||
| `cwd` | `string` | Filesystem path |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
|
||||
---
|
||||
|
||||
@@ -385,7 +398,20 @@ Array of workspace records. A workspace is a specific working directory within a
|
||||
}
|
||||
```
|
||||
|
||||
Simple set of Expo push notification tokens. No schema validation — just an array of strings.
|
||||
Simple set of Expo push notification tokens. Loaded with permissive parsing (filters non-string entries). Persisted with atomic temp-file rename.
|
||||
|
||||
---
|
||||
|
||||
## 9. Daemon meta files
|
||||
|
||||
These small files are not validated as full Zod schemas but are persisted under `$PASEO_HOME` for daemon identity and runtime coordination.
|
||||
|
||||
| Path | Format | Notes |
|
||||
| --------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `server-id` | Plain text, e.g. `srv_<base64url>` | Stable per-`$PASEO_HOME` daemon ID. Overridable via `PASEO_SERVER_ID` env. |
|
||||
| `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. |
|
||||
| `paseo.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$PASEO_HOME`. |
|
||||
| `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. |
|
||||
|
||||
---
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# DESIGN-SYSTEM.md
|
||||
# Design
|
||||
|
||||
Tokens — every color, font size, weight, spacing step, radius, icon size — live in `packages/app/src/styles/theme.ts`.
|
||||
|
||||
@@ -32,7 +32,7 @@ Hierarchy is conveyed through weight and color, not size. Most labels, titles, a
|
||||
|
||||
Weight has three tiers, applied by role:
|
||||
|
||||
- **Screen titles** — the title at the top of a screen — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx:31-34`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. Top-of-screen titles are lighter on desktop, not heavier. The workspace screen header follows the same rule (`packages/app/src/screens/workspace/workspace-screen.tsx:3052-3057`).
|
||||
- **Screen titles** — the title at the top of a screen — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. Top-of-screen titles are lighter on desktop, not heavier. The workspace screen header follows the same rule (`packages/app/src/screens/workspace/workspace-screen.tsx`).
|
||||
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
|
||||
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
|
||||
|
||||
@@ -60,7 +60,7 @@ The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has f
|
||||
|
||||
`destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog.
|
||||
|
||||
Sizes: `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
||||
Sizes: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
||||
|
||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||
|
||||
@@ -102,7 +102,7 @@ Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value
|
||||
|
||||
## 7. Density and rhythm
|
||||
|
||||
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx:1056-1062`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`).
|
||||
Settings detail pages, the projects detail page, and any list+detail content sit inside a centered, max-width 720 column (`packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx`). Lines stay readable, the eye does not have to track wide horizontal distances. Form modals carry their own narrower content frame (`packages/app/src/components/add-host-modal.tsx`).
|
||||
|
||||
Workspace and chat surfaces use the full width — these are working surfaces, not reading surfaces. The composer carries `MAX_CONTENT_WIDTH` from `packages/app/src/constants/layout.ts` to keep lines readable while letting the workspace pane fill the rest.
|
||||
|
||||
@@ -165,7 +165,7 @@ Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under th
|
||||
|
||||
Page-level alerts — informational notices, success confirmations, warnings, or recoverable errors that need a small visible block on the page — use `<Alert>` (`packages/app/src/components/ui/alert.tsx`). Variants: `default`, `info`, `success`, `warning`, `error`. The chrome is quiet by design: a 1px tinted border, transparent background, a small variant-tinted icon, the title in the variant accent, the description in `foregroundMuted`. Actions go in the `children` slot as `<Button variant="outline" size="sm">` — recovery actions are low-frequency and outline keeps them quiet alongside the alert's accent (`packages/app/src/screens/project-settings-screen.tsx`). One `<Alert>` at a time per region.
|
||||
|
||||
Sidebar callouts — cross-cutting alerts that apply across the whole app, like daemon version mismatch and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/daemon-version-mismatch-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
|
||||
Sidebar callouts — cross-cutting alerts that apply across the whole app, like worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
|
||||
|
||||
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
|
||||
|
||||
@@ -216,7 +216,7 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
|
||||
- Raw DOM APIs without an `isWeb` guard.
|
||||
- Spacing values outside the scale. `padding: 20` and `gap: 10` are wrong.
|
||||
- Color changes for disabled state. Opacity only.
|
||||
- Destructive actions without `confirmDialog`. Restart, remove, archive, and any future destructive action are confirmed.
|
||||
- Destructive actions without `confirmDialog`. Restart, remove, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
|
||||
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
|
||||
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
|
||||
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
|
||||
@@ -225,23 +225,23 @@ The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `
|
||||
|
||||
## 14. Canonical surfaces by pattern
|
||||
|
||||
| Pattern | Reference |
|
||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
|
||||
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
|
||||
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
|
||||
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
|
||||
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
|
||||
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
|
||||
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
|
||||
| Live list of items with sections (agents) | `packages/app/src/components/agent-list.tsx` |
|
||||
| Historical list (sessions) | `packages/app/src/screens/sessions-screen.tsx` |
|
||||
| Workspace pane (multi-tab, split) | `packages/app/src/screens/workspace/workspace-screen.tsx` |
|
||||
| Composer / message input | `packages/app/src/components/composer.tsx`, `packages/app/src/components/message-input.tsx` |
|
||||
| Pane chrome with single bottom border | `packages/app/src/components/git-diff-pane.tsx`, `packages/app/src/components/file-explorer-pane.tsx`, `packages/app/src/components/terminal-pane.tsx` |
|
||||
| Page-level alert (info / success / warning / error) | `packages/app/src/components/ui/alert.tsx`, `packages/app/src/screens/project-settings-screen.tsx` |
|
||||
| Sidebar callout (cross-cutting alert) | `packages/app/src/components/sidebar-callout.tsx`, `packages/app/src/contexts/sidebar-callout-context.tsx`, `packages/app/src/components/daemon-version-mismatch-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx` |
|
||||
| Searchable picker | `packages/app/src/components/ui/combobox.tsx`, `packages/app/src/components/branch-switcher.tsx` |
|
||||
| Trigger-anchored menu | `packages/app/src/components/ui/dropdown-menu.tsx` (used in `sidebar-workspace-list.tsx`, theme picker) |
|
||||
| Right-click / long-press menu | `packages/app/src/components/ui/context-menu.tsx` (used in `sidebar-workspace-list.tsx`) |
|
||||
| Headers (back, screen, menu) | `packages/app/src/components/headers/back-header.tsx`, `screen-header.tsx`, `menu-header.tsx` |
|
||||
| Pattern | Reference |
|
||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| List+detail (compact stack, desktop sidebar+pane) | `packages/app/src/screens/settings-screen.tsx`, `packages/app/src/screens/projects-screen.tsx` |
|
||||
| Detail card+row | `packages/app/src/screens/settings/host-page.tsx`, `packages/app/src/screens/settings/providers-section.tsx` |
|
||||
| Section grouping inside a card list | `packages/app/src/screens/settings/settings-section.tsx` |
|
||||
| Form modal (label + input fields, primary + cancel) | `packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx` |
|
||||
| Destructive confirmation | `confirmDialog` invoked from `packages/app/src/screens/settings/host-page.tsx:541-547` |
|
||||
| Centered hero / first-run | `packages/app/src/components/welcome-screen.tsx` |
|
||||
| Sidebar list (workspaces, hosts) | `packages/app/src/components/sidebar-workspace-list.tsx`, `packages/app/src/components/left-sidebar.tsx` |
|
||||
| Live list of items with sections (agents) | `packages/app/src/components/agent-list.tsx` |
|
||||
| Historical list (sessions) | `packages/app/src/screens/sessions-screen.tsx` |
|
||||
| Workspace pane (multi-tab, split) | `packages/app/src/screens/workspace/workspace-screen.tsx` |
|
||||
| Composer / message input | `packages/app/src/components/composer.tsx`, `packages/app/src/components/message-input.tsx` |
|
||||
| Pane chrome with single bottom border | `packages/app/src/components/git-diff-pane.tsx`, `packages/app/src/components/file-explorer-pane.tsx`, `packages/app/src/components/terminal-pane.tsx` |
|
||||
| Page-level alert (info / success / warning / error) | `packages/app/src/components/ui/alert.tsx`, `packages/app/src/screens/project-settings-screen.tsx` |
|
||||
| Sidebar callout (cross-cutting alert) | `packages/app/src/components/sidebar-callout.tsx`, `packages/app/src/contexts/sidebar-callout-context.tsx`, `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx` |
|
||||
| Searchable picker | `packages/app/src/components/ui/combobox.tsx`, `packages/app/src/components/branch-switcher.tsx` |
|
||||
| Trigger-anchored menu | `packages/app/src/components/ui/dropdown-menu.tsx` (used in `sidebar-workspace-list.tsx`, theme picker) |
|
||||
| Right-click / long-press menu | `packages/app/src/components/ui/context-menu.tsx` (used in `sidebar-workspace-list.tsx`) |
|
||||
| Headers (back, screen, menu) | `packages/app/src/components/headers/back-header.tsx`, `screen-header.tsx`, `menu-header.tsx` |
|
||||
188
docs/development.md
Normal file
188
docs/development.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Development
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js (see `.tool-versions` for exact version)
|
||||
- npm workspaces (comes with Node)
|
||||
|
||||
## Running the dev server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`scripts/dev.sh` runs the daemon and Expo together via `concurrently`, fronted by [`portless`](https://www.npmjs.com/package/portless) so each service is reachable at a stable name like `https://daemon.localhost` / `https://app.localhost` instead of a fixed port. The underlying TCP ports are ephemeral — never hardcode them. (Windows uses `scripts/dev.ps1`, which still binds the daemon to `localhost:6767` directly.)
|
||||
|
||||
### PASEO_HOME
|
||||
|
||||
`PASEO_HOME` is the directory that holds runtime state (agents, sockets, daemon log). Resolution rules:
|
||||
|
||||
- The **server itself** (e.g. when launched by the desktop app or `npm run start`) defaults to `~/.paseo` (see `packages/server/src/server/paseo-home.ts`).
|
||||
- **`npm run dev` from a git worktree** derives a stable home like `~/.paseo-<worktree-name>` and, on first run, seeds it from `~/.paseo` by copying agent/project JSON metadata and `config.json`. Checkout/worktree directories are not copied.
|
||||
- **`npm run dev` from the main checkout** (not a worktree) uses a fresh `mktemp` directory under `$TMPDIR` and removes it on exit. Set `PASEO_HOME` explicitly to keep state across runs.
|
||||
|
||||
Override knobs:
|
||||
|
||||
```bash
|
||||
PASEO_HOME=~/.paseo-blue npm run dev # explicit home
|
||||
PASEO_DEV_SEED_HOME=/path/to/home npm run dev # seed from a different source home
|
||||
PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived worktree home
|
||||
```
|
||||
|
||||
### Daemon endpoints
|
||||
|
||||
- Stable daemon launched by the desktop app: `localhost:6767`.
|
||||
- `npm run dev` (macOS/Linux): portless URLs only — read them from the `dev.sh` banner or `portless get daemon` / `portless get app`.
|
||||
- `npm run dev` (Windows): `localhost:6767` for the daemon.
|
||||
|
||||
In any worktree-style or portless setup, never assume default ports.
|
||||
|
||||
### Desktop renderer profiling
|
||||
|
||||
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
|
||||
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
|
||||
Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
|
||||
|
||||
### Daemon logs
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
||||
`PASEO_LOG_LEVEL=trace` before launching the daemon when you need full provider,
|
||||
session, and agent-manager traces for stuck-state debugging.
|
||||
|
||||
The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
|
||||
`$PASEO_HOME/config.json` win first. Without persisted config, the optional
|
||||
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
||||
defaults. The default rotation is `10m` x `3` files everywhere.
|
||||
|
||||
## paseo.json service scripts
|
||||
|
||||
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
|
||||
of commands. Both run sequentially.
|
||||
|
||||
```json
|
||||
{
|
||||
"worktree": {
|
||||
"setup": "npm ci\ncp \"$PASEO_SOURCE_CHECKOUT_PATH/.env\" .env\nnpm run db:migrate",
|
||||
"teardown": "npm run db:drop || true"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Every `scripts` entry with `"type": "service"` receives these environment variables:
|
||||
|
||||
| Variable | Value |
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PASEO_SERVICE_<NAME>_URL` | Proxied daemon URL for a declared peer service. Prefer this for peer discovery; it survives peer restarts. |
|
||||
| `PASEO_SERVICE_<NAME>_PORT` | Raw ephemeral port for a declared peer service. Use only as a bypass escape hatch; it can go stale if that peer restarts. |
|
||||
| `PASEO_URL` | Self alias for `PASEO_SERVICE_<SELF>_URL`. |
|
||||
| `PASEO_PORT` | Self alias for `PASEO_SERVICE_<SELF>_PORT`. |
|
||||
| `HOST` | Bind host for the service process. |
|
||||
|
||||
`<NAME>` is normalized from the script name by uppercasing it, replacing each run of non-`A-Z0-9` characters with `_`, and trimming leading or trailing `_`. For example, `app-server` and `app.server` both normalize to `APP_SERVER`; that collision fails at spawn time with an actionable error.
|
||||
|
||||
`PORT` is not injected by default. If a framework requires `PORT`, set it in the command:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"web": {
|
||||
"type": "service",
|
||||
"command": "PORT=$PASEO_PORT npm run dev:web"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Build sync gotchas
|
||||
|
||||
The daemon and CLI consume sibling workspaces from compiled `dist/` output, not `src/`. When you change a workspace that something else imports, rebuild the producer first or the consumer will speak a stale protocol and fail with handshake warnings, timeouts, or stale type errors.
|
||||
|
||||
The fastest way to keep this consistent is to rebuild the whole daemon stack with one command:
|
||||
|
||||
```bash
|
||||
npm run build:daemon
|
||||
```
|
||||
|
||||
This rebuilds, in order, `@getpaseo/highlight` → `@getpaseo/relay` → `@getpaseo/server` → `@getpaseo/cli`. Use it whenever you have changed any of those four and need clean cross-package types or runtime behavior.
|
||||
|
||||
For tighter loops, you can rebuild a single workspace:
|
||||
|
||||
- Changed `packages/relay/src/*`: `npm run build --workspace=@getpaseo/relay` (server imports `@getpaseo/relay` from `dist/*`).
|
||||
- Changed `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types: `npm run build --workspace=@getpaseo/server` (CLI imports `@getpaseo/server` via package exports resolving to `dist/*`).
|
||||
- Changed `packages/highlight/src/*`: `npm run build --workspace=@getpaseo/highlight` (server depends on it).
|
||||
|
||||
## CLI reference
|
||||
|
||||
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
|
||||
|
||||
```bash
|
||||
npm run cli -- ls -a -g # List all agents globally
|
||||
npm run cli -- ls -a -g --json # Same, as JSON
|
||||
npm run cli -- inspect <id> # Show detailed agent info
|
||||
npm run cli -- logs <id> # View agent timeline
|
||||
npm run cli -- daemon status # Check daemon status
|
||||
```
|
||||
|
||||
Use `--host <host:port>` to point the CLI at a different daemon:
|
||||
|
||||
```bash
|
||||
npm run cli -- --host localhost:7777 ls -a
|
||||
```
|
||||
|
||||
## Agent state
|
||||
|
||||
Agent data lives at:
|
||||
|
||||
```
|
||||
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
```
|
||||
|
||||
Find an agent by ID:
|
||||
|
||||
```bash
|
||||
find $PASEO_HOME/agents -name "{agent-id}.json"
|
||||
```
|
||||
|
||||
Find by content:
|
||||
|
||||
```bash
|
||||
rg -l "some title text" $PASEO_HOME/agents/
|
||||
```
|
||||
|
||||
## Provider session files
|
||||
|
||||
Get the session ID from the agent JSON (`persistence.sessionId`), then:
|
||||
|
||||
**Claude:**
|
||||
|
||||
```
|
||||
~/.claude/projects/{cwd-with-dashes}/{session-id}.jsonl
|
||||
```
|
||||
|
||||
**Codex:**
|
||||
|
||||
```
|
||||
~/.codex/sessions/{YYYY}/{MM}/{DD}/rollout-{timestamp}-{session-id}.jsonl
|
||||
```
|
||||
|
||||
## Testing with Playwright MCP
|
||||
|
||||
Point Playwright MCP at the running Expo web target. Under `npm run dev` (macOS/Linux) that is the portless URL printed in the dev banner — typically `https://app.localhost`. If you start Expo directly with `expo start --web` (no portless), Metro defaults to `http://localhost:8081`.
|
||||
|
||||
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.
|
||||
|
||||
## Expo troubleshooting
|
||||
|
||||
```bash
|
||||
npx expo-doctor
|
||||
```
|
||||
|
||||
Diagnoses version mismatches and native module issues.
|
||||
|
||||
## Typecheck
|
||||
|
||||
Always run typecheck after changes:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
@@ -15,6 +15,7 @@ This file is auto-generated. Do not edit it by hand.
|
||||
- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings
|
||||
- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names
|
||||
- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon
|
||||
- `packages/app/src/components/file-explorer-pane.tsx` is the only consumer; it renders the SVG with `SvgXml` from `react-native-svg`
|
||||
|
||||
## Adding a new icon
|
||||
|
||||
29
docs/glossary.md
Normal file
29
docs/glossary.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Paseo Glossary
|
||||
|
||||
Authoritative terminology. UI label wins. Don't invent synonyms; use what's here.
|
||||
|
||||
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/server/src/shared/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`).
|
||||
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/server/src/shared/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/server/src/shared/messages.ts:1936`), `DaemonClient` (`packages/server/src/client/daemon-client.ts`).
|
||||
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
|
||||
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
|
||||
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/server/src/shared/messages.ts:2113`).
|
||||
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/server/src/shared/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/server/src/shared/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
|
||||
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
|
||||
- **Session** — Per-client connection to a daemon. Internal. Code: `Session` (`packages/server/src/server/session.ts`). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, OpenCode). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/server/src/shared/messages.ts:198`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/server/src/shared/messages.ts:187`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/server/src/shared/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/server/src/shared/messages.ts`). Don't confuse with: Loop (iterative re-execution of one agent).
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/server/src/shared/messages.ts:257`).
|
||||
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/server/src/shared/messages.ts:782`).
|
||||
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string).
|
||||
|
||||
## Inconsistencies (documented, not papered over)
|
||||
|
||||
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
|
||||
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/server/src/shared/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).
|
||||
138
docs/hover.md
Normal file
138
docs/hover.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Hover
|
||||
|
||||
Read this before writing any hover code. Every hover regression we ship is one of the three failure modes below, and every one of them is solved by the same canonical pattern. The pattern is hardwon — it survived every other shape we tried — so copy it, don't reinvent it.
|
||||
|
||||
## The pattern
|
||||
|
||||
The canonical implementation lives in `packages/app/src/components/sidebar-workspace-list.tsx`, in the workspace row (around line 1369). When in doubt, open that file and copy the shape.
|
||||
|
||||
```tsx
|
||||
//
|
||||
// ┌─ Plain View. Tracks hover via pointerenter/pointerleave.
|
||||
// │
|
||||
<View
|
||||
style={styles.workspaceRowContainer}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
<Pressable // ┐ Separate inner Pressable.
|
||||
onPress={handlePress} // │ Handles press only.
|
||||
onPressIn={...} // │ Never has onHoverIn/onHoverOut.
|
||||
onPressOut={...} // ┘
|
||||
style={workspaceRowStyle}
|
||||
>
|
||||
<View style={styles.workspaceRowMain}>
|
||||
<View style={styles.workspaceRowLeft}>…</View>
|
||||
<WorkspaceRowRightGroup isHovered={isHovered} />
|
||||
{/* └─ Reveals content based on hover state. */}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
```
|
||||
|
||||
Five things make this work. Every one of them matters.
|
||||
|
||||
1. **Hover lives on a plain `View`, not a `Pressable`.** `Pressable` carries its own internal hover state machine. Nested `Pressable`s fight over it. A plain `View` just dispatches DOM events — no state machine, no fighting.
|
||||
2. **Press lives on a _separate_ inner `Pressable`.** Hover and press never share an element. The two state machines never see each other.
|
||||
3. **`onPointerEnter` / `onPointerLeave` are non-bubbling**, mouseenter-style by W3C spec. They fire only when crossing the outer `View`'s bounding box. Crossing into descendants — including descendant `Pressable`s (the kebab menu's buttons, a copy button, a tooltip target) — does **not** fire `pointerleave`. This is why nesting `Pressable`s inside is safe.
|
||||
4. **The row has a fixed `minHeight`.** When content swaps in on hover (kebab replacing a diff stat), both occupy the same fixed slot. Zero layout shift, zero geometry flicker.
|
||||
5. **The outer `View` has nothing but `position: relative`.** It exists only to be the hover target. All real layout lives on the inner `Pressable`. The hover-tracker is a sealed envelope around the row; layout changes inside it never leak out and re-enter through the side.
|
||||
|
||||
That's the whole pattern. Internalize it.
|
||||
|
||||
## When you skip the pattern, here is what breaks
|
||||
|
||||
### Failure mode 1 — Nested Pressables fight over hover state
|
||||
|
||||
If you put `onHoverIn` / `onHoverOut` on a `Pressable` that has another `Pressable` anywhere inside it (a copy button, an icon button, a nested action), the moment the cursor moves onto the inner `Pressable`, the inner one's hover state machine claims hover and the outer one's `onHoverOut` fires. Your reveal state flips off. The reveal hides. The cursor is no longer over the hidden reveal, so it ends up back over the trigger area. The outer's `onHoverIn` fires. Loop.
|
||||
|
||||
This is the most common hover bug shipped in this codebase, by a wide margin. It is what the workspace row is structured to avoid. The fix is not "be clever about handlers" — it's "don't put hover on a Pressable that contains other Pressables."
|
||||
|
||||
> **Rule:** the hover-tracking element is a plain `View` with `onPointerEnter` / `onPointerLeave`. Any `Pressable`s — including ones you forgot are Pressables, like `TurnCopyButton`, icon buttons, anything that handles a tap — live inside it.
|
||||
|
||||
### Failure mode 2 — The hovered state changes the trigger's geometry
|
||||
|
||||
Symptom: you hover a button, it changes appearance, then flickers between hovered and not-hovered without the cursor moving.
|
||||
|
||||
Cause: the hover state changed the size or position of the trigger. The cursor was on the original element; the new layout shifts or shrinks it out from under the cursor; `onHoverOut` fires; state reverts; original layout returns; cursor is back over the trigger; `onHoverIn` fires; loop.
|
||||
|
||||
Common variants:
|
||||
|
||||
- Hover state changes the trigger's `width`, `height`, `padding`, or `borderWidth`.
|
||||
- Hover state mounts/unmounts a child that pushes the trigger to a new position.
|
||||
- Hover state swaps the trigger for a different element type, remounting it.
|
||||
|
||||
Fixes, in preferred order:
|
||||
|
||||
1. **Don't change the trigger's outer geometry on hover.** Change colors, opacity, borders that don't take layout space (`outlineWidth` on web, absolutely positioned overlays), or child content that fits inside the same fixed box. Never change `width`, `height`, `padding`, or `borderWidth` of the hover target itself.
|
||||
2. **Hide with `opacity` + `pointerEvents`, not conditional rendering**, when the hidden element lives inside the trigger. Mounting/unmounting on hover reflows the layout under the cursor.
|
||||
3. **Pin the hit area.** Set a fixed `minHeight` / `minWidth` on the trigger so internal swaps (icon-A becomes icon-B on hover) leave the bounding box unchanged. The workspace row's `minHeight: 36` is what makes the kebab/diff-stat swap stable.
|
||||
|
||||
### Failure mode 3 — Revealed content lives outside the hover trigger
|
||||
|
||||
If hovering element A reveals element B, B must be **inside** A's hover trigger. If B is a sibling, the moment the cursor moves from A toward B it crosses out of A's bounding box, `pointerleave` fires, B disappears.
|
||||
|
||||
Wrong:
|
||||
|
||||
```tsx
|
||||
<View>
|
||||
<View onPointerEnter={...} onPointerLeave={...}> {/* hover trigger */}
|
||||
<Bubble />
|
||||
</View>
|
||||
<TrailingRow /> {/* OUTSIDE — sibling, not child */}
|
||||
</View>
|
||||
```
|
||||
|
||||
Right:
|
||||
|
||||
```tsx
|
||||
<View onPointerEnter={...} onPointerLeave={...}> {/* hover trigger */}
|
||||
<Bubble />
|
||||
<TrailingRow /> {/* INSIDE — child */}
|
||||
</View>
|
||||
```
|
||||
|
||||
Any gap between A and B (margins between siblings inside the same parent) is part of the parent's bounding box, so the cursor stays inside the hover region while crossing it. No bridge needed.
|
||||
|
||||
If A and B genuinely can't share a parent — B portals into a different layer, floats above other content — see [Section: real gaps](#real-gaps-with-floating-panels) below.
|
||||
|
||||
## Native fallback
|
||||
|
||||
Hover doesn't exist on touch devices. Anything you hide behind hover must have a non-hover path on native and compact layouts:
|
||||
|
||||
```tsx
|
||||
const showControls = isHovered || isNative || isCompact;
|
||||
```
|
||||
|
||||
`isNative` and `isCompact` come from `@/constants/platform` and `@/constants/layout`. Don't use `Platform.OS === "ios"` as a proxy.
|
||||
|
||||
`onPointerEnter` / `onPointerLeave` are DOM events. They do not fire on native. You do not need to gate them — on native, hover is unreachable anyway and visibility is driven by `isNative` / `isCompact` in your show-the-controls expression above. This is why the workspace row's pointer events are not wrapped in `if (isWeb)`.
|
||||
|
||||
## What about `Pressable.onHoverIn` / `onHoverOut`?
|
||||
|
||||
It's fine when a `Pressable` styles **itself** based on its own hover — for example, an icon button that changes color on hover. That's self-contained. The render-prop `<Pressable style={({ hovered }) => ...}>` does the same thing more cleanly and is the preferred form.
|
||||
|
||||
It is **not** fine for tracking hover to drive state **outside** that `Pressable` (revealing a sibling, opening a tooltip, showing a kebab) when there is any other `Pressable` inside — because that's Failure Mode 1.
|
||||
|
||||
Heuristic: if your hover state is going to be `useState`'d and read by anything other than the same `Pressable`'s own style, do not use `onHoverIn` / `onHoverOut`. Use the canonical pattern.
|
||||
|
||||
## Real gaps with floating panels
|
||||
|
||||
Sometimes the revealed content can't live inside the trigger — a hover card portals into a different layer, a tooltip floats above other content, a popover renders into a `Portal`. There's a real visual gap the user has to cross with the cursor.
|
||||
|
||||
For this case, use `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`). It computes a rectangular "bridge" between the trigger and the content; while the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. The canonical caller is `packages/app/src/components/workspace-hover-card.tsx`.
|
||||
|
||||
Don't roll your own. The math is annoying, the edge cases (pointer leaves window, drag in progress, content unmounts) are subtle, and we already paid for the hook.
|
||||
|
||||
## Pre-PR checklist
|
||||
|
||||
Before opening a PR that touches hover:
|
||||
|
||||
- [ ] Hover-tracking is on a plain `View` with `onPointerEnter` / `onPointerLeave`, **not** on a `Pressable` that wraps anything pressable.
|
||||
- [ ] Any press behavior lives on a separate inner `Pressable` that does not have `onHoverIn` / `onHoverOut`.
|
||||
- [ ] The hover trigger's bounding box contains every element the user might mouse into while interacting with the feature.
|
||||
- [ ] Hovered state does **not** change the trigger's outer geometry (`width`, `height`, `padding`, `borderWidth`, mount/unmount of siblings that shift it). Internal swaps fit inside a fixed `minHeight` / `minWidth`.
|
||||
- [ ] Revealed content inside the trigger uses `opacity` + `pointerEvents`, not conditional rendering, if mounting it would reflow the trigger.
|
||||
- [ ] Visibility on native and compact layouts works without hover (`isHovered || isNative || isCompact`).
|
||||
- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up.
|
||||
- [ ] You opened the dev server, hovered the trigger, and slowly moved the mouse along **every** revealed element — including any visible gaps — without losing hover state.
|
||||
@@ -143,7 +143,7 @@ Maestro `inputText` fires one character at a time. React Native's **controlled**
|
||||
|
||||
For inputs that E2E flows type into (host endpoint, pairing URL, etc.), use an **uncontrolled ref-backed input**: `defaultValue` + `onChangeText` writes into a `useRef`, reads via the ref on submit. No per-keystroke re-render, no dropped characters.
|
||||
|
||||
See `add-host-modal.tsx` and `pair-link-modal.tsx` for the pattern. Always pair the source change with a Maestro `assertVisible` on the input's `id + text` after `inputText`, so regressions are caught immediately.
|
||||
See `pair-link-modal.tsx` for the pattern (`useRef`-backed `onChangeText`, no `value=` prop). Always pair the source change with a Maestro `assertVisible` on the input's `id + text` after `inputText`, so regressions are caught immediately.
|
||||
|
||||
### Dropdowns that launch native presenters (iOS)
|
||||
|
||||
@@ -261,4 +261,4 @@ xcrun simctl ui booted appearance dark # set dark
|
||||
xcrun simctl ui booted appearance light # set light
|
||||
```
|
||||
|
||||
Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [DEVELOPMENT.md](DEVELOPMENT.md)).
|
||||
Expo dev server logs are in the tmux pane running `npm run dev`. Daemon logs are at `$PASEO_HOME/daemon.log` (see [development.md](development.md)).
|
||||
49
docs/opencode-global-event-baseline.md
Normal file
49
docs/opencode-global-event-baseline.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# OpenCode Global Event Verification
|
||||
|
||||
Date: 2026-05-11
|
||||
|
||||
## Objective
|
||||
|
||||
Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/global/event` stream and remove the EOF polling recovery path that was added for the `/event` regression.
|
||||
|
||||
## Environment
|
||||
|
||||
- `opencode --version`: `1.14.46`
|
||||
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
|
||||
- `node --version`: `v22.20.0`
|
||||
- `npm --version`: `10.9.3`
|
||||
|
||||
Each OpenCode test file was run independently with:
|
||||
|
||||
```bash
|
||||
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1 --minWorkers=1
|
||||
```
|
||||
|
||||
## Baseline
|
||||
|
||||
Before the provider change, the OpenCode matrix had 16 passing files and 4 failing files:
|
||||
|
||||
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: Vitest reports "No test suite found in file".
|
||||
- `packages/server/src/server/agent/providers/opencode-agent.test.ts`: `plan mode blocks edits while build mode can write files` did not observe a completed tool call.
|
||||
- `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts`: brittle unavailable-model assertion received an auth failure from the upstream API.
|
||||
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: timed out waiting for an interrupted sleep tool call, even though the recent bash tool call status was `failed`.
|
||||
|
||||
## Post-Change Result
|
||||
|
||||
After switching to `/global/event`, removing polling recovery, and replacing the brittle initial-prompt model case with `opencode/big-pickle`, the OpenCode matrix had 18 passing files and 2 baseline-equivalent failing files:
|
||||
|
||||
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: unchanged; Vitest still reports "No test suite found in file".
|
||||
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: unchanged; still times out after the interrupted sleep tool call is already marked `failed`.
|
||||
|
||||
The previously failing provider unit file now passes, and `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts` passes with `opencode/big-pickle`.
|
||||
|
||||
One live reasoning-dedup matrix run returned no reasoning content; an immediate targeted rerun passed. This appears model-output dependent rather than related to the event-stream change.
|
||||
|
||||
## Focused Verification
|
||||
|
||||
- `npm run typecheck`
|
||||
- `npm run lint`
|
||||
- `git diff --check`
|
||||
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --maxWorkers=1 --minWorkers=1`
|
||||
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
|
||||
- `npx vitest run packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
|
||||
@@ -1,68 +0,0 @@
|
||||
# Plan Approval Normalization
|
||||
|
||||
## Goal
|
||||
|
||||
Normalize plan approval across providers so the UI renders one consistent plan approval card and action row, while each provider keeps its own execution quirks behind the session permission interface.
|
||||
|
||||
## Compatibility Constraints
|
||||
|
||||
- Older clients must remain compatible with newer daemons.
|
||||
- All new wire fields must be optional.
|
||||
- Existing plan permissions without action metadata must still render and work.
|
||||
- Existing question permissions must keep their current behavior.
|
||||
|
||||
## Design
|
||||
|
||||
### Shared abstraction
|
||||
|
||||
Add optional permission action definitions to the shared permission request/response types.
|
||||
|
||||
- Permission requests may include `actions`.
|
||||
- Permission responses may include `selectedActionId`.
|
||||
- `kind: "plan"` remains the normalized concept for plan approval.
|
||||
- The UI renders actions from the permission request instead of hardcoding provider-specific buttons.
|
||||
|
||||
### Claude
|
||||
|
||||
Keep Claude's plan permission flow, but enrich it with explicit action definitions.
|
||||
|
||||
- Always expose `Reject`.
|
||||
- Always expose `Implement`.
|
||||
- If the agent entered plan mode from a more permissive mode like `bypassPermissions`, also expose `Implement with <previous mode>`.
|
||||
- Resolve the selected action entirely inside `respondToPermission()`.
|
||||
|
||||
### Codex
|
||||
|
||||
Synthesize a normalized `kind: "plan"` permission after a Codex plan-mode turn completes with a plan result.
|
||||
|
||||
- Emit a plan permission with `Reject` and `Implement` actions.
|
||||
- On `Implement`, disable `plan_mode`, disable `fast_mode`, and automatically start a follow-up implementation turn.
|
||||
- On `Reject`, resolve without starting a follow-up turn.
|
||||
- Keep the implementation prompt and state transitions inside the Codex provider.
|
||||
|
||||
### Manager and state sync
|
||||
|
||||
After permission resolution, refresh provider-derived state so the UI sees internal mode/feature changes without knowing provider quirks.
|
||||
|
||||
- Refresh current mode
|
||||
- Refresh pending permissions
|
||||
- Refresh runtime info
|
||||
- Refresh features
|
||||
- Persist refreshed state
|
||||
|
||||
### UI
|
||||
|
||||
Render plan permissions through the existing plan card, but generate buttons from normalized permission actions.
|
||||
|
||||
- If `actions` are absent, fall back to legacy buttons.
|
||||
- Plan cards should use `Implement` as the default primary label.
|
||||
- Do not add provider-specific rendering branches.
|
||||
|
||||
## Verification
|
||||
|
||||
1. Shared schema/type tests for optional `actions` and `selectedActionId`
|
||||
2. App tests for generic plan-action rendering
|
||||
3. Claude tests for third action when resuming from a more permissive mode
|
||||
4. Codex tests for synthetic plan approval and automatic implementation follow-up
|
||||
5. Manager tests for post-permission state refresh
|
||||
6. `npm run typecheck`
|
||||
@@ -67,10 +67,14 @@ Anyone who builds software:
|
||||
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
|
||||
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
|
||||
|
||||
## Current state (March 2026)
|
||||
## Current state (May 2026)
|
||||
|
||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||
- Providers: Claude Code (Agent SDK), Codex (app-server), OpenCode
|
||||
- Daily releases
|
||||
- Community contributions starting (packaging, bug fixes)
|
||||
- Key UX: split panes, keybinding customization, workspace model
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi
|
||||
- One-click ACP provider catalog: Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- Voice mode: dictate prompts or talk through problems hands-free
|
||||
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
|
||||
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||
- Frequent releases (multiple per week)
|
||||
- Community contributions across packaging, providers, and bug fixes
|
||||
- Key UX: split panes, keybinding customization, workspace model, in-app browser
|
||||
@@ -6,15 +6,27 @@ This guide walks through adding a new agent provider end-to-end. There are two i
|
||||
|
||||
### ACP (Agent Client Protocol) -- recommended
|
||||
|
||||
Extend `ACPAgentClient`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks.
|
||||
Extend `ACPAgentClient` from `packages/server/src/server/agent/providers/acp-agent.ts`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks.
|
||||
|
||||
Existing ACP providers: `claude-acp`, `copilot`.
|
||||
The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `GenericACPAgentClient` (`generic-acp-agent.ts`) is also ACP-based but is used for user-defined custom providers configured via `extends: "acp"` overrides — see [docs/custom-providers.md](custom-providers.md).
|
||||
|
||||
### Direct
|
||||
|
||||
Implement the `AgentClient` and `AgentSession` interfaces yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
|
||||
Existing direct providers: `claude`, `codex`, `opencode`.
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`pi-direct-agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
|
||||
|
||||
---
|
||||
|
||||
## Provider Snapshot Refresh Contract
|
||||
|
||||
The daemon keeps one global provider snapshot, keyed to the home directory, for settings, selectors, and old model/mode list requests. Snapshot reads may probe providers only while the snapshot is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until the user forces a refresh from settings/provider management.
|
||||
|
||||
Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
|
||||
|
||||
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery; warm reads and registry replacement must not; explicit full or targeted refreshes must.
|
||||
|
||||
---
|
||||
|
||||
@@ -151,7 +163,7 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
|
||||
### 3. Add the factory to the provider registry
|
||||
|
||||
In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry:
|
||||
In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry to `PROVIDER_CLIENT_FACTORIES`:
|
||||
|
||||
```ts
|
||||
import { MyProviderACPAgentClient } from "./providers/my-provider-agent.js";
|
||||
@@ -161,11 +173,13 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
"my-provider": (logger, runtimeSettings) =>
|
||||
new MyProviderACPAgentClient({
|
||||
logger,
|
||||
runtimeSettings: runtimeSettings?.["my-provider"],
|
||||
runtimeSettings,
|
||||
}),
|
||||
};
|
||||
```
|
||||
|
||||
The factory is invoked with `(logger, runtimeSettings, options)`; `options.workspaceGitService` is also available if you need it (see the `codex` factory for an example). The registry already passes the per-provider runtime settings slice through, so you don't index into the map yourself.
|
||||
|
||||
### 4. Add a provider icon (app)
|
||||
|
||||
Create `packages/app/src/components/icons/my-provider-icon.tsx` following the pattern from existing icons (e.g., `claude-icon.tsx`):
|
||||
@@ -187,19 +201,18 @@ export function MyProviderIcon({ size = 16, color = "currentColor" }: MyProvider
|
||||
}
|
||||
```
|
||||
|
||||
Then register it in `packages/app/src/components/provider-icons.ts`:
|
||||
Then register it in `packages/app/src/components/provider-icons.ts` by adding an entry to the existing `PROVIDER_ICONS` map (which already covers the built-in providers):
|
||||
|
||||
```ts
|
||||
import { MyProviderIcon } from "@/components/icons/my-provider-icon";
|
||||
|
||||
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||
claude: ClaudeIcon as unknown as typeof Bot,
|
||||
codex: CodexIcon as unknown as typeof Bot,
|
||||
// ... existing entries ...
|
||||
"my-provider": MyProviderIcon as unknown as typeof Bot,
|
||||
};
|
||||
```
|
||||
|
||||
If no icon is registered, the app falls back to a generic `Bot` icon from lucide.
|
||||
If no icon is registered, `getProviderIcon()` falls back to a generic `Bot` icon from lucide.
|
||||
|
||||
### 5. Add E2E test config
|
||||
|
||||
@@ -219,25 +232,25 @@ export const agentConfigs = {
|
||||
} as const satisfies Record<string, AgentTestConfig>;
|
||||
```
|
||||
|
||||
Add an availability check in `isProviderAvailable()`:
|
||||
Add an availability check in `isProviderAvailable()`. Note `isCommandAvailable` is async, so all branches `await` it:
|
||||
|
||||
```ts
|
||||
case "my-provider":
|
||||
return (
|
||||
isCommandAvailable("my-agent-binary") &&
|
||||
(await isCommandAvailable("my-agent-binary")) &&
|
||||
Boolean(process.env.MY_PROVIDER_API_KEY)
|
||||
);
|
||||
```
|
||||
|
||||
Add to the `allProviders` array:
|
||||
Add to the `allProviders` array (current built-ins are `claude`, `codex`, `copilot`, `opencode`, `pi`):
|
||||
|
||||
```ts
|
||||
export const allProviders: AgentProvider[] = [
|
||||
"claude",
|
||||
"claude-acp",
|
||||
"codex",
|
||||
"copilot",
|
||||
"opencode",
|
||||
"pi",
|
||||
"my-provider",
|
||||
];
|
||||
```
|
||||
@@ -258,7 +271,9 @@ If your agent does not speak ACP, implement the interfaces from `agent-sdk-types
|
||||
|
||||
### Interfaces to implement
|
||||
|
||||
**`AgentClient`** -- factory for sessions and model listing:
|
||||
The interfaces below are abridged signatures — read `agent-sdk-types.ts` for the full source of truth (option bag types, generics, etc.).
|
||||
|
||||
**`AgentClient`** -- factory for sessions and model/mode listing:
|
||||
|
||||
```ts
|
||||
interface AgentClient {
|
||||
@@ -267,16 +282,19 @@ interface AgentClient {
|
||||
createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession>;
|
||||
resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession>;
|
||||
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||
listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||
isAvailable(): Promise<boolean>;
|
||||
// Optional:
|
||||
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
|
||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||
getDiagnostic?(): Promise<{ diagnostic: string }>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -287,6 +305,7 @@ interface AgentSession {
|
||||
readonly provider: AgentProvider;
|
||||
readonly id: string | null;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
readonly features?: AgentFeature[];
|
||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
||||
@@ -296,7 +315,10 @@ interface AgentSession {
|
||||
getCurrentMode(): Promise<string | null>;
|
||||
setMode(modeId: string): Promise<void>;
|
||||
getPendingPermissions(): AgentPermissionRequest[];
|
||||
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
|
||||
respondToPermission(
|
||||
requestId: string,
|
||||
response: AgentPermissionResponse,
|
||||
): Promise<AgentPermissionResult | void>;
|
||||
describePersistence(): AgentPersistenceHandle | null;
|
||||
interrupt(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
@@ -304,6 +326,10 @@ interface AgentSession {
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
|
||||
setFeature?(featureId: string, value: unknown): Promise<void>;
|
||||
tryHandleOutOfBand?(prompt: AgentPromptInput): {
|
||||
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
|
||||
} | null;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -14,14 +14,14 @@ There are two supported ways to ship from `main`:
|
||||
Before running any stable patch release command:
|
||||
|
||||
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
|
||||
- Make sure local `npm run typecheck` passes on that commit.
|
||||
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
|
||||
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
|
||||
|
||||
```bash
|
||||
npm run release:patch
|
||||
```
|
||||
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag (triggering desktop, APK, and EAS mobile workflows).
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag (triggering `Desktop Release`, `Android APK Release`, EAS `release-mobile.yml`, and `Release Notes Sync` workflows).
|
||||
|
||||
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
|
||||
|
||||
@@ -61,19 +61,46 @@ Use the beta path when you need to:
|
||||
|
||||
## Staged rollout (stable channel)
|
||||
|
||||
Stable desktop releases go out via a linear time-based rollout: 0% admitted at publish, 100% admitted 24 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
|
||||
Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 36 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
|
||||
|
||||
The rollout is driven by a `rolloutHours` field stamped into the GitHub Release manifests (`latest-mac.yml`, `latest-linux.yml`, `latest.yml`) by the `finalize-rollout` job in `desktop-release.yml`.
|
||||
|
||||
Desktop release builds now publish in two phases:
|
||||
|
||||
- Platform build jobs upload the installers/packages (`.dmg`, `.zip`, `.exe`, `.AppImage`, etc.) to the GitHub release.
|
||||
- The final job merges/stamps the manifests and uploads all `.yml` files only after they already contain the final `releaseDate` and `rolloutHours`.
|
||||
|
||||
Updater clients only discover a release through those `.yml` manifests, so there is no silent 100% admission window before rollout metadata is present.
|
||||
|
||||
### Default behavior
|
||||
|
||||
`npm run release:patch` → tag push → 24h ramp. No extra action needed.
|
||||
`npm run release:patch` → tag push → 36h ramp. No extra action needed.
|
||||
|
||||
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 36. To get any other rollout duration on a fresh release, use the post-publish flip below.
|
||||
|
||||
### Instant-admit release (rollout_hours=0 from publish)
|
||||
|
||||
For a fresh release that should admit everyone immediately (low-risk change, doc-only, hotfix, or just a release you want out fast), cut the release normally and queue the rollout flip immediately after:
|
||||
|
||||
```bash
|
||||
# 1. Cut and publish (default 36h ramp from tag push).
|
||||
npm run release:patch
|
||||
|
||||
# 2. Immediately queue the flip — runs as soon as finalize-rollout completes.
|
||||
gh workflow run desktop-rollout.yml \
|
||||
-f tag=v0.1.64 \
|
||||
-f rollout_hours=0
|
||||
```
|
||||
|
||||
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=36`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
|
||||
|
||||
Run the dispatch right after `release:patch` returns. Don't wait for the tag-push CI to finish.
|
||||
|
||||
### Adjusting an already-published release
|
||||
|
||||
To change the rollout duration on a release that's already shipped — e.g. flip a hotfix to instant admit, or slow a release down — use the dedicated `desktop-rollout.yml` workflow. It edits the manifests in place on the GitHub release without rebuilding anything. It only rewrites `rolloutHours`; `releaseDate` is preserved, so the rollout clock keeps ticking from the original publish time.
|
||||
|
||||
**Hotfix (instant admit):**
|
||||
**Hotfix (instant admit) on an already-shipped release:**
|
||||
|
||||
```bash
|
||||
gh workflow run desktop-rollout.yml \
|
||||
@@ -93,11 +120,11 @@ gh workflow run desktop-rollout.yml \
|
||||
|
||||
`rollout_hours` is **total duration since the original release date**, not "extend by N more hours from now." If `v0.1.42` was published 2h ago and you set `rollout_hours=72`, the ramp finishes 70h from now.
|
||||
|
||||
The dispatch is idempotent and shares a concurrency group with `desktop-release.yml`'s `finalize-rollout` job keyed on the tag, so it serializes safely against an in-flight tag-push pipeline targeting the same release.
|
||||
The dispatch is idempotent and shares the `desktop-rollout-<tag>` concurrency group with `desktop-release.yml`'s `finalize-rollout` job, so it serializes safely against an in-flight tag-push pipeline targeting the same release.
|
||||
|
||||
### Faster ramp at release time
|
||||
### Custom ramp on a manually-dispatched build
|
||||
|
||||
For low-risk changes (doc-only, dependency bumps with no behavior change), trigger `desktop-release.yml` manually with a shorter rollout window so the build itself stamps a smaller `rolloutHours`:
|
||||
`desktop-release.yml` accepts `rollout_hours` only on `workflow_dispatch`, which is the path used to **rebuild an existing tag** (retry a failed release, force a rebuild on a different ref). When you go that route, you can stamp a non-default ramp directly:
|
||||
|
||||
```bash
|
||||
gh workflow run desktop-release.yml \
|
||||
@@ -105,6 +132,8 @@ gh workflow run desktop-release.yml \
|
||||
-f rollout_hours=6
|
||||
```
|
||||
|
||||
This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 36. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
|
||||
|
||||
### Releasing during an active rollout
|
||||
|
||||
If you ship N+1 while N is still ramping, N+1 starts a fresh rollout from its own publish timestamp. N's rollout effectively ends — the newer manifest supersedes it.
|
||||
@@ -118,11 +147,16 @@ If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+
|
||||
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
|
||||
- **Up to ~30 min admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window.
|
||||
|
||||
## Release notes on GitHub
|
||||
|
||||
The GitHub Release body is populated automatically by the `Release Notes Sync` workflow (`.github/workflows/release-notes-sync.yml`). It triggers on every `v*` tag push and on any push to `main` that touches `CHANGELOG.md`, then runs `scripts/sync-release-notes-from-changelog.mjs` to mirror the matching changelog entry into the release body. You don't need to write release notes on GitHub manually — keep `CHANGELOG.md` correct and the workflow will sync it. To force a re-sync, dispatch the workflow with the tag input.
|
||||
|
||||
## Website behavior
|
||||
|
||||
- The website download page points to GitHub's latest published **stable** release.
|
||||
- Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
|
||||
- The website only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on `release: published` for non-prerelease releases and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`.
|
||||
|
||||
## Fixing a failed release build
|
||||
|
||||
@@ -199,6 +233,8 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
|
||||
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`.
|
||||
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
|
||||
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
|
||||
- **Common trap:** when drafting from `git log`, every commit looks like a separate bullet — including the "fix X" commits that landed on top of a brand-new feature in the same release window. Before listing a Fixed entry, check whether the thing being fixed was itself added in this same release. If so, drop the fix and fold it into the feature bullet.
|
||||
- **Example:** if the release adds an in-app browser and also contains a commit "fix: browser pane keyboard handling no longer steals shortcuts", do **not** list the keyboard fix under Fixed. The browser is shipping for the first time, so users will only ever see the working version. The Added entry covers it.
|
||||
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
||||
|
||||
## Changelog conciseness
|
||||
@@ -228,7 +264,17 @@ Rules:
|
||||
- **One bullet = one user-facing change**, regardless of how many PRs went into it. Group related PRs on the same bullet.
|
||||
- **De-duplicate contributors.** If the same person authored multiple PRs in one bullet, list them once.
|
||||
- **Only credit external contributors.** Skip attribution for [@boudra](https://github.com/boudra). The changelog credits community contributions — core team work is the default.
|
||||
- **Use `git log` to find PR numbers and authors.** PR numbers are typically in the commit message as `(#N)`. Use `gh pr view N --json author` if the commit doesn't include the GitHub username.
|
||||
- **Credit the commit author, not the PR opener.** A maintainer often opens a PR that lands work authored by someone else (cherry-pick, rebase of a contributor's branch, manual extraction from a stacked PR). The squash commit preserves the original commit's author, but `gh pr view N --json author` returns the PR opener — using that field will silently mis-credit the work to the maintainer (and then the "skip @boudra" rule drops the attribution entirely). Always resolve attribution from commit authors.
|
||||
|
||||
Use this command to get the GitHub logins for each PR:
|
||||
|
||||
```bash
|
||||
gh pr view N --json commits --jq '[.commits[].authors[].login] | unique | .[]'
|
||||
```
|
||||
|
||||
This returns every distinct GitHub login that authored or co-authored a commit in the PR. Use those logins for attribution. Fall back to `gh pr view N --json author` only if the commits command returns nothing (which should not happen for merged PRs).
|
||||
|
||||
When listing PR numbers, `git log --format='%H %s' v<previous>..HEAD | grep -E '\(#[0-9]+\)$'` pulls the PR number out of squash commit subjects.
|
||||
|
||||
## Changelog ordering
|
||||
|
||||
84
docs/rpc-namespacing.md
Normal file
84
docs/rpc-namespacing.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# RPC Namespacing
|
||||
|
||||
New WebSocket session RPCs use dotted names with the direction as the final segment:
|
||||
|
||||
```ts
|
||||
checkout.github.set_auto_merge.request;
|
||||
checkout.github.set_auto_merge.response;
|
||||
```
|
||||
|
||||
The namespace reads left to right:
|
||||
|
||||
- Domain: `checkout`
|
||||
- Provider or subsystem: `github`
|
||||
- Operation: `set_auto_merge`
|
||||
- Direction: `request` or `response`
|
||||
|
||||
Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or transport routing.
|
||||
|
||||
## Request/Response Pairs
|
||||
|
||||
For ordinary correlated RPCs, a `.request` has a matching `.response` with the same prefix. The daemon client may derive the response type mechanically:
|
||||
|
||||
```ts
|
||||
checkout.github.set_auto_merge.request;
|
||||
// -> checkout.github.set_auto_merge.response
|
||||
```
|
||||
|
||||
Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema.
|
||||
|
||||
## Message Shape
|
||||
|
||||
Requests keep their parameters at the top level:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: "checkout.github.set_auto_merge.request",
|
||||
cwd: "/repo",
|
||||
enabled: true,
|
||||
mergeMethod: "squash",
|
||||
requestId: "req_123"
|
||||
}
|
||||
```
|
||||
|
||||
Responses put correlated result data under `payload`:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: "checkout.github.set_auto_merge.response",
|
||||
payload: {
|
||||
cwd: "/repo",
|
||||
enabled: true,
|
||||
success: true,
|
||||
error: null,
|
||||
requestId: "req_123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep `requestId` in both request and response payloads. It is the correlation key.
|
||||
|
||||
## Provider Namespacing
|
||||
|
||||
Provider-specific behavior belongs under the provider segment:
|
||||
|
||||
- `checkout.github.*` for GitHub-specific checkout operations
|
||||
- `checkout.gitlab.*` for future GitLab-specific checkout operations
|
||||
|
||||
Do not put GitHub-specific enums or semantics into generic checkout RPC names. A generic RPC should only exist when the behavior is genuinely provider-neutral.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The existing flat RPC names remain part of the protocol until they are intentionally migrated:
|
||||
|
||||
```ts
|
||||
checkout_pr_merge_request;
|
||||
checkout_pr_merge_response;
|
||||
```
|
||||
|
||||
Do not add new flat names. When migrating old RPCs, keep protocol compatibility rules in mind:
|
||||
|
||||
- Add the new names first.
|
||||
- Gate new feature behavior through `server_info.features.*` when an old host cannot support it.
|
||||
- Keep old names accepted until the compatibility window expires.
|
||||
- Mark shims with `COMPAT(...)` and a removal date.
|
||||
@@ -98,6 +98,37 @@ When a test is labeled end-to-end, it calls the real service. No environment var
|
||||
- Test bodies should read like plain English
|
||||
- Build a vocabulary of test helpers that make complex flows simple
|
||||
|
||||
### File naming
|
||||
|
||||
Vitest picks up tests by suffix. The suffix tells the runner which category it belongs to.
|
||||
|
||||
| Suffix | What it is | Where it runs |
|
||||
| --------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `*.test.ts(x)` | Unit test — pure, fast, no daemon | `npm run test:unit` |
|
||||
| `*.posix.test.ts` | Unit test that needs POSIX-only behavior | unit, skipped on Windows |
|
||||
| `*.browser.test.ts` | App test that needs a real browser (DOM) | `npm run test:browser` (Vitest browser mode, Playwright provider, headless Chromium) |
|
||||
| `*.e2e.test.ts` | End-to-end against a real daemon | `npm run test:e2e` |
|
||||
| `*.real.e2e.test.ts` | E2E that hits a real provider (Claude/Codex/OpenCode) — needs creds in `packages/server/.env.test` | `npm run test:integration:real` / `test:e2e:real` |
|
||||
| `*.local.e2e.test.ts` | E2E that needs a local-only resource | `npm run test:integration:local` / `test:e2e:local` |
|
||||
|
||||
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E).
|
||||
|
||||
### Test setup
|
||||
|
||||
- Server: `packages/server/src/test-utils/vitest-setup.ts` loads `.env.test`, sets `PASEO_SUPERVISED=0`, and disables Git/SSH prompts. Add new global env shims here, not in individual tests.
|
||||
- App: `packages/app/vitest.setup.ts` provides `expo`/`__DEV__` shims and stubs a few native-only modules (`react-native-unistyles`, `react-native-svg`, `expo-linking`, `@xterm/addon-ligatures`). Stubbing here is for modules that have no meaningful Node behavior — not a license to mock app code.
|
||||
|
||||
## Running tests locally
|
||||
|
||||
Test suites in this repo are heavy. Running them in bulk freezes the machine, especially with multiple agents in parallel.
|
||||
|
||||
- Run only the file you changed: `npx vitest run <path> --bail=1`
|
||||
- Never run `npm run test` for a whole workspace unless asked.
|
||||
- For a broad sweep, redirect to a file and read it after: `npx vitest run <path> --bail=1 > /tmp/test-output.txt 2>&1`
|
||||
- Never re-run a suite another agent already reported green.
|
||||
- For full-suite confidence, push to CI and check GitHub Actions.
|
||||
- Never run Playwright E2E (`packages/app/e2e/*.spec.ts`) locally — defer to CI.
|
||||
|
||||
## Agent authentication in tests
|
||||
|
||||
Agent providers handle their own auth. Do not add auth checks, environment variable gates, or conditional skips to tests. If auth fails, report it.
|
||||
@@ -64,9 +64,9 @@ If you add a new `useUnistyles()` call, leave a comment on the line explaining w
|
||||
Do not introduce `useUnistyles()` in or above any of these subtrees — re-renders here are observably expensive:
|
||||
|
||||
- `AgentStreamView` and anything it renders (message rows, tool calls, plan card, todo list, activity log, compaction marker, copy buttons)
|
||||
- `AgentPanel` body / `AgentStreamSection` / `AgentComposerSection`
|
||||
- `AgentPanel` body (`packages/app/src/panels/agent-panel.tsx`)
|
||||
- `Composer` and `MessageInput`
|
||||
- `WorkspaceScreen` shell, tabs row, deck wrapper
|
||||
- `WorkspaceScreen` shell, `WorkspaceDesktopTabsRow`, deck wrapper
|
||||
- `LeftSidebar` row items, `SidebarWorkspaceList`, `CommandCenter`
|
||||
- Anything inside a virtualized list (`@tanstack/react-virtual`, `FlashList`)
|
||||
|
||||
@@ -124,18 +124,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
This is the pattern used by the settings screen: the screen background lives on a normal `View style={styles.container}`, while the scroll content container only carries layout.
|
||||
|
||||
When the content container itself needs themed behavior, wrap the component with [`withUnistyles`](https://www.unistyl.es/v3/references/with-unistyles):
|
||||
In practice the wrapper-`View` pattern is the one we use. Across the app, `withUnistyles` is now reserved for wrapping leaf components — mostly lucide icons (`ThemedActivityIndicator`, `ThemedChevronDown`, …) and small third-party components like `MarkdownWithStableRenderer` — so they pick up theme-reactive `color`/`tintColor` props without re-rendering their parent.
|
||||
|
||||
```tsx
|
||||
import { ScrollView } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
|
||||
const ThemedScrollView = withUnistyles(ScrollView);
|
||||
|
||||
<ThemedScrollView style={styles.scrollView} contentContainerStyle={styles.contentContainer} />;
|
||||
```
|
||||
|
||||
`withUnistyles` extracts dependency metadata from both `style` and `contentContainerStyle`, subscribes to the relevant theme/runtime changes, and re-renders only that wrapped component when needed. Its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props) is the reason it fixes themed `ScrollView` content containers. Reach for it when wrapper-view layout would be awkward or when a third-party component needs theme-aware non-`style` props mapped through Unistyles.
|
||||
In principle, [`withUnistyles`](https://www.unistyl.es/v3/references/with-unistyles) can also wrap a `ScrollView` to make `contentContainerStyle` theme-reactive via its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props). We previously did this on the welcome screen and hit the `> *` child-selector leak documented below; we have since moved the welcome screen to the wrapper-`View` pattern. If you find yourself reaching for `withUnistyles(ScrollView)`, treat it as a smell and check whether a wrapper view works first.
|
||||
|
||||
The smallest escape hatch is to use `useUnistyles()` and pass an inline value through React:
|
||||
|
||||
@@ -155,7 +146,7 @@ Use this sparingly. It works because React re-renders the prop, but it gives up
|
||||
|
||||
The sharp edge: Unistyles hashes styles by value. If `withUnistyles` receives a style whose value is **identical** to a style used elsewhere in the app on a plain `View`, both usages get the same hash — and both CSS rules (the element rule and the `> *` child rule) are emitted under the same class name. The `> *` rule then leaks onto the direct children of every `View` that shares the hash.
|
||||
|
||||
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
|
||||
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `panels/agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
|
||||
|
||||
```css
|
||||
.unistyles_j2k2iilhfz {
|
||||
@@ -231,15 +222,49 @@ If a style factory is cheap, skipping `useMemo` entirely is also fine.
|
||||
|
||||
Do not import `theme` from `@/styles/theme` for live UI colors. That export is a dark-theme compatibility default, so using it in render code leaves icons, placeholders, or third-party props pinned to dark colors in light mode.
|
||||
|
||||
Use `useUnistyles()` inside the component instead:
|
||||
Wrap the icon (or other leaf component) with `withUnistyles` instead, so only that node re-renders when the theme changes:
|
||||
|
||||
```tsx
|
||||
const { theme } = useUnistyles();
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />;
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
icon: { color: theme.colors.foregroundMuted },
|
||||
}));
|
||||
|
||||
<ThemedChevronDown size={theme.iconSize.md} style={styles.icon} />;
|
||||
```
|
||||
|
||||
Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
|
||||
This is the dominant pattern in the app today (see `sidebar-workspace-list.tsx`, `message.tsx`, the workspace screens). Reserve `useUnistyles()` for the last-resort cases described at the top of this file. Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
|
||||
|
||||
## Reanimated `Animated.View` + Dynamic Styles Crashes
|
||||
|
||||
Do not apply `StyleSheet.create((theme) => ...)` styles to a Reanimated `Animated.View`. Unistyles wraps styled components in a `<UnistylesComponent>` and patches native view props from C++ via the ShadowRegistry. Reanimated also reaches into the same native node from its worklet runtime. When a theme change fires, both systems try to mutate the same node and the app crashes with `Unable to find node on an unmounted component.` This was a real iOS sidebar crash on theme toggle (commit `4896cfe9`).
|
||||
|
||||
Fix: keep static positioning on the `Animated.View` in plain React Native `StyleSheet`, and pass theme-dependent values (e.g. `backgroundColor`) as inline style from `useUnistyles()` — the inline path is acceptable here because no other escape works:
|
||||
|
||||
```tsx
|
||||
import { StyleSheet as RNStyleSheet } from "react-native";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
|
||||
const positionStyles = RNStyleSheet.create({
|
||||
sidebar: { position: "absolute", inset: 0, width: 280 },
|
||||
});
|
||||
|
||||
function Sidebar() {
|
||||
const { theme } = useUnistyles();
|
||||
return (
|
||||
<Animated.View
|
||||
style={[positionStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surface1 }]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
This is one of the rare places `useUnistyles()` is the right tool: there is no `withUnistyles(Animated.View)` equivalent, the affected component is small, and the alternative is a crash.
|
||||
|
||||
## Adaptive Themes And Persisted Settings
|
||||
|
||||
@@ -26,11 +26,18 @@
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
paseo = pkgs.callPackage ./nix/package.nix { };
|
||||
isLinux = nixpkgs.lib.elem system [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
in
|
||||
{
|
||||
default = paseo;
|
||||
paseo = paseo;
|
||||
}
|
||||
// nixpkgs.lib.optionalAttrs isLinux {
|
||||
desktop = pkgs.callPackage ./nix/desktop-package.nix { inherit paseo; };
|
||||
}
|
||||
);
|
||||
|
||||
nixosModules.default = self.nixosModules.paseo;
|
||||
|
||||
@@ -3,6 +3,8 @@ pre-commit:
|
||||
jobs:
|
||||
- name: format
|
||||
glob: "*.{css,js,json,jsonc,jsx,md,ts,tsx,yaml,yml}"
|
||||
exclude:
|
||||
- "**/package-lock.json"
|
||||
run: npm run format:check:files -- {staged_files}
|
||||
- name: lint
|
||||
glob: "*.{js,jsx,ts,tsx}"
|
||||
|
||||
159
nix/desktop-package.nix
Normal file
159
nix/desktop-package.nix
Normal file
@@ -0,0 +1,159 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
buildNpmPackage,
|
||||
nodejs_22,
|
||||
python3,
|
||||
makeWrapper,
|
||||
copyDesktopItems,
|
||||
makeDesktopItem,
|
||||
electron,
|
||||
libuv,
|
||||
# Reuse the daemon's prebuilt npm-deps FOD. Same lockfile, same content —
|
||||
# without this, the desktop drv produces a separately-named store path
|
||||
# (`paseo-desktop-<v>-npm-deps`) and refetches the entire registry. Override
|
||||
# the upstream hash via `paseo.override { npmDepsHash = "..."; }`.
|
||||
paseo,
|
||||
}:
|
||||
|
||||
buildNpmPackage rec {
|
||||
pname = "paseo-desktop";
|
||||
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
|
||||
|
||||
src = lib.cleanSourceWith {
|
||||
src = ./..;
|
||||
filter =
|
||||
path: type:
|
||||
let
|
||||
baseName = builtins.baseNameOf path;
|
||||
relPath = lib.removePrefix (toString ./..) path;
|
||||
in
|
||||
# Exclude mobile-only platform code (we only need the web/electron build)
|
||||
!(lib.hasPrefix "/packages/app/android" relPath)
|
||||
&& !(lib.hasPrefix "/packages/app/ios" relPath)
|
||||
# Website is unrelated to the desktop app
|
||||
&& !(lib.hasPrefix "/packages/website" relPath)
|
||||
# Test fixtures and build artifacts
|
||||
&& !(lib.hasSuffix ".test.ts" baseName)
|
||||
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
|
||||
&& baseName != "node_modules"
|
||||
&& baseName != ".git"
|
||||
&& baseName != ".paseo"
|
||||
&& baseName != ".DS_Store"
|
||||
&& baseName != "release";
|
||||
};
|
||||
|
||||
nodejs = nodejs_22;
|
||||
inherit (paseo) npmDeps;
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild. We manually rebuild only node-pty in buildPhase.
|
||||
npmRebuildFlags = [ "--ignore-scripts" ];
|
||||
|
||||
nativeBuildInputs = [
|
||||
python3 # for node-gyp (node-pty)
|
||||
makeWrapper
|
||||
copyDesktopItems
|
||||
];
|
||||
|
||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ libuv ];
|
||||
|
||||
dontNpmBuild = true;
|
||||
|
||||
env = {
|
||||
EXPO_NO_TELEMETRY = "1";
|
||||
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
|
||||
# to phone home during the build.
|
||||
CI = "1";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
# Native deps (terminal emulation; libuv-linked on Linux)
|
||||
npm rebuild node-pty
|
||||
|
||||
# Daemon workspaces (highlight + relay + server + cli)
|
||||
npm run build:daemon
|
||||
|
||||
# App workspace deps not covered by build:daemon
|
||||
npm run build --workspace=@getpaseo/expo-two-way-audio
|
||||
|
||||
# Expo web export for the Electron renderer
|
||||
( cd packages/app && PASEO_WEB_PLATFORM=electron npx expo export --platform web )
|
||||
|
||||
# Desktop main process (tsc only — NOT electron-builder)
|
||||
npm run build:main --workspace=@getpaseo/desktop
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/share/paseo-desktop $out/bin
|
||||
|
||||
# Preserve the monorepo layout so main.js's dev-mode path resolution
|
||||
# (`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
|
||||
# without patching: invoked unpackaged via `electron path/to/main.js`,
|
||||
# `app.isPackaged` is false, so these relative paths are used.
|
||||
#
|
||||
# Copy the entire packages/ tree (not just built artifacts) because npm
|
||||
# creates workspace symlinks from node_modules/@getpaseo/* into packages/*.
|
||||
# Missing any workspace package leaves dangling symlinks and fails the
|
||||
# noBrokenSymlinks output check. The cleanSourceWith filter above already
|
||||
# drops the big platform-specific things (android/ios, website, tests).
|
||||
cp package.json $out/share/paseo-desktop/
|
||||
cp -a packages $out/share/paseo-desktop/
|
||||
cp -a node_modules $out/share/paseo-desktop/
|
||||
|
||||
# Skills directory referenced at runtime by some agents
|
||||
if [ -d skills ]; then
|
||||
cp -a skills $out/share/paseo-desktop/
|
||||
fi
|
||||
|
||||
# Hicolor icon for desktop environments
|
||||
install -Dm644 packages/desktop/assets/icon.png \
|
||||
$out/share/icons/hicolor/512x512/apps/paseo-desktop.png
|
||||
|
||||
# Launcher wraps nixpkgs electron.
|
||||
# --no-sandbox: Chromium's setuid sandbox can't live in /nix/store
|
||||
# (immutable, no setuid). Acceptable for v1; a follow-up can wire
|
||||
# `security.wrappers` via a NixOS module for users who want the sandbox.
|
||||
#
|
||||
# EXPO_DEV_URL: We run unpackaged via `electron path/to/main.js`, so
|
||||
# `app.isPackaged` is false. In that mode main.ts loads `DEV_SERVER_URL`
|
||||
# (defaults to http://localhost:8081 — the Expo dev server, which doesn't
|
||||
# exist here). Point it at the `paseo://` protocol handler instead, which
|
||||
# serves from `__dirname/../../app/dist` (our install layout matches).
|
||||
makeWrapper ${electron}/bin/electron $out/bin/paseo-desktop \
|
||||
--add-flags "$out/share/paseo-desktop/packages/desktop/dist/main.js" \
|
||||
--add-flags "--no-sandbox" \
|
||||
--set EXPO_DEV_URL "paseo://app/"
|
||||
|
||||
copyDesktopItems
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
desktopItems = [
|
||||
(makeDesktopItem {
|
||||
name = "paseo-desktop";
|
||||
desktopName = "Paseo";
|
||||
genericName = "AI Coding Agents";
|
||||
comment = "Self-hosted daemon for AI coding agents";
|
||||
exec = "paseo-desktop";
|
||||
icon = "paseo-desktop";
|
||||
categories = [ "Development" ];
|
||||
startupWMClass = "Paseo";
|
||||
})
|
||||
];
|
||||
|
||||
meta = {
|
||||
description = "Paseo desktop app (Electron wrapper)";
|
||||
homepage = "https://github.com/getpaseo/paseo";
|
||||
license = lib.licenses.agpl3Plus;
|
||||
mainProgram = "paseo-desktop";
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
}
|
||||
137
nix/module.nix
137
nix/module.nix
@@ -81,7 +81,48 @@ in
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
|
||||
description = ''
|
||||
Whether to enable relay-based remote access. When false, the daemon
|
||||
runs with `--no-relay` and only accepts direct (LAN/loopback)
|
||||
connections.
|
||||
'';
|
||||
};
|
||||
|
||||
mode = lib.mkOption {
|
||||
type = lib.types.enum [ "hosted" "remote" ];
|
||||
default = "hosted";
|
||||
description = ''
|
||||
How the daemon reaches the relay when `relay.enable = true`:
|
||||
|
||||
- `"hosted"` (default): use the upstream `app.paseo.sh` relay.
|
||||
Preserves the current behavior; no extra options needed.
|
||||
- `"remote"`: connect to a self-hosted relay at
|
||||
`relay.host:relay.port`. Sets `PASEO_RELAY_ENDPOINT` and
|
||||
`PASEO_RELAY_USE_TLS` for the daemon.
|
||||
|
||||
A `"local"` mode (running a relay on the same host as a systemd
|
||||
unit) is not yet implemented — the relay package currently only
|
||||
ships a Cloudflare Workers adapter. Tracked separately.
|
||||
'';
|
||||
};
|
||||
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
example = "relay.example.com";
|
||||
description = "Relay hostname. Required when `relay.mode = \"remote\"`.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 443;
|
||||
description = "Relay port. Used when `relay.mode = \"remote\"`.";
|
||||
};
|
||||
|
||||
useTls = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to use TLS when connecting to the relay. Used when `relay.mode = \"remote\"`.";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -94,8 +135,9 @@ in
|
||||
|
||||
When Paseo runs as a real user (not the default system user), AI agents
|
||||
need access to the user's tools (git, ssh, etc.). This adds the user's
|
||||
NixOS profile and system paths so agents can use them without manually
|
||||
setting PATH.
|
||||
NixOS profile, home-manager profile (`~/.nix-profile/bin` and
|
||||
`~/.local/state/nix/profile/bin`), and system paths so agents can use
|
||||
them without manually setting PATH.
|
||||
|
||||
Enabled by default when `user` is set to a non-default value.
|
||||
'';
|
||||
@@ -111,9 +153,50 @@ in
|
||||
'';
|
||||
description = "Extra environment variables for the Paseo daemon.";
|
||||
};
|
||||
|
||||
settings = lib.mkOption {
|
||||
type = (pkgs.formats.json { }).type;
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
daemon.mcp = { enabled = true; injectIntoAgents = false; };
|
||||
agents.providers.myAcp = {
|
||||
extends = "acp";
|
||||
label = "My Agent";
|
||||
command = { path = "/run/current-system/sw/bin/my-acp"; };
|
||||
};
|
||||
log.file = { level = "info"; path = "/var/lib/paseo/daemon.log"; };
|
||||
}
|
||||
'';
|
||||
description = ''
|
||||
Declarative content for `$PASEO_HOME/config.json`. Rendered to JSON
|
||||
and installed on every service start.
|
||||
|
||||
Runtime mutations to `config.json` (e.g. via `paseo daemon set-password`
|
||||
or the mobile app toggling MCP injection / provider overrides) are
|
||||
overwritten on the next restart. Pick one: manage via this option, or
|
||||
manage via the CLI — not both.
|
||||
|
||||
The full schema is defined by `PersistedConfigSchema` in
|
||||
`packages/server/src/server/persisted-config.ts`.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
config = lib.mkIf cfg.enable (
|
||||
let
|
||||
settingsFile = (pkgs.formats.json { }).generate "paseo-config.json" cfg.settings;
|
||||
in
|
||||
{
|
||||
assertions = [
|
||||
{
|
||||
assertion = !(cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.host == "");
|
||||
message = ''
|
||||
services.paseo.relay.host must be set when relay.mode = "remote".
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
@@ -131,24 +214,45 @@ in
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
preStart = lib.mkIf (cfg.settings != { }) ''
|
||||
install -m 0600 ${settingsFile} ${cfg.dataDir}/config.json
|
||||
'';
|
||||
|
||||
environment = {
|
||||
NODE_ENV = "production";
|
||||
PASEO_HOME = cfg.dataDir;
|
||||
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
|
||||
} // lib.optionalAttrs cfg.inheritUserEnvironment {
|
||||
# mkForce overrides the default PATH from NixOS's systemd module (which
|
||||
# only includes store paths for coreutils/grep/sed/systemd). Our PATH
|
||||
# includes /run/current-system/sw/bin which is a superset of those.
|
||||
PATH = lib.mkForce (lib.concatStringsSep ":" [
|
||||
"/etc/profiles/per-user/${cfg.user}/bin"
|
||||
"/run/current-system/sw/bin"
|
||||
"/run/wrappers/bin"
|
||||
"/nix/var/nix/profiles/default/bin"
|
||||
]);
|
||||
} // lib.optionalAttrs (cfg.hostnames == true) {
|
||||
} // lib.optionalAttrs cfg.inheritUserEnvironment (
|
||||
let
|
||||
# Match dataDir's convention. We can't read users.users.<name>.home
|
||||
# because the user may be managed outside NixOS.
|
||||
userHome = "/home/${cfg.user}";
|
||||
in {
|
||||
# mkForce overrides the default PATH from NixOS's systemd module (which
|
||||
# only includes store paths for coreutils/grep/sed/systemd). When the
|
||||
# daemon runs as a real user, also include home-manager profile paths
|
||||
# so user-installed CLIs (claude, opencode, codex, ...) are reachable
|
||||
# by agent processes the daemon spawns.
|
||||
PATH = lib.mkForce (lib.concatStringsSep ":" (
|
||||
lib.optionals (cfg.user != "paseo") [
|
||||
"${userHome}/.nix-profile/bin"
|
||||
"${userHome}/.local/state/nix/profile/bin"
|
||||
]
|
||||
++ [
|
||||
"/etc/profiles/per-user/${cfg.user}/bin"
|
||||
"/run/current-system/sw/bin"
|
||||
"/run/wrappers/bin"
|
||||
"/nix/var/nix/profiles/default/bin"
|
||||
]
|
||||
));
|
||||
}
|
||||
) // lib.optionalAttrs (cfg.hostnames == true) {
|
||||
PASEO_HOSTNAMES = "true";
|
||||
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
|
||||
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
|
||||
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote") {
|
||||
PASEO_RELAY_ENDPOINT = "${cfg.relay.host}:${toString cfg.relay.port}";
|
||||
PASEO_RELAY_USE_TLS = if cfg.relay.useTls then "true" else "false";
|
||||
} // cfg.environment;
|
||||
|
||||
serviceConfig = {
|
||||
@@ -172,5 +276,6 @@ in
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
1
nix/npm-deps.hash
Normal file
1
nix/npm-deps.hash
Normal file
@@ -0,0 +1 @@
|
||||
sha256-SRax+Frqf0OhDhfWICSlhOnHBGsoHiXQc6P+zo5VjN8=
|
||||
@@ -7,6 +7,15 @@
|
||||
makeWrapper,
|
||||
# node-pty needs libuv headers on Linux
|
||||
libuv,
|
||||
# Exposed so downstream flakes that follow a different nixpkgs revision
|
||||
# (where `fetchNpmDeps` may produce a different hash for the same lockfile)
|
||||
# can override via `.override { npmDepsHash = "sha256-..."; }` without
|
||||
# `overrideAttrs` gymnastics — `npmDepsHash` is destructured from
|
||||
# `buildNpmPackage`'s args, so `overrideAttrs` cannot reach it.
|
||||
#
|
||||
# The default is read from a sidecar file so the CI auto-updater can replace
|
||||
# the hash with a single file write instead of a sed against this source.
|
||||
npmDepsHash ? lib.fileContents ./npm-deps.hash,
|
||||
}:
|
||||
|
||||
buildNpmPackage rec {
|
||||
@@ -40,9 +49,9 @@ buildNpmPackage rec {
|
||||
|
||||
nodejs = nodejs_22;
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-d87dMrQ2iY0sl0ddOapdEBGtnpbewowZh0DMg/smAb0=";
|
||||
# Default hash lives in nix/npm-deps.hash (see arg default above).
|
||||
# CI auto-updates that file when package-lock.json changes (see .github/workflows/).
|
||||
inherit npmDepsHash;
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
@@ -79,51 +88,29 @@ buildNpmPackage rec {
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
# Compute the daemon's runtime closure by static module-graph tracing
|
||||
# (@vercel/nft from supervisor-entrypoint.js, cli/dist/index.js, and the
|
||||
# forked terminal-worker-process.js) plus an explicit list of non-JS
|
||||
# assets read at runtime. The trace script is the single source of
|
||||
# truth for what the daemon needs at $out — auditable in plain JS, no
|
||||
# npm hoisting / .bin / workspace-symlink footguns.
|
||||
mkdir -p $out/lib/paseo
|
||||
node scripts/trace-daemon.mjs > daemon-files.txt
|
||||
|
||||
# Copy root package metadata
|
||||
while IFS= read -r path; do
|
||||
[ -z "$path" ] && continue
|
||||
mkdir -p "$out/lib/paseo/$(dirname "$path")"
|
||||
cp -a "$path" "$out/lib/paseo/$path"
|
||||
done < daemon-files.txt
|
||||
|
||||
# Root package.json lets node resolve the workspace layout when the
|
||||
# CLI/server bin starts from $out.
|
||||
cp package.json $out/lib/paseo/
|
||||
|
||||
# Copy node_modules (preserving workspace symlinks)
|
||||
cp -a node_modules $out/lib/paseo/
|
||||
|
||||
# Auto-detect which @getpaseo/* packages were built by build:daemon
|
||||
# (they'll have a dist/ directory). Copy those and remove the rest.
|
||||
for link in $out/lib/paseo/node_modules/@getpaseo/*; do
|
||||
name=$(basename "$link")
|
||||
if [ -d "packages/$name/dist" ]; then
|
||||
mkdir -p "$out/lib/paseo/packages/$name"
|
||||
cp "packages/$name/package.json" "$out/lib/paseo/packages/$name/"
|
||||
cp -a "packages/$name/dist" "$out/lib/paseo/packages/$name/"
|
||||
if [ -d "packages/$name/node_modules" ]; then
|
||||
cp -a "packages/$name/node_modules" "$out/lib/paseo/packages/$name/"
|
||||
fi
|
||||
else
|
||||
rm -f "$link"
|
||||
fi
|
||||
done
|
||||
|
||||
# Copy CLI bin entry
|
||||
mkdir -p $out/lib/paseo/packages/cli/bin
|
||||
cp packages/cli/bin/paseo $out/lib/paseo/packages/cli/bin/
|
||||
|
||||
# Copy extra server files referenced at runtime
|
||||
for f in agent-prompt.md .env.example; do
|
||||
if [ -f packages/server/$f ]; then
|
||||
cp packages/server/$f $out/lib/paseo/packages/server/
|
||||
fi
|
||||
done
|
||||
|
||||
# Copy server scripts (including supervisor-entrypoint) needed by CLI
|
||||
if [ -d packages/server/dist/scripts ]; then
|
||||
mkdir -p $out/lib/paseo/packages/server/dist/scripts
|
||||
cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/
|
||||
fi
|
||||
|
||||
# Create wrapper for the server entry point (for systemd / direct use)
|
||||
mkdir -p $out/bin
|
||||
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
|
||||
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
|
||||
--add-flags "$out/lib/paseo/packages/server/dist/scripts/supervisor-entrypoint.js" \
|
||||
--set NODE_ENV production
|
||||
|
||||
# Create wrapper for the CLI
|
||||
|
||||
3716
package-lock.json
generated
3716
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.63",
|
||||
"version": "0.1.77",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
@@ -63,7 +63,7 @@
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
|
||||
"build:desktop": "npm run version:sync-internal && npm run build:web --workspace=@getpaseo/app && npm run build --workspace=@getpaseo/desktop",
|
||||
"build:desktop": "npm run build:workspace-deps --workspace=@getpaseo/app && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
|
||||
"db:query": "npm run db:query --workspace=@getpaseo/server --",
|
||||
"cli": "npx tsx packages/cli/src/index.js",
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
@@ -93,13 +93,16 @@
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.14",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260423.1",
|
||||
"@vercel/nft": "^1.5.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"get-port-cli": "^3.0.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"knip": "^5.82.1",
|
||||
"lefthook": "^2.1.6",
|
||||
"oxfmt": "0.46.0",
|
||||
"oxlint": "1.61.0",
|
||||
"oxlint-tsgolint": "^0.22.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"playwright": "^1.56.1",
|
||||
"typescript": "^5.9.3",
|
||||
|
||||
@@ -5,6 +5,7 @@ on:
|
||||
tags:
|
||||
- "v*"
|
||||
- "!v*-rc.*"
|
||||
- "!v*-beta.*"
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
@@ -40,13 +41,15 @@ jobs:
|
||||
|
||||
submit_ios_for_review:
|
||||
name: Submit iOS for App Store review
|
||||
needs: [submit_ios]
|
||||
needs: [build_ios, submit_ios]
|
||||
environment: production
|
||||
runs_on: macos-medium
|
||||
steps:
|
||||
- uses: eas/checkout
|
||||
- name: Install fastlane
|
||||
working_directory: ./packages/app
|
||||
run: bundle install
|
||||
- name: Submit for review
|
||||
working_directory: ./packages/app
|
||||
run: bundle exec fastlane ios submit_review
|
||||
run: |
|
||||
export APP_VERSION="${{ needs.build_ios.outputs.app_version }}"
|
||||
export APP_BUILD_VERSION="${{ needs.build_ios.outputs.app_build_version }}"
|
||||
bundle exec fastlane ios submit_review
|
||||
|
||||
33
packages/app/.eas/workflows/resubmit-ios-review.yml
Normal file
33
packages/app/.eas/workflows/resubmit-ios-review.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
name: Resubmit iOS for App Store review
|
||||
|
||||
# Standalone re-trigger for the App Store review submission step. The iOS
|
||||
# binary is already uploaded to TestFlight via the main release-mobile flow;
|
||||
# this workflow just runs the fastlane submit_review lane against the latest
|
||||
# TestFlight build, without rebuilding or re-uploading.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
app_version:
|
||||
type: string
|
||||
required: false
|
||||
description: "Marketing version to resubmit, e.g. 0.1.76. Leave empty to target the most recently uploaded iOS build."
|
||||
app_build_version:
|
||||
type: string
|
||||
required: false
|
||||
description: "CFBundleVersion to resubmit, e.g. 2. Only used when app_version is set."
|
||||
|
||||
jobs:
|
||||
submit_ios_for_review:
|
||||
name: Submit iOS for App Store review
|
||||
environment: production
|
||||
runs_on: macos-medium
|
||||
steps:
|
||||
- uses: eas/checkout
|
||||
- name: Install fastlane
|
||||
run: bundle install
|
||||
- name: Submit for review
|
||||
run: |
|
||||
export APP_VERSION="${{ inputs.app_version }}"
|
||||
export APP_BUILD_VERSION="${{ inputs.app_build_version }}"
|
||||
bundle exec fastlane ios submit_review
|
||||
@@ -1,3 +1,3 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane"
|
||||
gem "fastlane", "~> 2.234"
|
||||
|
||||
37
packages/app/e2e/00-sessions-empty.spec.ts
Normal file
37
packages/app/e2e/00-sessions-empty.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// "00-" prefix is intentional: this file must sort before every other spec.
|
||||
// Sessions history is daemon-global — any agent created by a prior spec hides the empty state.
|
||||
// If the beforeAll probe below fails, a spec sorted before this file is creating agents.
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
connectArchiveTabDaemonClient,
|
||||
expectSessionsEmptyState,
|
||||
openSessions,
|
||||
} from "./helpers/archive-tab";
|
||||
|
||||
test.describe("Sessions screen empty state", () => {
|
||||
test.beforeAll(async () => {
|
||||
const client = await connectArchiveTabDaemonClient();
|
||||
try {
|
||||
const history = await client.fetchAgentHistory({ page: { limit: 1 } });
|
||||
if (history.entries.length > 0) {
|
||||
throw new Error(
|
||||
`Sessions empty-state precondition failed: daemon already has ${history.entries.length} agent(s). ` +
|
||||
`Either a spec that sorts before 00-sessions-empty.spec.ts created agents, ` +
|
||||
`or the daemon has stale history from a previous run.`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("shows empty placeholder when there is no session history", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "sessions-empty-" });
|
||||
await workspace.navigateTo();
|
||||
await openSessions(page);
|
||||
await expectSessionsEmptyState(page);
|
||||
});
|
||||
});
|
||||
33
packages/app/e2e/acp-provider-catalog.spec.ts
Normal file
33
packages/app/e2e/acp-provider-catalog.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import {
|
||||
expectProviderInstalledInSettings,
|
||||
installAcpCatalogProvider,
|
||||
openAddProviderModal,
|
||||
openSettingsHost,
|
||||
} from "./helpers/settings";
|
||||
|
||||
const ACP_PROVIDER = {
|
||||
id: "hermes",
|
||||
name: "Hermes",
|
||||
};
|
||||
|
||||
function getSeededServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
test.describe("ACP provider catalog", () => {
|
||||
test("adds a catalog provider from settings", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getSeededServerId());
|
||||
await openAddProviderModal(page);
|
||||
|
||||
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
|
||||
await expectProviderInstalledInSettings(page, ACP_PROVIDER.name);
|
||||
});
|
||||
});
|
||||
65
packages/app/e2e/agent-stream-ui.spec.ts
Normal file
65
packages/app/e2e/agent-stream-ui.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
awaitAssistantMessage,
|
||||
expectAgentIdle,
|
||||
expectInlineWorkingIndicator,
|
||||
expectTurnCopyButton,
|
||||
expectScrollFollowsNewContent,
|
||||
} from "./helpers/agent-stream";
|
||||
import { clickNewChat } from "./helpers/launcher";
|
||||
import { startRunningMockAgent } from "./helpers/composer";
|
||||
|
||||
test.describe("Agent stream UI", () => {
|
||||
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
prefix: "stream-scroll-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stream for auto-scroll test.",
|
||||
});
|
||||
try {
|
||||
await awaitAssistantMessage(page);
|
||||
await expectScrollFollowsNewContent(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
prefix: "stream-indicator-",
|
||||
model: "ten-second-stream",
|
||||
prompt: "Stream briefly for indicator transition test.",
|
||||
});
|
||||
try {
|
||||
await awaitAssistantMessage(page);
|
||||
await expectInlineWorkingIndicator(page);
|
||||
await expectAgentIdle(page, 30_000);
|
||||
await expectTurnCopyButton(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows elapsed timer on first app-created running turn", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "stream-first-app-turn-timer-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await page.getByText("Model defaults are still loading").waitFor({
|
||||
state: "hidden",
|
||||
timeout: 30_000,
|
||||
});
|
||||
const prompt = "Stream briefly for first app-created turn timer test.";
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(prompt);
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
await page.getByText(prompt, { exact: true }).first().waitFor({ state: "visible" });
|
||||
await awaitAssistantMessage(page);
|
||||
await expectInlineWorkingIndicator(page);
|
||||
await page.getByTestId("turn-working-elapsed").waitFor({ state: "visible", timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
205
packages/app/e2e/client-slash-commands.spec.ts
Normal file
205
packages/app/e2e/client-slash-commands.spec.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { composerLocator, expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
expectSessionRowArchived,
|
||||
expectWorkspaceTabHidden,
|
||||
expectWorkspaceTabVisible,
|
||||
openSessions,
|
||||
} from "./helpers/archive-tab";
|
||||
|
||||
interface SlashCommandScenario {
|
||||
agent: { id: string };
|
||||
client: TerminalPerfDaemonClient;
|
||||
cwd: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const REPLACEMENT_PROMPT = "Replacement prompt after slash clear.";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function withOpenReadyMockAgent(
|
||||
page: Page,
|
||||
input: {
|
||||
title: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
},
|
||||
run: (scenario: SlashCommandScenario) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const repo = await createTempGitRepo("client-slash-command-");
|
||||
const client = await connectTerminalClient();
|
||||
|
||||
try {
|
||||
await openProject(client, repo.path);
|
||||
const agent = await createReadyMockAgent(client, {
|
||||
cwd: repo.path,
|
||||
title: input.title,
|
||||
model: input.model,
|
||||
modeId: input.modeId,
|
||||
});
|
||||
await openActiveAgentTab(page, { cwd: repo.path, agentId: agent.id });
|
||||
|
||||
await run({ agent, client, cwd: repo.path, title: input.title });
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function openProject(client: TerminalPerfDaemonClient, cwd: string): Promise<void> {
|
||||
const opened = await client.openProject(cwd);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${cwd}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createReadyMockAgent(
|
||||
client: TerminalPerfDaemonClient,
|
||||
input: {
|
||||
cwd: string;
|
||||
title: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
},
|
||||
): Promise<{ id: string }> {
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: input.cwd,
|
||||
title: input.title,
|
||||
modeId: input.modeId ?? "load-test",
|
||||
model: input.model ?? "ten-second-stream",
|
||||
initialPrompt: "Prepare a client slash command test agent.",
|
||||
});
|
||||
return { id: agent.id };
|
||||
}
|
||||
|
||||
async function openActiveAgentTab(
|
||||
page: Page,
|
||||
input: { cwd: string; agentId: string },
|
||||
): Promise<void> {
|
||||
const agentUrl = `${buildHostWorkspaceRoute(
|
||||
getServerId(),
|
||||
input.cwd,
|
||||
)}?open=${encodeURIComponent(`agent:${input.agentId}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await expectWorkspaceTabVisible(page, input.agentId);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
async function runClientSlashCommand(page: Page, command: "/quit" | "/clear"): Promise<void> {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill(command);
|
||||
await expect(input).toHaveValue(command);
|
||||
await input.press("Enter");
|
||||
}
|
||||
|
||||
async function selectClientSlashCommand(page: Page, query: string, label: string): Promise<void> {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill(query);
|
||||
await expect(page.getByText(label, { exact: true }).first()).toBeVisible({ timeout: 30_000 });
|
||||
await input.press("Enter");
|
||||
}
|
||||
|
||||
async function expectAgentArchivedInSessions(page: Page, title: string): Promise<void> {
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, title);
|
||||
}
|
||||
|
||||
async function expectReplacementDraftMatchesPreviousSetup(page: Page): Promise<void> {
|
||||
await expectComposerVisible(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Select model (Ten second stream)" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Select agent mode (load-test)" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function createAgentFromReplacementDraft(page: Page): Promise<void> {
|
||||
await submitMessage(page, REPLACEMENT_PROMPT);
|
||||
}
|
||||
|
||||
async function waitForReplacementAgentId(page: Page, oldAgentId: string): Promise<string> {
|
||||
let newAgentId: string | null = null;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const ids = await page
|
||||
.locator('[data-testid^="workspace-tab-agent_"]')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes.flatMap((node) => {
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
return [];
|
||||
}
|
||||
const testId = node.getAttribute("data-testid") ?? "";
|
||||
if (!testId.startsWith("workspace-tab-agent_")) {
|
||||
return [];
|
||||
}
|
||||
if (node.offsetParent === null) {
|
||||
return [];
|
||||
}
|
||||
return [testId.slice("workspace-tab-agent_".length)];
|
||||
}),
|
||||
);
|
||||
newAgentId = ids.find((id) => id !== oldAgentId) ?? null;
|
||||
return newAgentId;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
if (!newAgentId) {
|
||||
throw new Error("Replacement agent was not created.");
|
||||
}
|
||||
return newAgentId;
|
||||
}
|
||||
|
||||
test.describe("Client slash commands", () => {
|
||||
test("slash quit archives the active agent and removes its tab", async ({ page }) => {
|
||||
await withOpenReadyMockAgent(page, { title: "Slash quit e2e" }, async ({ agent, title }) => {
|
||||
await runClientSlashCommand(page, "/quit");
|
||||
await expectWorkspaceTabHidden(page, agent.id);
|
||||
await expectAgentArchivedInSessions(page, title);
|
||||
});
|
||||
});
|
||||
|
||||
test("slash quit selected from autocomplete archives immediately", async ({ page }) => {
|
||||
await withOpenReadyMockAgent(
|
||||
page,
|
||||
{ title: "Slash quit autocomplete e2e" },
|
||||
async ({ agent, title }) => {
|
||||
await selectClientSlashCommand(page, "/qu", "/quit");
|
||||
await expectWorkspaceTabHidden(page, agent.id);
|
||||
await expectAgentArchivedInSessions(page, title);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("slash clear replaces the active agent with a matching draft", async ({ page }) => {
|
||||
await withOpenReadyMockAgent(
|
||||
page,
|
||||
{ title: "Slash clear e2e", model: "ten-second-stream", modeId: "load-test" },
|
||||
async ({ agent, title }) => {
|
||||
await runClientSlashCommand(page, "/clear");
|
||||
await expectWorkspaceTabHidden(page, agent.id);
|
||||
await expectReplacementDraftMatchesPreviousSetup(page);
|
||||
await createAgentFromReplacementDraft(page);
|
||||
await waitForReplacementAgentId(page, agent.id);
|
||||
await expectAgentArchivedInSessions(page, title);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
61
packages/app/e2e/codex-plan-approval.spec.ts
Normal file
61
packages/app/e2e/codex-plan-approval.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { allowPermission, waitForPermissionPrompt } from "./helpers/permissions";
|
||||
import { connectTerminalClient } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
test.describe("Codex plan approval", () => {
|
||||
test("shows a single actionable plan panel and removes it after implementation starts", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const repo = await createTempGitRepo("codex-plan-approval-");
|
||||
const client = await connectTerminalClient();
|
||||
|
||||
try {
|
||||
const workspaceResult = await client.openProject(repo.path);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||
}
|
||||
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "Codex plan approval e2e",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "Emit synthetic plan approval.",
|
||||
});
|
||||
|
||||
const agentUrl = `${buildHostWorkspaceRoute(
|
||||
getServerId(),
|
||||
repo.path,
|
||||
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
|
||||
await waitForPermissionPrompt(page, 120_000);
|
||||
|
||||
await expect(page.getByTestId("permission-plan-card")).toHaveCount(1);
|
||||
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
|
||||
|
||||
await allowPermission(page);
|
||||
|
||||
await expect(page.getByTestId("permission-plan-card")).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
284
packages/app/e2e/composer-attachments.spec.ts
Normal file
284
packages/app/e2e/composer-attachments.spec.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { clickNewChat } from "./helpers/launcher";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { expectAgentIdle } from "./helpers/agent-stream";
|
||||
import {
|
||||
openAttachmentMenu,
|
||||
openGithubPickerFromMenu,
|
||||
attachImageFromMenu,
|
||||
expectAttachmentPill,
|
||||
removeAttachmentPill,
|
||||
openImageLightbox,
|
||||
closeImageLightbox,
|
||||
pressInterruptShortcut,
|
||||
expectComposerDraft,
|
||||
expectComposerDisabled,
|
||||
expectComposerEditable,
|
||||
expectAttachButtonDisabled,
|
||||
fillComposerDraft,
|
||||
sendDraftToQueue,
|
||||
expectQueuedMessageButton,
|
||||
startRunningMockAgent,
|
||||
selectGithubOption,
|
||||
expectGithubAttachmentPill,
|
||||
openGithubWorkspace,
|
||||
} from "./helpers/composer";
|
||||
import {
|
||||
connectNewWorkspaceDaemonClient,
|
||||
delayBrowserAgentCreatedStatus,
|
||||
openNewWorkspaceComposer,
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { waitForSidebarHydration, switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { hasGithubAuth, createTempGithubRepo } from "./helpers/github-fixtures";
|
||||
|
||||
const MINIMAL_PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
const TEST_IMAGE = { name: "test.png", mimeType: "image/png", buffer: MINIMAL_PNG };
|
||||
|
||||
test.describe("Composer attachments", () => {
|
||||
test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-plus-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await openAttachmentMenu(page);
|
||||
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible();
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
|
||||
});
|
||||
|
||||
test("GitHub combobox does not render until the picker is opened", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-gh-lazy-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await expect(page.getByTestId("combobox-desktop-container")).not.toBeVisible();
|
||||
|
||||
await openGithubPickerFromMenu(page);
|
||||
|
||||
await expect(page.getByPlaceholder("Search issues and PRs...")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(
|
||||
page.getByTestId("combobox-empty-text").or(page.getByText("Searching...")),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("GitHub issue attachment pill visible after search and selection", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
if (!hasGithubAuth()) {
|
||||
test.skip(true, "GitHub auth not available in this environment");
|
||||
}
|
||||
|
||||
const ghRepo = await createTempGithubRepo({
|
||||
category: "attach-issue",
|
||||
issues: [{ title: "fix: attach-issue-unique-alpha" }],
|
||||
prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }],
|
||||
});
|
||||
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
|
||||
try {
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await selectGithubOption(
|
||||
page,
|
||||
"attach-issue-unique-alpha",
|
||||
`issue:${ghRepo.issues[0].number}`,
|
||||
);
|
||||
|
||||
await expectGithubAttachmentPill(page, {
|
||||
number: ghRepo.issues[0].number,
|
||||
title: ghRepo.issues[0].title,
|
||||
});
|
||||
} finally {
|
||||
await handle.cleanup();
|
||||
await ghRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("GitHub PR attachment pill visible after search and selection", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
if (!hasGithubAuth()) {
|
||||
test.skip(true, "GitHub auth not available in this environment");
|
||||
}
|
||||
|
||||
const ghRepo = await createTempGithubRepo({
|
||||
category: "attach-pr",
|
||||
prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }],
|
||||
});
|
||||
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
|
||||
try {
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await selectGithubOption(page, "attach-pr-unique-beta", `pr:${ghRepo.prs[0].number}`);
|
||||
|
||||
await expectGithubAttachmentPill(page, {
|
||||
number: ghRepo.prs[0].number,
|
||||
title: ghRepo.prs[0].title,
|
||||
});
|
||||
} finally {
|
||||
await handle.cleanup();
|
||||
await ghRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.fixme("workspace-review pill suppresses on X-click and reappears after send", async () => {
|
||||
// The review attachment is created via InlineReviewEditor in surface.tsx (addComment action).
|
||||
// Automating this requires: a workspace with staged changes, navigating to the diff panel,
|
||||
// hovering the gutter "+" button, typing a comment, and submitting. A dedicated
|
||||
// helpers/review.ts with addInlineReviewComment(page, filePath, lineNumber, comment) is
|
||||
// needed before this can be exercised end-to-end.
|
||||
});
|
||||
|
||||
test.fixme("browser-element attachment pill is created from Electron webview selection", async () => {
|
||||
// The browser-element attachment is only created in browser-pane.electron.tsx via DOM
|
||||
// element selection in the Electron webview. It is not exercisable in headless Chromium E2E.
|
||||
});
|
||||
|
||||
test("image lightbox opens on pill click and closes on Escape", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-lightbox-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
|
||||
await openImageLightbox(page);
|
||||
await closeImageLightbox(page);
|
||||
});
|
||||
|
||||
test("image attachment pill renders after file is selected", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-pill-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
});
|
||||
|
||||
test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-remove-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
|
||||
await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment");
|
||||
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("submitting while agent is running queues the message and clears the draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
prefix: "attach-queue-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stay running for queue test.",
|
||||
});
|
||||
try {
|
||||
await fillComposerDraft(page, "queued draft text");
|
||||
await sendDraftToQueue(page);
|
||||
|
||||
await expectQueuedMessageButton(page);
|
||||
await expectComposerDraft(page, "");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("Escape interrupt cancels the running agent and preserves composer draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
prefix: "attach-interrupt-",
|
||||
model: "ten-second-stream",
|
||||
prompt: "Stay running for interrupt test.",
|
||||
});
|
||||
try {
|
||||
await fillComposerDraft(page, "preserve me");
|
||||
await pressInterruptShortcut(page);
|
||||
|
||||
await expectAgentIdle(page, 15_000);
|
||||
await expectComposerDraft(page, "preserve me");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("composer is locked while new workspace agent is being created", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) throw new Error("E2E_SERVER_ID is not set.");
|
||||
|
||||
const repo = await createTempGitRepo("attach-lock-");
|
||||
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
|
||||
const daemonClient = await connectNewWorkspaceDaemonClient();
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(daemonClient, repo.path);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
});
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await fillComposerDraft(page, "lock test prompt");
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||
await createButton.click();
|
||||
|
||||
await agentCreatedDelay.waitForCreateRequest();
|
||||
await agentCreatedDelay.waitForDelayedCreatedStatus();
|
||||
|
||||
await expectComposerDisabled(page);
|
||||
await expectAttachButtonDisabled(page);
|
||||
|
||||
agentCreatedDelay.release();
|
||||
|
||||
await expectComposerEditable(page);
|
||||
} finally {
|
||||
agentCreatedDelay.release();
|
||||
await daemonClient.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
151
packages/app/e2e/desktop-updates.spec.ts
Normal file
151
packages/app/e2e/desktop-updates.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
loadRealDaemonState,
|
||||
injectDesktopBridge,
|
||||
openDesktopSettings,
|
||||
expectUpdateBanner,
|
||||
clickInstallUpdate,
|
||||
expectInstallInProgress,
|
||||
interceptDaemonManagementConfirmDialog,
|
||||
toggleDaemonManagement,
|
||||
expectDaemonManagementConfirmDialog,
|
||||
expectDaemonManagementEnabled,
|
||||
expectDaemonManagementDisabled,
|
||||
expectDaemonStatusPid,
|
||||
expectDaemonStatusLogPath,
|
||||
expectDaemonStatusVersion,
|
||||
} from "./helpers/desktop-updates";
|
||||
|
||||
function getSeededServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
// No Playwright Electron runner exists; we simulate the desktop bridge via
|
||||
// addInitScript so Electron-gated UI activates without a real Electron process.
|
||||
test.describe("Desktop updates", () => {
|
||||
test("update banner appears in the sidebar when an app update is available", async ({ page }) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getSeededServerId(),
|
||||
updateAvailable: true,
|
||||
latestVersion: "1.2.3",
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
|
||||
await expectUpdateBanner(page, "1.2.3");
|
||||
});
|
||||
|
||||
test("clicking install shows the installing state on the callout", async ({ page }) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getSeededServerId(),
|
||||
updateAvailable: true,
|
||||
latestVersion: "1.2.3",
|
||||
slowInstall: true,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
|
||||
await expectUpdateBanner(page, "1.2.3");
|
||||
await clickInstallUpdate(page);
|
||||
await expectInstallInProgress(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Desktop daemon management", () => {
|
||||
test("disabling built-in daemon management shows confirm dialog with correct copy", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getSeededServerId();
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
confirmShouldAccept: false,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopSettings(page, serverId);
|
||||
|
||||
const dialogArgs = await interceptDaemonManagementConfirmDialog(page);
|
||||
expectDaemonManagementConfirmDialog(dialogArgs);
|
||||
|
||||
await expectDaemonManagementEnabled(page);
|
||||
});
|
||||
|
||||
test("cancelling the confirm dialog leaves the daemon management toggle on", async ({ page }) => {
|
||||
const serverId = getSeededServerId();
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
confirmShouldAccept: false,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopSettings(page, serverId);
|
||||
|
||||
await expectDaemonManagementEnabled(page);
|
||||
await toggleDaemonManagement(page, "disable");
|
||||
await expectDaemonManagementEnabled(page);
|
||||
});
|
||||
|
||||
test("confirming the dialog disables built-in daemon management", async ({ page }) => {
|
||||
const serverId = getSeededServerId();
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
confirmShouldAccept: true,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopSettings(page, serverId);
|
||||
|
||||
await toggleDaemonManagement(page, "disable");
|
||||
|
||||
await expectDaemonManagementDisabled(page);
|
||||
});
|
||||
|
||||
test("daemon status panel renders version, PID, and log path from the real daemon", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getSeededServerId();
|
||||
const realState = await loadRealDaemonState();
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: false,
|
||||
daemonPid: realState.pid,
|
||||
daemonVersion: realState.version,
|
||||
daemonLogPath: realState.logPath,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopSettings(page, serverId);
|
||||
|
||||
await expectDaemonStatusVersion(page, realState.version);
|
||||
await expectDaemonStatusPid(page, realState.pid);
|
||||
await expectDaemonStatusLogPath(page, realState.logPath);
|
||||
});
|
||||
|
||||
test("stopping and restarting the daemon updates the PID", async ({ page }) => {
|
||||
const serverId = getSeededServerId();
|
||||
const realState = await loadRealDaemonState();
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
daemonPid: realState.pid,
|
||||
daemonVersion: realState.version,
|
||||
daemonLogPath: realState.logPath,
|
||||
confirmShouldAccept: true,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopSettings(page, serverId);
|
||||
|
||||
await expectDaemonStatusPid(page, realState.pid);
|
||||
|
||||
await toggleDaemonManagement(page, "disable");
|
||||
await expectDaemonManagementDisabled(page);
|
||||
await expectDaemonStatusPid(page, null);
|
||||
|
||||
await toggleDaemonManagement(page, "enable");
|
||||
await expectDaemonManagementEnabled(page);
|
||||
const newPid = realState.pid !== null ? realState.pid + 1000 : 11000;
|
||||
await expectDaemonStatusPid(page, newPid);
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ test.beforeAll(async () => {
|
||||
if (!result.workspace) {
|
||||
throw new Error(result.error ?? "Failed to seed workspace");
|
||||
}
|
||||
workspaceId = String(result.workspace.id);
|
||||
workspaceId = result.workspace.id;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
|
||||
|
||||
// Extend base test to provide dynamic baseURL from global-setup
|
||||
const test = base.extend({
|
||||
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
|
||||
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
|
||||
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
|
||||
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
|
||||
// reliably for every test that uses this `test` object.
|
||||
const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>({
|
||||
baseURL: async ({}, provide) => {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
@@ -10,102 +15,99 @@ const test = base.extend({
|
||||
}
|
||||
await provide(`http://localhost:${metroPort}`);
|
||||
},
|
||||
});
|
||||
|
||||
const consoleEntries = new WeakMap<Page, string[]>();
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
|
||||
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
|
||||
);
|
||||
}
|
||||
if (daemonPort === "6767") {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
|
||||
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
|
||||
);
|
||||
}
|
||||
if (!metroPort) {
|
||||
throw new Error(
|
||||
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
|
||||
);
|
||||
}
|
||||
|
||||
// Hard guardrail: never allow tests to hit the developer's default daemon.
|
||||
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
|
||||
await page.route(/:(6767)\b/, (route) => route.abort());
|
||||
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||
});
|
||||
|
||||
const entries: string[] = [];
|
||||
consoleEntries.set(page, entries);
|
||||
|
||||
page.on("console", (message) => {
|
||||
entries.push(`[console:${message.type()}] ${message.text()}`);
|
||||
});
|
||||
|
||||
page.on("pageerror", (error) => {
|
||||
entries.push(`[pageerror] ${error.message}`);
|
||||
});
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const seedNonce = Math.random().toString(36).slice(2);
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
|
||||
}
|
||||
const testDaemon = buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
nowIso,
|
||||
});
|
||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce: nonce }) => {
|
||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||
// setting this flag before the reload.
|
||||
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
||||
const disableValue = localStorage.getItem(disableOnceKey);
|
||||
if (disableValue) {
|
||||
localStorage.removeItem(disableOnceKey);
|
||||
if (disableValue === nonce) {
|
||||
return;
|
||||
}
|
||||
paseoE2ESetup: [
|
||||
async ({ page }, provide, testInfo) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
|
||||
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
|
||||
);
|
||||
}
|
||||
if (daemonPort === "6767") {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
|
||||
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
|
||||
);
|
||||
}
|
||||
if (!metroPort) {
|
||||
throw new Error(
|
||||
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
|
||||
);
|
||||
}
|
||||
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
||||
// Hard guardrail: never allow tests to hit the developer's default daemon.
|
||||
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
|
||||
await page.route(/:(6767)\b/, (route) => route.abort());
|
||||
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||
});
|
||||
|
||||
// Hard-reset anything that could point to a developer's real daemon.
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
const entries: string[] = [];
|
||||
|
||||
page.on("console", (message) => {
|
||||
entries.push(`[console:${message.type()}] ${message.text()}`);
|
||||
});
|
||||
|
||||
page.on("pageerror", (error) => {
|
||||
entries.push(`[pageerror] ${error.message}`);
|
||||
});
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const seedNonce = Math.random().toString(36).slice(2);
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
|
||||
}
|
||||
const testDaemon = buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
nowIso,
|
||||
});
|
||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce: nonce }) => {
|
||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||
// setting this flag before the reload.
|
||||
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
||||
const disableValue = localStorage.getItem(disableOnceKey);
|
||||
if (disableValue) {
|
||||
localStorage.removeItem(disableOnceKey);
|
||||
if (disableValue === nonce) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
||||
|
||||
// Hard-reset anything that could point to a developer's real daemon.
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
|
||||
);
|
||||
|
||||
await provide();
|
||||
|
||||
if (entries.length > 0 && testInfo.status !== testInfo.expectedStatus) {
|
||||
await testInfo.attach("browser-console", {
|
||||
body: entries.join("\n"),
|
||||
contentType: "text/plain",
|
||||
});
|
||||
}
|
||||
},
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
|
||||
);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
const entries = consoleEntries.get(page);
|
||||
if (!entries || entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (testInfo.status === testInfo.expectedStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
await testInfo.attach("browser-console", {
|
||||
body: entries.join("\n"),
|
||||
contentType: "text/plain",
|
||||
});
|
||||
{ auto: true },
|
||||
],
|
||||
withWorkspace: async ({ page }, provide) => {
|
||||
const handle = createWithWorkspace(page);
|
||||
await provide(handle.withWorkspace);
|
||||
await handle.cleanup();
|
||||
},
|
||||
});
|
||||
|
||||
export { test, expect, type Page };
|
||||
|
||||
@@ -215,8 +215,28 @@ async function createFakeGhBin(): Promise<string> {
|
||||
await writeFile(
|
||||
ghPath,
|
||||
`#!/usr/bin/env node
|
||||
const { spawnSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function findRealGh() {
|
||||
const fakeBinDir = __dirname;
|
||||
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
||||
if (dir === fakeBinDir) continue;
|
||||
const candidate = path.join(dir, "gh");
|
||||
try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function forwardToRealGh() {
|
||||
const realGh = findRealGh();
|
||||
if (!realGh) { console.error("[fake-gh] real gh not found in PATH"); process.exit(1); }
|
||||
const result = spawnSync(realGh, process.argv.slice(2), { stdio: "inherit", env: process.env });
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
if (args[0] === "auth" && args[1] === "status") {
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -238,8 +258,21 @@ if (args[0] === "pr" && args[1] === "list") {
|
||||
}
|
||||
|
||||
if (args[0] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) {
|
||||
console.error("no pull requests found for branch");
|
||||
process.exit(1);
|
||||
const fixture = path.join(process.cwd(), ".paseo-e2e-pr.json");
|
||||
if (fs.existsSync(fixture)) {
|
||||
console.log(fs.readFileSync(fixture, "utf8"));
|
||||
process.exit(0);
|
||||
}
|
||||
forwardToRealGh();
|
||||
}
|
||||
|
||||
if (args[0] === "api" && args[1] === "graphql") {
|
||||
const fixture = path.join(process.cwd(), ".paseo-e2e-timeline.json");
|
||||
if (fs.existsSync(fixture)) {
|
||||
console.log(fs.readFileSync(fixture, "utf8"));
|
||||
process.exit(0);
|
||||
}
|
||||
forwardToRealGh();
|
||||
}
|
||||
|
||||
if (args[0] === "issue" && args[1] === "list") {
|
||||
@@ -247,8 +280,7 @@ if (args[0] === "issue" && args[1] === "list") {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error("Unsupported fake gh invocation: " + args.join(" "));
|
||||
process.exit(1);
|
||||
forwardToRealGh();
|
||||
`,
|
||||
);
|
||||
await chmod(ghPath, 0o755);
|
||||
@@ -382,15 +414,19 @@ async function resolveDictationConfig(): Promise<DictationConfig> {
|
||||
);
|
||||
const hasDefaultLocalModelsDir =
|
||||
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
|
||||
const dictationProvider = openAiUsable ? "openai" : "local";
|
||||
|
||||
if (dictationProvider === "local" && !hasDefaultLocalModelsDir) {
|
||||
throw new Error(
|
||||
"OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. " +
|
||||
"Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.",
|
||||
// Fork PRs run without secrets and usually without local models. Don't crash
|
||||
// the whole Playwright run — disable dictation/voice and let tests that need
|
||||
// them gate on PASEO_DICTATION_ENABLED.
|
||||
if (!openAiUsable && !hasDefaultLocalModelsDir) {
|
||||
console.warn(
|
||||
"[e2e] Neither OPENAI_API_KEY nor local speech models found — running with dictation/voice disabled. " +
|
||||
"Tests that require dictation should gate on PASEO_DICTATION_ENABLED.",
|
||||
);
|
||||
return { openAiUsable: false, localModelsDir: null };
|
||||
}
|
||||
|
||||
const dictationProvider = openAiUsable ? "openai" : "local";
|
||||
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
|
||||
console.log(
|
||||
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
|
||||
@@ -573,7 +609,7 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const { openAiUsable, localModelsDir } = args.dictation;
|
||||
|
||||
const child = spawn(tsxBin, ["src/server/index.ts"], {
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -585,6 +621,7 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
|
||||
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
|
||||
PASEO_NODE_ENV: "development",
|
||||
...(openAiUsable
|
||||
? {
|
||||
PASEO_DICTATION_STT_PROVIDER: "openai",
|
||||
@@ -704,6 +741,7 @@ export default async function globalSetup() {
|
||||
process.env.E2E_SERVER_ID = offer.serverId;
|
||||
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
||||
process.env.E2E_METRO_PORT = String(metroPort);
|
||||
process.env.E2E_PASEO_HOME = paseoHome;
|
||||
console.log(
|
||||
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
|
||||
);
|
||||
|
||||
35
packages/app/e2e/helpers/agent-stream.ts
Normal file
35
packages/app/e2e/helpers/agent-stream.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { readScrollMetrics, waitForContentGrowth, expectNearBottom } from "./agent-bottom-anchor";
|
||||
|
||||
export async function awaitAssistantMessage(page: Page, hasText?: string | RegExp): Promise<void> {
|
||||
const messages = page.getByTestId("assistant-message");
|
||||
const target = hasText === undefined ? messages.first() : messages.filter({ hasText }).first();
|
||||
await expect(target).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function awaitToolCall(page: Page, toolName: string | RegExp): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("tool-call-badge").filter({ hasText: toolName }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectAgentIdle(page: Page, timeout = 30_000): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, { timeout });
|
||||
}
|
||||
|
||||
// The working indicator is an animated spinner View — no semantic ARIA role, testId is correct.
|
||||
export async function expectInlineWorkingIndicator(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectTurnCopyButton(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Copy turn" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectScrollFollowsNewContent(page: Page): Promise<void> {
|
||||
const { contentHeight } = await readScrollMetrics(page);
|
||||
await waitForContentGrowth(page, contentHeight);
|
||||
await expectNearBottom(page);
|
||||
}
|
||||
@@ -1,217 +1,36 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function getE2EDaemonPort(): string {
|
||||
const port = process.env.E2E_DAEMON_PORT;
|
||||
if (!port) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
if (port === "6767") {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.",
|
||||
);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
||||
const port = getE2EDaemonPort();
|
||||
const expectedEndpoint = `127.0.0.1:${port}`;
|
||||
const expectedServerId = process.env.E2E_SERVER_ID;
|
||||
if (!expectedServerId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
|
||||
const needsReset = await page.evaluate(
|
||||
({ expectedEndpoint: endpoint, expectedServerId: serverId }) => {
|
||||
const raw = localStorage.getItem("@paseo:daemon-registry");
|
||||
if (!raw) return true;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
|
||||
const entry = parsed[0] as { serverId?: string; connections?: unknown };
|
||||
if (entry?.serverId !== serverId) return true;
|
||||
const connections = entry?.connections;
|
||||
if (!Array.isArray(connections)) return true;
|
||||
if (
|
||||
connections.some(
|
||||
(c: { type?: string; endpoint?: string }) =>
|
||||
c?.type === "directTcp" &&
|
||||
typeof c?.endpoint === "string" &&
|
||||
/:6767\b/.test(c.endpoint),
|
||||
)
|
||||
)
|
||||
return true;
|
||||
return !connections.some(
|
||||
(c: { type?: string; endpoint?: string }) =>
|
||||
c?.type === "directTcp" && c?.endpoint === endpoint,
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{ expectedEndpoint, expectedServerId },
|
||||
);
|
||||
|
||||
if (!needsReset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const daemon = buildSeededHost({
|
||||
serverId: expectedServerId,
|
||||
endpoint: expectedEndpoint,
|
||||
nowIso,
|
||||
});
|
||||
const preferences = buildCreateAgentPreferences(expectedServerId);
|
||||
await page.evaluate(
|
||||
({ daemon: seededDaemon, preferences: seededPreferences }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
},
|
||||
{ daemon, preferences },
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
function parseRegistryEntry(registryRaw: string): { serverId: string; connections: unknown } {
|
||||
let registry: unknown;
|
||||
try {
|
||||
registry = JSON.parse(registryRaw);
|
||||
} catch {
|
||||
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
|
||||
}
|
||||
if (!Array.isArray(registry) || registry.length !== 1) {
|
||||
throw new Error(
|
||||
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
|
||||
);
|
||||
}
|
||||
const daemon = registry[0] as { serverId?: string; connections?: unknown };
|
||||
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
|
||||
throw new Error(
|
||||
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
|
||||
);
|
||||
}
|
||||
return { serverId: daemon.serverId, connections: daemon.connections };
|
||||
}
|
||||
|
||||
function assertDaemonConnections(connections: unknown, expectedEndpoint: string): void {
|
||||
if (
|
||||
!Array.isArray(connections) ||
|
||||
!connections.some(
|
||||
(c: { type?: string; endpoint?: string }) =>
|
||||
c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
Array.isArray(connections) &&
|
||||
connections.some(
|
||||
(c: { type?: string; endpoint?: string }) =>
|
||||
c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPreferencesMatch(prefsRaw: string, serverId: string): void {
|
||||
try {
|
||||
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
|
||||
if (prefs?.serverId !== serverId) {
|
||||
throw new Error(
|
||||
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${serverId}) (got ${String(prefs?.serverId)}).`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) throw error;
|
||||
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
||||
const port = getE2EDaemonPort();
|
||||
const expectedEndpoint = `127.0.0.1:${port}`;
|
||||
const expectedServerId = process.env.E2E_SERVER_ID;
|
||||
if (!expectedServerId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
|
||||
const snapshot = await page.evaluate(() => {
|
||||
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
||||
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
||||
return { registryRaw, prefsRaw };
|
||||
});
|
||||
|
||||
if (!snapshot.registryRaw) {
|
||||
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
|
||||
}
|
||||
|
||||
const { serverId, connections } = parseRegistryEntry(snapshot.registryRaw);
|
||||
if (serverId !== expectedServerId) {
|
||||
throw new Error(
|
||||
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${serverId}).`,
|
||||
);
|
||||
}
|
||||
assertDaemonConnections(connections, expectedEndpoint);
|
||||
|
||||
if (!snapshot.prefsRaw) {
|
||||
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
|
||||
}
|
||||
assertPreferencesMatch(snapshot.prefsRaw, serverId);
|
||||
}
|
||||
|
||||
export const gotoAppShell = async (page: Page) => {
|
||||
await page.goto("/");
|
||||
await ensureE2EStorageSeeded(page);
|
||||
};
|
||||
|
||||
export const gotoHome = async (page: Page) => {
|
||||
await gotoAppShell(page);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
if (
|
||||
!(await composer
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false))
|
||||
) {
|
||||
const addProjectCta = page.getByText("Add a project", { exact: true }).first();
|
||||
const addProjectSidebar = page.getByText("Add project", { exact: true }).first();
|
||||
const newAgentButton = page.getByText("New agent", { exact: true }).first();
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
const entryButton = page
|
||||
.getByText("Add a project", { exact: true })
|
||||
.or(page.getByText("Add project", { exact: true }))
|
||||
.or(page.getByText("New agent", { exact: true }))
|
||||
.first();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await addProjectCta.isVisible().catch(() => false)) ||
|
||||
(await addProjectSidebar.isVisible().catch(() => false)) ||
|
||||
(await newAgentButton.isVisible().catch(() => false)),
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await composer.isVisible().catch(() => false)) ||
|
||||
(await entryButton.isVisible().catch(() => false)),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
if (await addProjectCta.isVisible().catch(() => false)) {
|
||||
await addProjectCta.click();
|
||||
} else if (await addProjectSidebar.isVisible().catch(() => false)) {
|
||||
await addProjectSidebar.click();
|
||||
} else {
|
||||
await newAgentButton.click();
|
||||
}
|
||||
if (!(await composer.isVisible().catch(() => false))) {
|
||||
await entryButton.click();
|
||||
}
|
||||
await expect(composer.first()).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||
};
|
||||
|
||||
export const openSettings = async (page: Page) => {
|
||||
@@ -341,46 +160,6 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
};
|
||||
|
||||
export const ensureHostSelected = async (page: Page) => {
|
||||
await ensureE2EStorageSeeded(page);
|
||||
|
||||
// Absolute verification that we're using the per-run e2e daemon (never :6767).
|
||||
// Also self-heal a rare case where app code rewrites daemon IDs after boot, by
|
||||
// realigning create-agent-preferences.serverId to the sole seeded daemon.
|
||||
try {
|
||||
await assertE2EUsesSeededTestDaemon(page);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!/create-agent-preferences\.serverId/i.test(message)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const fix = await page.evaluate(() => {
|
||||
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
||||
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
||||
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
|
||||
const registry = JSON.parse(registryRaw) as Array<{ serverId?: string }>;
|
||||
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
|
||||
if (!Array.isArray(registry) || registry.length !== 1)
|
||||
return { ok: false, reason: "registry shape" } as const;
|
||||
const serverId = registry[0]?.serverId;
|
||||
if (typeof serverId !== "string" || serverId.length === 0)
|
||||
return { ok: false, reason: "missing serverId" } as const;
|
||||
prefs.serverId = serverId;
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(prefs));
|
||||
// Prevent the fixture's init-script from overwriting the corrected prefs on reload.
|
||||
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1";
|
||||
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
if (!fix.ok) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await page.reload();
|
||||
await assertE2EUsesSeededTestDaemon(page);
|
||||
}
|
||||
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(input).toBeVisible();
|
||||
|
||||
@@ -392,7 +171,7 @@ export const ensureHostSelected = async (page: Page) => {
|
||||
if (await selectHostLabel.isVisible()) {
|
||||
await selectHostLabel.click();
|
||||
|
||||
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
|
||||
// We enforce a single seeded daemon, so the option should be unambiguous.
|
||||
const localhostOption = page.getByText("localhost", { exact: true }).first();
|
||||
const daemonIdOption = page
|
||||
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
|
||||
@@ -657,57 +436,3 @@ export const createAgentInRepo = async (
|
||||
await setWorkingDirectory(page, config.directory);
|
||||
await createAgent(page, config.prompt);
|
||||
};
|
||||
|
||||
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
|
||||
const promptText = page.getByTestId("permission-request-question").first();
|
||||
await expect(promptText).toBeVisible({ timeout });
|
||||
};
|
||||
|
||||
export const allowPermission = async (page: Page) => {
|
||||
const acceptButton = page.getByTestId("permission-request-accept").first();
|
||||
await expect(acceptButton).toBeVisible({ timeout: 5000 });
|
||||
await acceptButton.click();
|
||||
};
|
||||
|
||||
export const denyPermission = async (page: Page) => {
|
||||
const denyButton = page.getByTestId("permission-request-deny").first();
|
||||
await expect(denyButton).toBeVisible({ timeout: 5000 });
|
||||
await denyButton.click();
|
||||
};
|
||||
|
||||
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
||||
// Wait for the stop button to disappear
|
||||
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
|
||||
|
||||
// First, let's debug what's happening - wait a bit to see the state
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check if stop button is visible
|
||||
const isVisible = await stopButton.isVisible().catch(() => false);
|
||||
|
||||
if (isVisible) {
|
||||
// If stop button is still visible after permission denial,
|
||||
// it might be that the agent is waiting for something.
|
||||
// Let's check if there's a tool call result or other UI indication
|
||||
|
||||
// Look for any indication that the agent has processed the permission denial
|
||||
const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i);
|
||||
|
||||
// Wait for the tool call result to appear
|
||||
await expect(toolCallResult)
|
||||
.toBeVisible({ timeout: 10000 })
|
||||
.catch(() => {
|
||||
// If no specific message, just wait for the button to disappear
|
||||
});
|
||||
|
||||
// Now wait for the stop button to disappear
|
||||
await expect(stopButton).not.toBeVisible({ timeout });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getToolCallCount(page: Page): Promise<number> {
|
||||
// Tool calls are rendered as ExpandableBadge components with tool names like Bash, Write, Read, etc.
|
||||
// They appear as pressable badges in the agent stream
|
||||
const toolCallBadges = page.locator('[data-testid="tool-call-badge"]');
|
||||
return toolCallBadges.count();
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ interface ArchiveTabDaemonClient {
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
}): Promise<{ entries: Array<{ id: string }> }>;
|
||||
}
|
||||
|
||||
function getDaemonPort(): string {
|
||||
@@ -265,8 +268,10 @@ export async function openSessions(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
const AGENT_ROW_SELECTOR = '[data-testid^="agent-row-"]';
|
||||
|
||||
function getSessionRowByTitle(page: Page, title: string) {
|
||||
return page.locator('[data-testid^="agent-row-"]').filter({ hasText: title }).first();
|
||||
return page.locator(AGENT_ROW_SELECTOR).filter({ hasText: title }).first();
|
||||
}
|
||||
|
||||
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
|
||||
@@ -283,6 +288,12 @@ export async function clickSessionRow(page: Page, title: string): Promise<void>
|
||||
await row.click();
|
||||
}
|
||||
|
||||
export async function expectSessionsEmptyState(page: Page): Promise<void> {
|
||||
// Guard: if session rows appear, a prior spec polluted the shared daemon — see 00-sessions-empty.spec.ts.
|
||||
await expect(page.locator(AGENT_ROW_SELECTOR)).toHaveCount(0, { timeout: 5_000 });
|
||||
await expect(page.getByText("No sessions yet")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function archiveAgentFromSessions(
|
||||
page: Page,
|
||||
input: { agentId: string; title: string },
|
||||
|
||||
195
packages/app/e2e/helpers/composer.ts
Normal file
195
packages/app/e2e/helpers/composer.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import { connectTerminalClient, type TerminalPerfDaemonClient } from "./terminal-perf";
|
||||
import { connectWorkspaceSetupClient, openHomeWithProject } from "./workspace-setup";
|
||||
import { selectWorkspaceInSidebar } from "./sidebar";
|
||||
import { waitForTabBar } from "./launcher";
|
||||
|
||||
function composerInput(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
}
|
||||
|
||||
export function composerLocator(page: Page) {
|
||||
return composerInput(page);
|
||||
}
|
||||
|
||||
export async function expectComposerVisible(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
|
||||
}
|
||||
|
||||
export async function expectComposerDisabled(page: Page): Promise<void> {
|
||||
// React Native TextInput with editable={false} renders as <textarea readonly> on web,
|
||||
// not <textarea disabled>. Use not.toBeEditable() to match either form.
|
||||
await expect(composerInput(page)).not.toBeEditable({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function expectComposerDraft(page: Page, text: string): Promise<void> {
|
||||
await expect(composerInput(page)).toHaveValue(text, { timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function expectComposerEditable(page: Page): Promise<void> {
|
||||
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function submitMessage(page: Page, text: string): Promise<void> {
|
||||
const input = composerInput(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill(text);
|
||||
await input.press("Enter");
|
||||
}
|
||||
|
||||
export async function fillComposerDraft(page: Page, text: string): Promise<void> {
|
||||
await composerInput(page).fill(text);
|
||||
}
|
||||
|
||||
export async function sendDraftToQueue(page: Page): Promise<void> {
|
||||
await composerInput(page).press("Control+Enter");
|
||||
}
|
||||
|
||||
export async function expectQueuedMessageButton(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelAgent(page: Page): Promise<void> {
|
||||
const stopButton = page.getByRole("button", { name: /stop|cancel/i }).first();
|
||||
await expect(stopButton).toBeVisible({ timeout: 10_000 });
|
||||
await stopButton.click();
|
||||
}
|
||||
|
||||
/** Escape is bound to the "agent.interrupt" keyboard shortcut. */
|
||||
export async function pressInterruptShortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function openAttachmentMenu(page: Page): Promise<void> {
|
||||
await page.getByTestId("message-input-attach-button").filter({ visible: true }).first().click();
|
||||
await expect(page.getByTestId("message-input-attachment-menu")).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function expectAttachButtonDisabled(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("message-input-attach-button").filter({ visible: true }).first(),
|
||||
).toBeDisabled({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function attachImageFromMenu(
|
||||
page: Page,
|
||||
file: { name: string; mimeType: string; buffer: Buffer },
|
||||
): Promise<void> {
|
||||
const chooserPromise = page.waitForEvent("filechooser", { timeout: 10_000 });
|
||||
await openAttachmentMenu(page);
|
||||
await page.getByTestId("message-input-attachment-menu-item-image").click();
|
||||
const chooser = await chooserPromise;
|
||||
await chooser.setFiles([file]);
|
||||
}
|
||||
|
||||
export async function expectAttachmentPill(page: Page, testID: string): Promise<void> {
|
||||
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
/** Hover to reveal the X button (hidden until hover on desktop web), then click by accessible label. */
|
||||
export async function removeAttachmentPill(
|
||||
page: Page,
|
||||
pillTestId: string,
|
||||
removeAccessibilityLabel: string,
|
||||
): Promise<void> {
|
||||
await page.getByTestId(pillTestId).first().hover();
|
||||
await page.getByRole("button", { name: removeAccessibilityLabel }).first().click();
|
||||
}
|
||||
|
||||
export async function expectGithubAttachmentPill(
|
||||
page: Page,
|
||||
input: { number: number; title: string },
|
||||
): Promise<void> {
|
||||
const pill = page.getByTestId("composer-github-attachment-pill").first();
|
||||
await expect(pill).toBeVisible({ timeout: 10_000 });
|
||||
await expect(pill).toContainText(`#${input.number}`);
|
||||
await expect(pill).toContainText(input.title);
|
||||
}
|
||||
|
||||
export async function openImageLightbox(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open image attachment" }).first().click();
|
||||
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function closeImageLightbox(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function openGithubPickerFromMenu(page: Page): Promise<void> {
|
||||
await openAttachmentMenu(page);
|
||||
await page.getByTestId("message-input-attachment-menu-item-github").click();
|
||||
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "pr:1"), and click it. */
|
||||
export async function selectGithubOption(
|
||||
page: Page,
|
||||
searchTerm: string,
|
||||
optionId: string,
|
||||
): Promise<void> {
|
||||
await openGithubPickerFromMenu(page);
|
||||
const searchInput = page.getByPlaceholder("Search issues and PRs...");
|
||||
await expect(searchInput).toBeVisible({ timeout: 5_000 });
|
||||
await searchInput.fill(searchTerm);
|
||||
const option = page.getByTestId(`composer-github-option-${optionId}`);
|
||||
await expect(option).toBeVisible({ timeout: 15_000 });
|
||||
await option.click();
|
||||
}
|
||||
|
||||
export interface MockAgentSetup {
|
||||
client: TerminalPerfDaemonClient;
|
||||
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
|
||||
}
|
||||
|
||||
/** Create a temp repo, start a mock agent, navigate to it, and wait for it to be running. */
|
||||
export async function startRunningMockAgent(
|
||||
page: Page,
|
||||
opts: { prefix: string; model: string; prompt: string },
|
||||
): Promise<MockAgentSetup> {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) throw new Error("E2E_SERVER_ID is not set.");
|
||||
|
||||
const repo = await createTempGitRepo(opts.prefix);
|
||||
const client = await connectTerminalClient();
|
||||
const opened = await client.openProject(repo.path);
|
||||
if (!opened.workspace) throw new Error(opened.error ?? "Failed to open project");
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
model: opts.model,
|
||||
initialPrompt: opts.prompt,
|
||||
});
|
||||
const agentUrl = `${buildHostWorkspaceRoute(serverId, repo.path)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectComposerVisible(page);
|
||||
return { client, repo };
|
||||
}
|
||||
|
||||
export interface GithubWorkspaceHandle {
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Open a workspace backed by an existing repo path (e.g. a cloned GitHub repo). */
|
||||
export async function openGithubWorkspace(
|
||||
page: Page,
|
||||
repoPath: string,
|
||||
): Promise<GithubWorkspaceHandle> {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const opened = await client.openProject(repoPath);
|
||||
if (!opened.workspace) throw new Error(opened.error ?? `Failed to open project ${repoPath}`);
|
||||
await openHomeWithProject(page, repoPath);
|
||||
await selectWorkspaceInSidebar(page, opened.workspace.id);
|
||||
await waitForTabBar(page);
|
||||
return { cleanup: () => client.close().catch(() => undefined) };
|
||||
}
|
||||
@@ -30,10 +30,15 @@ export function buildSeededHost(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export const TEST_MOCK_PROVIDER_PREFERENCES = {
|
||||
...TEST_PROVIDER_PREFERENCES,
|
||||
mock: { model: "ten-second-stream" },
|
||||
} as const;
|
||||
|
||||
export function buildCreateAgentPreferences(serverId: string) {
|
||||
return {
|
||||
serverId,
|
||||
provider: "codex" as const,
|
||||
providerPreferences: TEST_PROVIDER_PREFERENCES,
|
||||
provider: "mock" as const,
|
||||
providerPreferences: TEST_MOCK_PROVIDER_PREFERENCES,
|
||||
};
|
||||
}
|
||||
|
||||
289
packages/app/e2e/helpers/desktop-updates.ts
Normal file
289
packages/app/e2e/helpers/desktop-updates.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { openSettings } from "./app";
|
||||
import { openSettingsHost } from "./settings";
|
||||
|
||||
interface DaemonApiStatus {
|
||||
version: string;
|
||||
serverId: string;
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
interface PidFileContent {
|
||||
pid: number;
|
||||
desktopManaged: boolean;
|
||||
}
|
||||
|
||||
export interface RealDaemonState {
|
||||
version: string;
|
||||
pid: number | null;
|
||||
logPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads live state from the running E2E test daemon: version from the HTTP
|
||||
* status endpoint, PID from the paseo.pid lock file, log path from the
|
||||
* E2E_PASEO_HOME directory. Call this in Node test code (not in the browser).
|
||||
*/
|
||||
export async function loadRealDaemonState(): Promise<RealDaemonState> {
|
||||
const port = process.env.E2E_DAEMON_PORT;
|
||||
const paseoHome = process.env.E2E_PASEO_HOME;
|
||||
if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first");
|
||||
if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — globalSetup must run first");
|
||||
|
||||
const resp = await fetch(`http://127.0.0.1:${port}/api/status`);
|
||||
const data: DaemonApiStatus = await resp.json();
|
||||
|
||||
let pid: number | null = null;
|
||||
try {
|
||||
const raw = readFileSync(`${paseoHome}/paseo.pid`, "utf8");
|
||||
const pidContent: PidFileContent = JSON.parse(raw);
|
||||
pid = pidContent.pid ?? null;
|
||||
} catch (err) {
|
||||
// PID file may not be present yet on a very fresh daemon start
|
||||
console.warn("[desktop-updates] paseo.pid not found:", err);
|
||||
}
|
||||
|
||||
return { version: data.version, pid, logPath: `${paseoHome}/daemon.log` };
|
||||
}
|
||||
|
||||
export interface DesktopBridgeConfig {
|
||||
serverId: string;
|
||||
updateAvailable?: boolean;
|
||||
latestVersion?: string;
|
||||
slowInstall?: boolean;
|
||||
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
|
||||
daemonPid?: number | null;
|
||||
daemonVersion?: string | null;
|
||||
daemonLogPath?: string;
|
||||
/** Initial manageBuiltInDaemon setting. Defaults to false. */
|
||||
manageBuiltInDaemon?: boolean;
|
||||
/**
|
||||
* Controls what dialog.ask returns when the daemon management confirm dialog
|
||||
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
|
||||
* false so tests that only assert copy don't inadvertently trigger state changes.
|
||||
*/
|
||||
confirmShouldAccept?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfirmDialogCall {
|
||||
message: string;
|
||||
title: string | undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__capturedDialogCall: ConfirmDialogCall | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects window.paseoDesktop before app load so all Electron-gated code
|
||||
* activates. The update-check IPC is mocked at the boundary so the real
|
||||
* auto-updater never fires. Daemon start/stop commands are stateful: the mock
|
||||
* tracks running state and assigns a fresh PID on each start, letting tests
|
||||
* observe PID changes without touching the real E2E daemon process.
|
||||
* dialog.ask captures call arguments on window.__capturedDialogCall so tests
|
||||
* can assert dialog copy without depending on window.confirm concatenation.
|
||||
*/
|
||||
export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfig): Promise<void> {
|
||||
await page.addInitScript((cfg) => {
|
||||
// Mutable state shared across IPC calls within this page
|
||||
let manageDaemon = cfg.manageBuiltInDaemon ?? false;
|
||||
let daemonRunning = true;
|
||||
let currentPid: number | null = cfg.daemonPid ?? null;
|
||||
let startCount = 0;
|
||||
|
||||
function buildDaemonStatus() {
|
||||
return {
|
||||
serverId: cfg.serverId,
|
||||
status: daemonRunning ? "running" : "stopped",
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: currentPid,
|
||||
home: "",
|
||||
version: cfg.daemonVersion ?? null,
|
||||
desktopManaged: manageDaemon,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
||||
platform: "darwin",
|
||||
invoke: async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "check_app_update") {
|
||||
return cfg.updateAvailable
|
||||
? {
|
||||
hasUpdate: true,
|
||||
readyToInstall: true,
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: cfg.latestVersion ?? "1.2.3",
|
||||
body: null,
|
||||
date: null,
|
||||
}
|
||||
: {
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: null,
|
||||
body: null,
|
||||
date: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (command === "install_app_update") {
|
||||
if (cfg.slowInstall) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
return {
|
||||
installed: true,
|
||||
version: cfg.latestVersion ?? "1.2.3",
|
||||
message: "App update installed. Restart required.",
|
||||
};
|
||||
}
|
||||
|
||||
if (command === "desktop_daemon_status") {
|
||||
return buildDaemonStatus();
|
||||
}
|
||||
|
||||
if (command === "desktop_daemon_logs") {
|
||||
return { logPath: cfg.daemonLogPath ?? "", contents: "" };
|
||||
}
|
||||
|
||||
if (command === "get_desktop_settings") {
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
||||
};
|
||||
}
|
||||
|
||||
if (command === "patch_desktop_settings") {
|
||||
const daemon = args?.daemon;
|
||||
if (
|
||||
daemon !== null &&
|
||||
typeof daemon === "object" &&
|
||||
"manageBuiltInDaemon" in daemon &&
|
||||
typeof daemon.manageBuiltInDaemon === "boolean"
|
||||
) {
|
||||
manageDaemon = daemon.manageBuiltInDaemon;
|
||||
}
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
||||
};
|
||||
}
|
||||
|
||||
if (command === "stop_desktop_daemon") {
|
||||
daemonRunning = false;
|
||||
currentPid = null;
|
||||
return buildDaemonStatus();
|
||||
}
|
||||
|
||||
if (command === "start_desktop_daemon") {
|
||||
startCount += 1;
|
||||
daemonRunning = true;
|
||||
// First start (bootstrap) returns the configured PID; subsequent starts
|
||||
// (after a stop) get a fresh PID so tests can observe the change.
|
||||
currentPid = (cfg.daemonPid ?? 10000) + (startCount - 1) * 1000;
|
||||
return buildDaemonStatus();
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
dialog: {
|
||||
ask: async (message: string, options?: Record<string, unknown>) => {
|
||||
window.__capturedDialogCall = {
|
||||
message,
|
||||
title: typeof options?.title === "string" ? options.title : undefined,
|
||||
};
|
||||
return cfg.confirmShouldAccept ?? false;
|
||||
},
|
||||
},
|
||||
getPendingOpenProject: async () => null,
|
||||
events: { on: async () => () => undefined },
|
||||
};
|
||||
}, config);
|
||||
}
|
||||
|
||||
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectUpdateBanner(page: Page, version: string): Promise<void> {
|
||||
const callout = page.getByTestId("update-callout");
|
||||
await expect(callout).toBeVisible({ timeout: 15_000 });
|
||||
await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`);
|
||||
}
|
||||
|
||||
export async function clickInstallUpdate(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Install & restart" }).click();
|
||||
}
|
||||
|
||||
export async function expectInstallInProgress(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Installing..." })).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the daemon management switch and waits for dialog.ask to fire in the
|
||||
* mock, then returns the captured call args (message + title). The mock auto-
|
||||
* dismisses via confirmShouldAccept=false so callers can assert copy without
|
||||
* worrying about state changes.
|
||||
*/
|
||||
export async function interceptDaemonManagementConfirmDialog(
|
||||
page: Page,
|
||||
): Promise<ConfirmDialogCall> {
|
||||
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
|
||||
await page.waitForFunction(() => !!window.__capturedDialogCall, { timeout: 5_000 });
|
||||
return page.evaluate(() => window.__capturedDialogCall!);
|
||||
}
|
||||
|
||||
export async function toggleDaemonManagement(
|
||||
page: Page,
|
||||
_action: "enable" | "disable",
|
||||
): Promise<void> {
|
||||
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
|
||||
}
|
||||
|
||||
export function expectDaemonManagementConfirmDialog(args: ConfirmDialogCall): void {
|
||||
expect(args.title).toBe("Pause built-in daemon");
|
||||
expect(args.message).toContain("stop the built-in daemon immediately");
|
||||
}
|
||||
|
||||
export async function expectDaemonManagementEnabled(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).toBeChecked();
|
||||
}
|
||||
|
||||
export async function expectDaemonManagementDisabled(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).not.toBeChecked();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the daemon status card shows the given PID. Pass null to assert
|
||||
* the cleared state (shown as "PID —" when the daemon is stopped).
|
||||
*/
|
||||
export async function expectDaemonStatusPid(page: Page, pid: number | null): Promise<void> {
|
||||
const expected = pid !== null ? `PID ${pid}` : "PID —";
|
||||
await expect(
|
||||
page.getByTestId("host-page-daemon-lifecycle-card").getByText(expected),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectDaemonStatusLogPath(page: Page, logPath: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("host-page-daemon-lifecycle-card").getByText(logPath),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the host page identity badge shows the given version string.
|
||||
* The badge is populated from the live WebSocket session's serverInfo.version.
|
||||
*/
|
||||
export async function expectDaemonStatusVersion(page: Page, version: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("host-page-identity").getByText(version, { exact: false }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
249
packages/app/e2e/helpers/github-fixtures.ts
Normal file
249
packages/app/e2e/helpers/github-fixtures.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export function hasGithubAuth(): boolean {
|
||||
try {
|
||||
execSync("gh auth status", { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CheckSpec {
|
||||
context: string;
|
||||
state: "success" | "failure" | "pending";
|
||||
}
|
||||
|
||||
export interface PrSpec {
|
||||
title: string;
|
||||
state: "open" | "merged" | "closed" | "draft";
|
||||
checks?: CheckSpec[];
|
||||
commentCount?: number;
|
||||
}
|
||||
|
||||
export interface IssueSpec {
|
||||
title: string;
|
||||
body?: string;
|
||||
labels?: string[];
|
||||
state?: "open" | "closed";
|
||||
}
|
||||
|
||||
export interface GhPrFixture {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
branch: string;
|
||||
localPath: string;
|
||||
}
|
||||
|
||||
export interface GhIssueFixture {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GhRepoFixture {
|
||||
owner: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
prs: GhPrFixture[];
|
||||
issues: GhIssueFixture[];
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
function gh(args: string[], opts?: { cwd?: string }): string {
|
||||
return execFileSync("gh", args, {
|
||||
cwd: opts?.cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function git(args: string[], cwd: string): string {
|
||||
return execFileSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
async function seedPr(args: {
|
||||
spec: PrSpec;
|
||||
branch: string;
|
||||
index: number;
|
||||
basePath: string;
|
||||
authedUrl: string;
|
||||
fullName: string;
|
||||
repoName: string;
|
||||
}): Promise<{ fixture: GhPrFixture; localPath: string }> {
|
||||
const { spec, branch, index, basePath, authedUrl, fullName, repoName } = args;
|
||||
|
||||
const createArgs = [
|
||||
"pr",
|
||||
"create",
|
||||
"--title",
|
||||
spec.title,
|
||||
"--base",
|
||||
"main",
|
||||
"--head",
|
||||
branch,
|
||||
"--body",
|
||||
"",
|
||||
];
|
||||
if (spec.state === "draft") createArgs.push("--draft");
|
||||
|
||||
const prUrl = gh(createArgs, { cwd: basePath });
|
||||
const prNumber = parseInt(prUrl.split("/").pop() ?? "0", 10);
|
||||
|
||||
if (spec.checks && spec.checks.length > 0) {
|
||||
const sha = git(["rev-parse", branch], basePath);
|
||||
for (const check of spec.checks) {
|
||||
gh([
|
||||
"api",
|
||||
`repos/${fullName}/statuses/${sha}`,
|
||||
"--method",
|
||||
"POST",
|
||||
"-f",
|
||||
`state=${check.state}`,
|
||||
"-f",
|
||||
`context=${check.context}`,
|
||||
"-f",
|
||||
`target_url=https://example.com/${encodeURIComponent(check.context)}`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
for (let j = 0; j < (spec.commentCount ?? 0); j++) {
|
||||
gh(["pr", "comment", String(prNumber), "--body", `Test comment ${j + 1}`], { cwd: basePath });
|
||||
}
|
||||
|
||||
if (spec.state === "merged") {
|
||||
gh(["pr", "merge", String(prNumber), "--merge"], { cwd: basePath });
|
||||
} else if (spec.state === "closed") {
|
||||
gh(["pr", "close", String(prNumber)], { cwd: basePath });
|
||||
}
|
||||
|
||||
const localPath = await mkdtemp(path.join("/tmp", `${repoName}-ws-${index}-`));
|
||||
git(["clone", authedUrl, localPath, "--quiet", "-b", branch], basePath);
|
||||
// Clean remote URL (no embedded token) so gh can parse owner/repo
|
||||
git(["remote", "set-url", "origin", `https://github.com/${fullName}.git`], localPath);
|
||||
git(["config", "user.email", "e2e@paseo.test"], localPath);
|
||||
git(["config", "user.name", "Paseo E2E"], localPath);
|
||||
git(["config", "commit.gpgsign", "false"], localPath);
|
||||
|
||||
return {
|
||||
fixture: { number: prNumber, title: spec.title, url: prUrl, branch, localPath },
|
||||
localPath,
|
||||
};
|
||||
}
|
||||
|
||||
function seedIssue(args: { spec: IssueSpec; basePath: string }): GhIssueFixture {
|
||||
const { spec, basePath } = args;
|
||||
const createArgs = ["issue", "create", "--title", spec.title, "--body", spec.body ?? ""];
|
||||
for (const label of spec.labels ?? []) {
|
||||
createArgs.push("--label", label);
|
||||
}
|
||||
const issueUrl = gh(createArgs, { cwd: basePath });
|
||||
const issueNumber = parseInt(issueUrl.split("/").pop() ?? "0", 10);
|
||||
if (spec.state === "closed") {
|
||||
gh(["issue", "close", String(issueNumber)], { cwd: basePath });
|
||||
}
|
||||
return { number: issueNumber, title: spec.title, url: issueUrl };
|
||||
}
|
||||
|
||||
// Single namespace for temporary GitHub repos created by Paseo tests.
|
||||
// Bulk cleanup relies on this prefix being unmistakable — never reuse `paseo-`
|
||||
// (collides with real repos like `paseo`, `paseo-website`).
|
||||
const TEMP_GITHUB_REPO_PREFIX = "paseotmp-";
|
||||
|
||||
export async function createTempGithubRepo(options: {
|
||||
category: string;
|
||||
prs?: PrSpec[];
|
||||
issues?: IssueSpec[];
|
||||
}): Promise<GhRepoFixture> {
|
||||
const { category, prs = [], issues = [] } = options;
|
||||
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const repoName = `${TEMP_GITHUB_REPO_PREFIX}${category}-${uniqueSuffix}`;
|
||||
|
||||
// Bootstrap local git repo
|
||||
const basePath = await mkdtemp(path.join("/tmp", `${repoName}-base-`));
|
||||
git(["init", "-b", "main"], basePath);
|
||||
git(["config", "user.email", "e2e@paseo.test"], basePath);
|
||||
git(["config", "user.name", "Paseo E2E"], basePath);
|
||||
git(["config", "commit.gpgsign", "false"], basePath);
|
||||
await writeFile(path.join(basePath, "README.md"), "# E2E Test Repo\n");
|
||||
git(["add", "README.md"], basePath);
|
||||
git(["commit", "-m", "Initial commit"], basePath);
|
||||
|
||||
// Create GitHub repo and push initial commit
|
||||
gh(["repo", "create", repoName, "--private", `--source=${basePath}`, "--push"]);
|
||||
|
||||
const owner = gh(["api", "user", "--jq", ".login"]);
|
||||
const fullName = `${owner}/${repoName}`;
|
||||
const token = gh(["auth", "token"]);
|
||||
const authedUrl = `https://x-access-token:${token}@github.com/${fullName}.git`;
|
||||
|
||||
// Switch remote to authed URL for subsequent pushes
|
||||
git(["remote", "set-url", "origin", authedUrl], basePath);
|
||||
|
||||
// Create a branch + commit for each PR spec
|
||||
const branches: string[] = [];
|
||||
for (let i = 0; i < prs.length; i++) {
|
||||
const branch = `pr-branch-${i + 1}`;
|
||||
branches.push(branch);
|
||||
git(["checkout", "-b", branch], basePath);
|
||||
await writeFile(path.join(basePath, `pr-${i + 1}.txt`), `PR ${i + 1}\n`);
|
||||
git(["add", `pr-${i + 1}.txt`], basePath);
|
||||
git(["commit", "-m", `Add PR ${i + 1}`], basePath);
|
||||
git(["checkout", "main"], basePath);
|
||||
}
|
||||
|
||||
if (branches.length > 0) {
|
||||
git(["push", "origin", ...branches], basePath);
|
||||
}
|
||||
|
||||
// Create PRs, seed checks/comments, apply state changes, clone workspaces
|
||||
const prFixtures: GhPrFixture[] = [];
|
||||
const localPaths: string[] = [];
|
||||
|
||||
for (let i = 0; i < prs.length; i++) {
|
||||
const { fixture, localPath } = await seedPr({
|
||||
spec: prs[i],
|
||||
branch: branches[i],
|
||||
index: i,
|
||||
basePath,
|
||||
authedUrl,
|
||||
fullName,
|
||||
repoName,
|
||||
});
|
||||
localPaths.push(localPath);
|
||||
prFixtures.push(fixture);
|
||||
}
|
||||
|
||||
// Create issues
|
||||
const issueFixtures: GhIssueFixture[] = [];
|
||||
for (const spec of issues) {
|
||||
issueFixtures.push(seedIssue({ spec, basePath }));
|
||||
}
|
||||
|
||||
return {
|
||||
owner,
|
||||
name: repoName,
|
||||
fullName,
|
||||
prs: prFixtures,
|
||||
issues: issueFixtures,
|
||||
cleanup: async () => {
|
||||
try {
|
||||
gh(["repo", "delete", fullName, "--yes"]);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
await Promise.all([
|
||||
rm(basePath, { recursive: true, force: true }),
|
||||
...localPaths.map((p) => rm(p, { recursive: true, force: true })),
|
||||
]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -68,20 +68,6 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
||||
|
||||
// ─── Tab actions ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */
|
||||
export async function clickNewTabButton(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */
|
||||
export async function clickNewTerminalButton(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open a new tab. */
|
||||
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
@@ -90,11 +76,6 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||
|
||||
// ─── Tab bar assertions ───────────────────────────────────────────────────
|
||||
|
||||
/** @deprecated The launcher panel was removed. Actions go directly to their target. */
|
||||
export async function waitForLauncherPanel(_page: Page): Promise<void> {
|
||||
// No-op: the launcher panel no longer exists.
|
||||
}
|
||||
|
||||
/** Assert the new agent tab button is visible in the tab bar. */
|
||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
@@ -119,7 +100,7 @@ export async function clickNewChat(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
/** Click the new terminal button to create a terminal tab. */
|
||||
export async function clickTerminal(page: Page): Promise<void> {
|
||||
export async function clickNewTerminal(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
@@ -193,6 +174,19 @@ export async function sampleTabsDuringTransition(
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
export function terminalSurfaceLocator(page: Page) {
|
||||
return page.locator('[data-testid="terminal-surface"]').first();
|
||||
}
|
||||
|
||||
export async function expectAgentTabActive(page: Page, agentId: string): Promise<void> {
|
||||
const tabTestId = `workspace-tab-agent_${agentId}`;
|
||||
await expect(page.getByTestId(tabTestId).filter({ visible: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(getActiveTabTestId(page)).resolves.toBe(tabTestId);
|
||||
}
|
||||
|
||||
// ─── Workspace setup ───────────────────────────────────────────────────────
|
||||
|
||||
/** Create a temp git repo and return its path with a cleanup function. */
|
||||
|
||||
@@ -170,9 +170,12 @@ export async function openNewWorkspaceComposer(
|
||||
|
||||
export async function clickNewWorkspaceButton(
|
||||
page: Page,
|
||||
input: { projectKey: string; projectDisplayName: string },
|
||||
input: { projectKey: string; projectDisplayName: string; prompt?: string },
|
||||
): Promise<void> {
|
||||
await openNewWorkspaceComposer(page, input);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||
await composer.fill(input.prompt ?? "Hello from e2e");
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
@@ -202,12 +205,45 @@ export async function expectStartingRefPickerTriggerPr(
|
||||
page: Page,
|
||||
input: { number: number; title: string; headRef: string },
|
||||
): Promise<void> {
|
||||
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
|
||||
const trigger = page.getByRole("button", { name: "Starting ref" });
|
||||
await expect(trigger).toContainText(`#${input.number}`);
|
||||
await expect(trigger).toContainText(input.title);
|
||||
await expect(trigger).not.toContainText(input.headRef);
|
||||
}
|
||||
|
||||
export async function openBranchPicker(page: Page): Promise<void> {
|
||||
const trigger = page.getByRole("button", { name: "Starting ref" });
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
}
|
||||
|
||||
export async function selectPickerOptionByKeyboard(page: Page, label: string): Promise<void> {
|
||||
const searchInput = page.getByPlaceholder("Search branches and PRs");
|
||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||
await page.keyboard.type(label);
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
|
||||
export async function closeBranchPicker(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function expectPickerOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectPickerClosed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("combobox-desktop-container")).not.toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectPickerSelected(page: Page, label: string): Promise<void> {
|
||||
const trigger = page.getByRole("button", { name: "Starting ref" });
|
||||
await expect(trigger).toContainText(label);
|
||||
}
|
||||
|
||||
export async function expectComposerGithubAttachmentPill(
|
||||
page: Page,
|
||||
input: { number: number; title: string },
|
||||
@@ -250,3 +286,110 @@ export async function assertNewWorkspaceSidebarAndHeader(
|
||||
|
||||
return { workspaceId };
|
||||
}
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(rawMessage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseWebSocketJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getStringField(input: Record<string, unknown>, key: string): string | null {
|
||||
const value = input[key];
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
export interface AgentCreatedDelayControl {
|
||||
release(): void;
|
||||
waitForCreateRequest(): Promise<void>;
|
||||
waitForDelayedCreatedStatus(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function delayBrowserAgentCreatedStatus(
|
||||
page: Page,
|
||||
): Promise<AgentCreatedDelayControl> {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
|
||||
const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
||||
const createRequestIds = new Set<string>();
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let releaseRequested = false;
|
||||
let resolveCreateRequest: (() => void) | null = null;
|
||||
let resolveDelayedCreatedStatus: (() => void) | null = null;
|
||||
const createRequestSeen = new Promise<void>((resolve) => {
|
||||
resolveCreateRequest = resolve;
|
||||
});
|
||||
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
|
||||
resolveDelayedCreatedStatus = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonPortPattern, (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "create_agent_request") {
|
||||
const requestId = getStringField(sessionMessage, "requestId");
|
||||
if (requestId) {
|
||||
createRequestIds.add(requestId);
|
||||
resolveCreateRequest?.();
|
||||
}
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload =
|
||||
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
|
||||
? (sessionMessage.payload as Record<string, unknown>)
|
||||
: null;
|
||||
const requestId = payload ? getStringField(payload, "requestId") : null;
|
||||
|
||||
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
|
||||
resolveDelayedCreatedStatus?.();
|
||||
if (releaseRequested) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
delayedForwards.push(() => ws.send(message));
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
releaseRequested = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
waitForCreateRequest: () => createRequestSeen,
|
||||
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
|
||||
};
|
||||
}
|
||||
|
||||
17
packages/app/e2e/helpers/permissions.ts
Normal file
17
packages/app/e2e/helpers/permissions.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export async function waitForPermissionPrompt(page: Page, timeout = 30_000): Promise<void> {
|
||||
await expect(page.getByTestId("permission-request-question").first()).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
export async function allowPermission(page: Page): Promise<void> {
|
||||
const acceptButton = page.getByTestId("permission-request-accept").first();
|
||||
await expect(acceptButton).toBeVisible({ timeout: 5_000 });
|
||||
await acceptButton.click();
|
||||
}
|
||||
|
||||
export async function denyPermission(page: Page): Promise<void> {
|
||||
const denyButton = page.getByTestId("permission-request-deny").first();
|
||||
await expect(denyButton).toBeVisible({ timeout: 5_000 });
|
||||
await denyButton.click();
|
||||
}
|
||||
42
packages/app/e2e/helpers/pr-pane.ts
Normal file
42
packages/app/e2e/helpers/pr-pane.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { getStateLabel } from "@/git/pr-pane-data";
|
||||
|
||||
export async function openPrPane(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await page.getByTestId("explorer-tab-pr").click();
|
||||
await expect(page.getByTestId("pr-pane")).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function expectPrPaneTitle(page: Page, title: string): Promise<void> {
|
||||
await expect(page.getByTestId("pr-pane-title")).toContainText(title, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function expectPrPaneState(
|
||||
page: Page,
|
||||
state: "open" | "merged" | "closed" | "draft",
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("pr-pane-state")).toHaveText(getStateLabel(state), {
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function assertCheckPill(page: Page, testId: string, count: number): Promise<void> {
|
||||
const locator = page.getByTestId(testId);
|
||||
await expect(locator).toHaveCount(count > 0 ? 1 : 0, { timeout: 15_000 });
|
||||
if (count > 0) {
|
||||
await expect(locator).toContainText(String(count));
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectPrPaneCheckSummary(
|
||||
page: Page,
|
||||
counts: { passed: number; failed: number; pending: number },
|
||||
): Promise<void> {
|
||||
await assertCheckPill(page, "pr-pane-check-passed", counts.passed);
|
||||
await assertCheckPill(page, "pr-pane-check-failed", counts.failed);
|
||||
await assertCheckPill(page, "pr-pane-check-pending", counts.pending);
|
||||
}
|
||||
|
||||
export async function expectPrPaneActivityCount(page: Page, count: number): Promise<void> {
|
||||
await expect(page.getByTestId("pr-pane-activity-row")).toHaveCount(count, { timeout: 15_000 });
|
||||
}
|
||||
269
packages/app/e2e/helpers/project-settings.ts
Normal file
269
packages/app/e2e/helpers/project-settings.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { chmod, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { WebSocketRoute } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
export async function openProjects(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await page.getByTestId("settings-projects").click();
|
||||
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||
}
|
||||
|
||||
export async function openProjectSettings(page: Page, projectName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function navigateToProjectSettings(page: Page, projectName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
|
||||
}
|
||||
|
||||
// --- Form interactions ---
|
||||
|
||||
export async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
|
||||
await page
|
||||
.getByRole("textbox", { name: "Worktree setup commands" })
|
||||
.fill(setupCommands.join("\n"));
|
||||
}
|
||||
|
||||
export async function clickSaveProjectSettings(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Save project config" }).click();
|
||||
}
|
||||
|
||||
export async function clickRetryProjectSettingsSave(page: Page): Promise<void> {
|
||||
// action-0 is always "Try again"; action-1 is always "Reload".
|
||||
// The write-failed callout renders these two buttons in a fixed order.
|
||||
await page.getByTestId("write-failed-callout-action-0").click();
|
||||
}
|
||||
|
||||
export async function clickReloadProjectSettings(page: Page): Promise<void> {
|
||||
// Scope to the active error callout so the locator is unambiguous.
|
||||
// At most one error callout renders at a time.
|
||||
await page.locator('[data-testid$="-callout"]').getByRole("button", { name: "Reload" }).click();
|
||||
}
|
||||
|
||||
// --- Error-state assertions ---
|
||||
|
||||
type ErrorKind = "stale" | "invalid" | "write_failed" | "transport" | "read_failed";
|
||||
|
||||
const errorCalloutTestId: Record<ErrorKind, string> = {
|
||||
stale: "stale-callout",
|
||||
invalid: "invalid-callout",
|
||||
write_failed: "write-failed-callout",
|
||||
transport: "read-transport-callout",
|
||||
read_failed: "read-failed-callout",
|
||||
};
|
||||
|
||||
export async function expectProjectSettingsError(page: Page, kind: ErrorKind): Promise<void> {
|
||||
await expect(page.getByTestId(errorCalloutTestId[kind])).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function expectNoProjectSettingsError(
|
||||
page: Page,
|
||||
kind: ErrorKind,
|
||||
timeout = 15_000,
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId(errorCalloutTestId[kind])).not.toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
export async function expectWriteFailedCalloutActions(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("write-failed-callout-action-0")).toHaveText("Try again");
|
||||
await expect(page.getByTestId("write-failed-callout-action-1")).toHaveText("Reload");
|
||||
}
|
||||
|
||||
export async function expectSaveButtonDisabled(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Save project config" })).toBeDisabled();
|
||||
}
|
||||
|
||||
// --- Form-state assertions ---
|
||||
|
||||
export async function expectProjectSettingsFormVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectProjectSettingsFormHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectNoEditableTarget(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("project-settings-back-button")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
// --- Host-section assertions ---
|
||||
|
||||
export async function expectHostIndicatorVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-indicator")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostPickerHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-picker")).not.toBeVisible();
|
||||
}
|
||||
|
||||
// --- Script-list assertions and interactions ---
|
||||
|
||||
// Counts only row Views, not kebab-trigger elements (which share the "script-row-"
|
||||
// prefix but contain "-menu-").
|
||||
export async function expectScriptRowCount(page: Page, count: number): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("scripts-list")
|
||||
.locator('[data-testid^="script-row-"]:not([data-testid*="-menu-"])'),
|
||||
).toHaveCount(count);
|
||||
}
|
||||
|
||||
export async function expectEmptyScriptList(page: Page): Promise<void> {
|
||||
await expect(page.getByText("No scripts yet.")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function removeProjectScript(page: Page, scriptName: string): Promise<void> {
|
||||
const row = page
|
||||
.getByTestId("scripts-list")
|
||||
.locator('[data-testid^="script-row-"]:not([data-testid*="-menu-"])')
|
||||
.filter({ hasText: scriptName })
|
||||
.first();
|
||||
// DropdownMenuTrigger renders as a Pressable (no role="button"); derive its testID
|
||||
// from the row's testID to avoid scoped locator unreliability.
|
||||
const id = (await row.getAttribute("data-testid"))!.replace("script-row-", "");
|
||||
await page.getByTestId(`script-row-menu-${id}`).click();
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await page.getByRole("button", { name: "Remove" }).click();
|
||||
}
|
||||
|
||||
// --- File manipulation ---
|
||||
|
||||
export async function corruptPaseoConfig(repoPath: string): Promise<void> {
|
||||
await writeFile(path.join(repoPath, "paseo.json"), "{not valid json}");
|
||||
}
|
||||
|
||||
export async function bumpPaseoConfigOnDisk(repoPath: string): Promise<void> {
|
||||
const configPath = path.join(repoPath, "paseo.json");
|
||||
const raw = await readFile(configPath, "utf8");
|
||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
||||
config._bump = Date.now();
|
||||
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n");
|
||||
}
|
||||
|
||||
export async function restorePaseoConfig(
|
||||
repoPath: string,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await writeFile(path.join(repoPath, "paseo.json"), JSON.stringify(config, null, 2) + "\n");
|
||||
}
|
||||
|
||||
// The daemon writes atomically via a temp file + rename, so blocking writes requires
|
||||
// removing write permission from the *directory*, not just the file.
|
||||
export async function blockPaseoConfigWrites(repoPath: string): Promise<void> {
|
||||
await chmod(repoPath, 0o555);
|
||||
}
|
||||
|
||||
export async function unblockPaseoConfigWrites(repoPath: string): Promise<void> {
|
||||
await chmod(repoPath, 0o755);
|
||||
}
|
||||
|
||||
// --- WebSocket helpers ---
|
||||
|
||||
function buildDaemonPortPattern(): RegExp {
|
||||
const port = process.env.E2E_DAEMON_PORT;
|
||||
if (!port) throw new Error("E2E_DAEMON_PORT not set — globalSetup must run first");
|
||||
return new RegExp(`:${port.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
||||
}
|
||||
|
||||
// Proxies all daemon WS traffic transparently until a read_project_config_request
|
||||
// is seen, then closes that connection (triggering readQuery.isError). Subsequent
|
||||
// connections pass through so the Reload action can succeed.
|
||||
export async function installReadTransportFailure(page: Page): Promise<void> {
|
||||
let armed = true;
|
||||
|
||||
await page.routeWebSocket(buildDaemonPortPattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (armed && typeof message === "string") {
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: string;
|
||||
message?: { type?: string };
|
||||
};
|
||||
if (
|
||||
envelope.type === "session" &&
|
||||
envelope.message?.type === "read_project_config_request"
|
||||
) {
|
||||
armed = false;
|
||||
void ws.close({ code: 1001 });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// binary or malformed frame — pass through
|
||||
}
|
||||
}
|
||||
try {
|
||||
server.send(message);
|
||||
} catch {
|
||||
// server socket already closed
|
||||
}
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
try {
|
||||
ws.send(message);
|
||||
} catch {
|
||||
// client socket already closed
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Installs a transparent WS proxy that can later drop all active daemon connections
|
||||
// and block new ones. Code 1001 (Going Away) without reason triggers "error" state
|
||||
// in DaemonClient due to describeTransportClose returning a non-empty string.
|
||||
export async function installDaemonConnectionGate(
|
||||
page: Page,
|
||||
): Promise<{ drop: () => Promise<void> }> {
|
||||
let acceptingConnections = true;
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
|
||||
await page.routeWebSocket(buildDaemonPortPattern(), (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
void ws.close({ code: 1001 });
|
||||
return;
|
||||
}
|
||||
|
||||
activeSockets.add(ws);
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (!acceptingConnections) return;
|
||||
try {
|
||||
server.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
if (!acceptingConnections) return;
|
||||
try {
|
||||
ws.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
async drop(): Promise<void> {
|
||||
acceptingConnections = false;
|
||||
const sockets = Array.from(activeSockets);
|
||||
activeSockets.clear();
|
||||
await Promise.all(sockets.map((ws) => ws.close({ code: 1001 }).catch(() => undefined)));
|
||||
},
|
||||
};
|
||||
}
|
||||
273
packages/app/e2e/helpers/settings.ts
Normal file
273
packages/app/e2e/helpers/settings.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { requireServerId } from "./sidebar";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
const SECTION_LABELS = {
|
||||
general: "General",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
permissions: "Permissions",
|
||||
diagnostics: "Diagnostics",
|
||||
about: "About",
|
||||
} as const;
|
||||
|
||||
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
|
||||
|
||||
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
if (section === "projects") {
|
||||
await page.getByTestId("settings-projects").click();
|
||||
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||
return;
|
||||
}
|
||||
|
||||
await sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/settings/${section}$`));
|
||||
}
|
||||
|
||||
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
|
||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(title);
|
||||
}
|
||||
|
||||
export async function openAddHostFlow(page: Page): Promise<void> {
|
||||
await page.getByTestId("settings-add-host").click();
|
||||
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function selectHostConnectionType(
|
||||
page: Page,
|
||||
type: "direct" | "relay",
|
||||
): Promise<void> {
|
||||
const label = type === "direct" ? "Direct connection" : "Paste pairing link";
|
||||
await page.getByRole("button", { name: label }).click();
|
||||
}
|
||||
|
||||
export async function toggleHostAdvanced(page: Page): Promise<void> {
|
||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
||||
}
|
||||
|
||||
export async function openCompactSettings(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(settingsButton).toBeVisible();
|
||||
await settingsButton.click();
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectCompactSettingsList(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectSettingsSidebarVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectSettingsSidebarHidden(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectSettingsSidebarSections(
|
||||
page: Page,
|
||||
sections: Array<Exclude<SettingsSection, "projects">>,
|
||||
): Promise<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
for (const section of sections) {
|
||||
await expect(
|
||||
sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
export async function goBackInSettings(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectSettingsBackButton(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function clickSettingsBackToWorkspace(page: Page): Promise<void> {
|
||||
await page.getByTestId("settings-back-to-workspace").click();
|
||||
}
|
||||
|
||||
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function verifyLegacyHostSettingsRedirect(page: Page): Promise<void> {
|
||||
const serverId = requireServerId();
|
||||
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
|
||||
await expectHostSettingsUrl(page, serverId);
|
||||
}
|
||||
|
||||
export async function openCompactSettingsHost(page: Page): Promise<void> {
|
||||
const serverId = requireServerId();
|
||||
await openSettingsHost(page, serverId);
|
||||
await expectHostSettingsUrl(page, serverId);
|
||||
}
|
||||
|
||||
export async function expectAddHostMethodOptions(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Direct connection" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function fillDirectHostUri(page: Page, uri: string): Promise<void> {
|
||||
await page.getByTestId("direct-host-uri-input").fill(uri);
|
||||
}
|
||||
|
||||
export async function expectDirectHostFormValues(
|
||||
page: Page,
|
||||
fields: { host: string; port: string; password: string },
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("direct-host-input")).toHaveValue(fields.host);
|
||||
await expect(page.getByTestId("direct-port-input")).toHaveValue(fields.port);
|
||||
await expect(page.getByTestId("direct-password-input")).toHaveValue(fields.password);
|
||||
}
|
||||
|
||||
export async function expectDirectHostSslEnabled(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectDirectHostUriValue(page: Page, uri: string): Promise<void> {
|
||||
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(uri);
|
||||
}
|
||||
|
||||
export async function expectDirectHostUriHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectDiagnosticsContent(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectAboutContent(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectGeneralContent(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostLabelDisplayed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function clickEditHostLabel(page: Page): Promise<void> {
|
||||
await page.getByTestId("host-page-label-edit-button").click();
|
||||
}
|
||||
|
||||
export async function expectHostLabelEditMode(page: Page, expectedLabel: string): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveValue(expectedLabel);
|
||||
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostConnectionsCard(page: Page, port: string): Promise<void> {
|
||||
const card = page.getByTestId("host-page-connections-card");
|
||||
await expect(card).toBeVisible();
|
||||
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
card.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostInjectMcpCard(page: Page): Promise<void> {
|
||||
const card = page.getByTestId("host-page-inject-mcp-card");
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostActionCards(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function serveJson(page: Page, url: string, body: unknown): Promise<void> {
|
||||
await page.route(url, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function openAddProviderModal(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add provider", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Search providers" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function findAcpCatalogProvider(page: Page, providerName: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search providers" }).fill(providerName);
|
||||
await expect(page.getByText(providerName, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function installAcpCatalogProvider(page: Page, providerName: string): Promise<void> {
|
||||
await findAcpCatalogProvider(page, providerName);
|
||||
await page.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Search providers" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectProviderInstalledInSettings(
|
||||
page: Page,
|
||||
providerName: string,
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.getByRole("button", { name: `${providerName} provider details`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostNoLocalOnlyRows(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
|
||||
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostPageVisible(page: Page, serverId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectLocalHostEntryFirst(page: Page, serverId: string): Promise<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible({ timeout: 15_000 });
|
||||
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]').first()).toHaveAttribute(
|
||||
"data-testid",
|
||||
`settings-host-entry-${serverId}`,
|
||||
);
|
||||
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
|
||||
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
|
||||
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
|
||||
}
|
||||
40
packages/app/e2e/helpers/sidebar.ts
Normal file
40
packages/app/e2e/helpers/sidebar.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export function requireServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
export async function selectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${requireServerId()}:${workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
}
|
||||
|
||||
export async function expectWorkspaceListed(page: Page, name: string): Promise<void> {
|
||||
await expect(
|
||||
page.locator('[data-testid^="sidebar-workspace-row-"]').filter({ hasText: name }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function openMobileAgentSidebar(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
}
|
||||
|
||||
export async function closeMobileAgentSidebar(page: Page): Promise<void> {
|
||||
const closeButton = page.getByTestId("sidebar-close");
|
||||
await expect(closeButton).toBeInViewport({ timeout: 5_000 });
|
||||
await closeButton.click({ force: true });
|
||||
}
|
||||
|
||||
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
|
||||
export async function expectMobileAgentSidebarVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function expectMobileAgentSidebarHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
|
||||
}
|
||||
@@ -140,7 +140,6 @@ class StartupAssertions {
|
||||
|
||||
async expectsReconnectWelcome(): Promise<this> {
|
||||
await expect(this.page.getByTestId("welcome-screen")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(this.page.getByText("Connecting…", { exact: true })).toBeVisible();
|
||||
await expect(this.page.getByTestId("welcome-open-settings")).toBeVisible();
|
||||
await expect(this.page.getByTestId("welcome-direct-connection")).toBeVisible();
|
||||
await expect(this.page.getByTestId("welcome-paste-pairing-link")).toBeVisible();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -24,7 +24,16 @@ export interface TerminalPerfDaemonClient {
|
||||
cwd: string;
|
||||
title?: string;
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
initialPrompt?: string;
|
||||
}): Promise<{ id: string; status: string }>;
|
||||
waitForAgentUpsert(
|
||||
agentId: string,
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
subscribeTerminal(
|
||||
terminalId: string,
|
||||
@@ -259,6 +268,34 @@ export async function measureKeystrokeLatency(page: Page, char: string): Promise
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectTerminalSurfaceVisible(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect(page.locator('[data-testid="terminal-surface"]').first()).toBeVisible({
|
||||
timeout: options?.timeout ?? 20_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function focusTerminalSurface(page: Page): Promise<void> {
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await page.locator('[data-testid="terminal-surface"]').first().click();
|
||||
}
|
||||
|
||||
export async function typeInTerminal(page: Page, text: string): Promise<void> {
|
||||
await page
|
||||
.locator('[data-testid="terminal-surface"]')
|
||||
.first()
|
||||
.pressSequentially(text, { delay: 0 });
|
||||
}
|
||||
|
||||
export async function waitForTerminalAttached(page: Page): Promise<void> {
|
||||
await page
|
||||
.locator('[data-testid="terminal-attach-loading"]')
|
||||
.waitFor({ state: "hidden", timeout: 10_000 })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
export function computePercentile(samples: number[], p: number): number {
|
||||
const sorted = [...samples].sort((a, b) => a - b);
|
||||
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
||||
|
||||
@@ -204,8 +204,7 @@ export async function installTerminalRenderProbe(page: Page): Promise<void> {
|
||||
if (next?.write && !next.__paseoRenderProbeWriteWrapped) {
|
||||
const originalWrite = next.write.bind(next);
|
||||
next.write = (data: string | Uint8Array, callback?: () => void) => {
|
||||
const text =
|
||||
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
|
||||
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
||||
probe.writeCount += 1;
|
||||
const preview = text
|
||||
.replaceAll("\u001b", "\\x1b")
|
||||
@@ -424,7 +423,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
|
||||
const count = Math.min(from.length, to.length);
|
||||
const values: number[] = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
values.push(to[index]!.at - from[index]!.at);
|
||||
values.push(to[index].at - from[index].at);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
@@ -459,7 +458,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
|
||||
const keydownToInputFrame = this.keydowns
|
||||
.map((keydown) => firstAtOrAfter(this.inputFrames, keydown.at)?.at ?? null)
|
||||
.filter((at): at is number => at !== null)
|
||||
.map((at, index) => at - this.keydowns[index]!.at);
|
||||
.map((at, index) => at - this.keydowns[index].at);
|
||||
const inputFrameToOutputFrame = this.inputFrames
|
||||
.map((input) => {
|
||||
const output = firstAtOrAfter(this.outputFrames, input.at);
|
||||
@@ -663,8 +662,7 @@ export async function installTerminalKeystrokeStressProbe(page: Page): Promise<v
|
||||
if (next?.write && !next.__paseoKeystrokeProbeWriteWrapped) {
|
||||
const originalWrite = next.write.bind(next);
|
||||
next.write = (data: string | Uint8Array, callback?: () => void) => {
|
||||
const text =
|
||||
typeof data === "string" ? data : new TextDecoder().decode(data as Uint8Array);
|
||||
const text = typeof data === "string" ? data : new TextDecoder().decode(data);
|
||||
const event: XtermWriteEvent = {
|
||||
at: performance.now(),
|
||||
committedAt: null,
|
||||
|
||||
105
packages/app/e2e/helpers/with-workspace.ts
Normal file
105
packages/app/e2e/helpers/with-workspace.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { waitForTabBar } from "./launcher";
|
||||
import { selectWorkspaceInSidebar } from "./sidebar";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
openHomeWithProject,
|
||||
type WorkspaceSetupDaemonClient,
|
||||
} from "./workspace-setup";
|
||||
|
||||
export interface CreatedWorkspace {
|
||||
workspaceId: string;
|
||||
repoPath: string;
|
||||
navigateTo(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface WithWorkspaceOptions {
|
||||
worktree?: boolean;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
export type WithWorkspace = (options?: WithWorkspaceOptions) => Promise<CreatedWorkspace>;
|
||||
|
||||
interface WorktreeRecord {
|
||||
repoPath: string;
|
||||
worktreePath: string;
|
||||
}
|
||||
|
||||
export interface WithWorkspaceHandle {
|
||||
withWorkspace: WithWorkspace;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createWithWorkspace(page: Page): WithWorkspaceHandle {
|
||||
let client: WorkspaceSetupDaemonClient | null = null;
|
||||
const repos: Array<{ cleanup: () => Promise<void> }> = [];
|
||||
const worktrees: WorktreeRecord[] = [];
|
||||
|
||||
const withWorkspace: WithWorkspace = async (options) => {
|
||||
if (!client) {
|
||||
client = await connectWorkspaceSetupClient();
|
||||
}
|
||||
const prefix = options?.prefix ?? (options?.worktree ? "wt-" : "ws-");
|
||||
const repo = await createTempGitRepo(prefix);
|
||||
repos.push(repo);
|
||||
|
||||
let workspacePath = repo.path;
|
||||
if (options?.worktree) {
|
||||
const tempRoot = await realpath("/tmp");
|
||||
workspacePath = path.join(
|
||||
tempRoot,
|
||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
const branchName = `paseo-wt-${Date.now()}`;
|
||||
execSync(
|
||||
`git worktree add ${JSON.stringify(workspacePath)} -b ${JSON.stringify(branchName)} main`,
|
||||
{ cwd: repo.path, stdio: "ignore" },
|
||||
);
|
||||
worktrees.push({ repoPath: repo.path, worktreePath: workspacePath });
|
||||
// Register the parent project so the sidebar lists it before we navigate.
|
||||
await client.openProject(repo.path);
|
||||
}
|
||||
|
||||
const opened = await client.openProject(workspacePath);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${workspacePath}`);
|
||||
}
|
||||
const workspaceId = opened.workspace.id;
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
repoPath: workspacePath,
|
||||
navigateTo: async () => {
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await selectWorkspaceInSidebar(page, workspaceId);
|
||||
await waitForTabBar(page);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
withWorkspace,
|
||||
cleanup: async () => {
|
||||
for (const { repoPath, worktreePath } of worktrees) {
|
||||
try {
|
||||
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup so the original test failure is preserved.
|
||||
}
|
||||
}
|
||||
for (const repo of repos) {
|
||||
await repo.cleanup();
|
||||
}
|
||||
if (client) {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { clickNewChat, clickTerminal } from "./launcher";
|
||||
import { clickNewChat, clickNewTerminal } from "./launcher";
|
||||
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
|
||||
|
||||
function terminalSurface(page: Page) {
|
||||
@@ -20,7 +20,7 @@ export async function expectTerminalCwd(page: Page, expectedPath: string): Promi
|
||||
}
|
||||
|
||||
export async function createStandaloneTerminalFromLauncher(page: Page): Promise<void> {
|
||||
await clickTerminal(page);
|
||||
await clickNewTerminal(page);
|
||||
await expect(terminalSurface(page)).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { expect, type Page } from "@playwright/test";
|
||||
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { switchWorkspaceViaSidebar } from "./workspace-ui";
|
||||
import type { SessionOutboundMessage } from "@server/shared/messages";
|
||||
|
||||
interface WorkspaceSetupDaemonClient {
|
||||
@@ -44,7 +45,7 @@ interface WorkspaceSetupDaemonClient {
|
||||
}>;
|
||||
fetchAgent(agentId: string): Promise<{
|
||||
agent: { id: string; cwd: string } | null;
|
||||
project: unknown | null;
|
||||
project: unknown;
|
||||
} | null>;
|
||||
listTerminals(cwd: string): Promise<{
|
||||
cwd?: string;
|
||||
@@ -265,7 +266,7 @@ export async function createWorkspaceThroughDaemon(
|
||||
throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`);
|
||||
}
|
||||
return {
|
||||
id: String(result.workspace.id),
|
||||
id: result.workspace.id,
|
||||
name: result.workspace.name,
|
||||
};
|
||||
}
|
||||
@@ -291,7 +292,7 @@ export async function findWorktreeWorkspaceForProject(
|
||||
throw new Error(`Failed to find created worktree workspace for ${repoPath}`);
|
||||
}
|
||||
return {
|
||||
id: String(workspace.id),
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
@@ -308,13 +309,37 @@ export async function fetchWorkspaceById(
|
||||
projectRootPath: string;
|
||||
}> {
|
||||
const payload = await client.fetchWorkspaces();
|
||||
const workspace = payload.entries.find((entry) => String(entry.id) === workspaceId) ?? null;
|
||||
const workspace = payload.entries.find((entry) => entry.id === workspaceId) ?? null;
|
||||
if (!workspace) {
|
||||
throw new Error(`Workspace not found: ${workspaceId}`);
|
||||
}
|
||||
return workspace;
|
||||
}
|
||||
|
||||
export async function navigateToWorkspaceViaSidebar(
|
||||
page: Page,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: workspaceId });
|
||||
}
|
||||
|
||||
export async function openWorkspaceScriptsMenu(page: Page): Promise<void> {
|
||||
await page.getByTestId("workspace-scripts-button").click();
|
||||
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function startWorkspaceScriptFromMenu(page: Page, scriptName: string): Promise<void> {
|
||||
await page.getByTestId(`workspace-scripts-start-${scriptName}`).click();
|
||||
}
|
||||
|
||||
export async function closeWorkspaceScriptsMenu(page: Page): Promise<void> {
|
||||
await page.getByTestId("workspace-scripts-menu-backdrop").click();
|
||||
}
|
||||
|
||||
export async function waitForWorkspaceSetupProgress(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
predicate: (payload: WorkspaceSetupProgressPayload) => boolean,
|
||||
|
||||
@@ -70,6 +70,29 @@ export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectWorkspaceTabsAbsent(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-tabs-row")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectNoTerminalTabs(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function clickFirstTerminalTab(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
const tab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
|
||||
await expect(tab).toBeVisible({ timeout: options?.timeout ?? 30_000 });
|
||||
await tab.click();
|
||||
}
|
||||
|
||||
export async function expectFirstTerminalTabContains(page: Page, text: string): Promise<void> {
|
||||
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]').first()).toContainText(
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
export async function sampleWorkspaceTabIds(
|
||||
page: Page,
|
||||
options: { durationMs?: number; intervalMs?: number } = {},
|
||||
|
||||
@@ -114,6 +114,49 @@ export async function expectWorkspaceHeader(
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectReconnectingToastVisible(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("agent-reconnecting-toast")).toBeVisible({
|
||||
timeout: options?.timeout ?? 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectReconnectingToastGone(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("agent-reconnecting-toast")).toHaveCount(0, {
|
||||
timeout: options?.timeout ?? 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectHostConnectingOrOffline(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.getByText(/^Connecting$|localhost is offline|Cannot reach localhost/i),
|
||||
).toBeVisible({ timeout: options?.timeout ?? 30_000 });
|
||||
}
|
||||
|
||||
export async function expectMenuButtonVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("menu-button")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHeaderAbsent(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-header-title")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export function workspaceDeckEntryLocator(page: Page, serverId: string, workspaceId: string) {
|
||||
return page.getByTestId(`workspace-deck-entry-${serverId}:${workspaceId}`);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceDeckEntryCount(page: Page, count: number): Promise<void> {
|
||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(count);
|
||||
}
|
||||
|
||||
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
@@ -9,10 +9,35 @@ interface TempRepo {
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function configureRemote(input: {
|
||||
repoPath: string;
|
||||
withRemote: boolean;
|
||||
originUrl: string | undefined;
|
||||
}): Promise<void> {
|
||||
const { repoPath, withRemote, originUrl } = input;
|
||||
if (withRemote) {
|
||||
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
||||
const remoteDir = path.join(repoPath, "remote.git");
|
||||
await mkdir(remoteDir, { recursive: true });
|
||||
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
|
||||
return;
|
||||
}
|
||||
if (originUrl) {
|
||||
// Daemon reads origin for project grouping; no fetch occurs, so a synthetic URL is fine.
|
||||
execSync(`git remote add origin ${JSON.stringify(originUrl)}`, {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const createTempGitRepo = async (
|
||||
prefix = "paseo-e2e-",
|
||||
options?: {
|
||||
withRemote?: boolean;
|
||||
originUrl?: string;
|
||||
paseoConfig?: Record<string, unknown>;
|
||||
files?: Array<{ path: string; content: string }>;
|
||||
branches?: string[];
|
||||
@@ -74,14 +99,7 @@ export const createTempGitRepo = async (
|
||||
execSync("git checkout main", { cwd: repoPath, stdio: "ignore" });
|
||||
}
|
||||
|
||||
if (withRemote) {
|
||||
// Deterministic local remote to avoid relying on external auth/network in e2e.
|
||||
const remoteDir = path.join(repoPath, "remote.git");
|
||||
await mkdir(remoteDir, { recursive: true });
|
||||
execSync(`git init --bare -b main ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync(`git remote add origin ${remoteDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync("git push -u origin --all", { cwd: repoPath, stdio: "ignore" });
|
||||
}
|
||||
await configureRemote({ repoPath, withRemote, originUrl: options?.originUrl });
|
||||
|
||||
return {
|
||||
path: repoPath,
|
||||
|
||||
@@ -5,16 +5,18 @@ import {
|
||||
assertNewChatTileVisible,
|
||||
assertTerminalTileVisible,
|
||||
assertSingleNewTabButton,
|
||||
clickNewTabButton,
|
||||
pressNewTabShortcut,
|
||||
clickNewChat,
|
||||
clickTerminal,
|
||||
clickNewTerminal,
|
||||
countTabsOfKind,
|
||||
getTabTestIds,
|
||||
waitForTabWithTitle,
|
||||
measureTileTransition,
|
||||
sampleTabsDuringTransition,
|
||||
terminalSurfaceLocator,
|
||||
} from "./helpers/launcher";
|
||||
import { expectComposerVisible, composerLocator } from "./helpers/composer";
|
||||
import { expectTerminalSurfaceVisible } from "./helpers/terminal-perf";
|
||||
import {
|
||||
connectTerminalClient,
|
||||
setupDeterministicPrompt,
|
||||
@@ -32,7 +34,7 @@ test.beforeAll(async () => {
|
||||
seedClient = await connectTerminalClient();
|
||||
const result = await seedClient.openProject(tempRepo.path);
|
||||
if (!result.workspace) throw new Error(result.error ?? "Failed to seed workspace");
|
||||
workspaceId = String(result.workspace.id);
|
||||
workspaceId = result.workspace.id;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
@@ -50,9 +52,7 @@ test.describe("Tab creation", () => {
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
|
||||
// Should show the composer directly (no launcher panel)
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
|
||||
await expectComposerVisible(page);
|
||||
});
|
||||
|
||||
test("opening two new tabs creates two draft tabs", async ({ page }) => {
|
||||
@@ -77,11 +77,9 @@ test.describe("Tab creation", () => {
|
||||
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await clickNewChat(page);
|
||||
|
||||
// Draft composer should appear (the agent message input)
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
|
||||
@@ -92,11 +90,9 @@ test.describe("Tab creation", () => {
|
||||
test.setTimeout(45_000);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
await clickTerminal(page);
|
||||
await clickNewTerminal(page);
|
||||
|
||||
// Terminal surface should appear
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
|
||||
@@ -141,19 +137,18 @@ test.describe("Terminal title propagation", () => {
|
||||
try {
|
||||
// Navigate to workspace and open a terminal
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickTerminal(page);
|
||||
await clickNewTerminal(page);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
await terminal.first().click();
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await terminalSurfaceLocator(page).click();
|
||||
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
// Send OSC 0 (set window title) escape sequence
|
||||
const testTitle = `E2E-Title-${Date.now()}`;
|
||||
await terminal
|
||||
.first()
|
||||
.pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, { delay: 0 });
|
||||
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, {
|
||||
delay: 0,
|
||||
});
|
||||
|
||||
// Wait for the tab to reflect the new title
|
||||
await waitForTabWithTitle(page, testTitle, 15_000);
|
||||
@@ -171,24 +166,24 @@ test.describe("Terminal title propagation", () => {
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickTerminal(page);
|
||||
await clickNewTerminal(page);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
await terminal.first().click();
|
||||
await expectTerminalSurfaceVisible(page);
|
||||
await terminalSurfaceLocator(page).click();
|
||||
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
// Fire many rapid title changes — only the last should stick
|
||||
const finalTitle = `Final-${Date.now()}`;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await terminal
|
||||
.first()
|
||||
.pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { delay: 0 });
|
||||
await terminalSurfaceLocator(page).pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, {
|
||||
delay: 0,
|
||||
});
|
||||
}
|
||||
await terminal
|
||||
.first()
|
||||
.pressSequentially(`printf '\\033]0;${finalTitle}\\007'\n`, { delay: 0 });
|
||||
await terminalSurfaceLocator(page).pressSequentially(
|
||||
`printf '\\033]0;${finalTitle}\\007'\n`,
|
||||
{ delay: 0 },
|
||||
);
|
||||
|
||||
// The tab should eventually settle on the final title
|
||||
await waitForTabWithTitle(page, finalTitle, 15_000);
|
||||
@@ -228,11 +223,10 @@ test.describe("Tab transitions (no flash)", () => {
|
||||
test.setTimeout(30_000);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickTerminal(page),
|
||||
terminal.first(),
|
||||
() => clickNewTerminal(page),
|
||||
terminalSurfaceLocator(page),
|
||||
20_000,
|
||||
);
|
||||
|
||||
@@ -245,9 +239,12 @@ test.describe("Tab transitions (no flash)", () => {
|
||||
test("New agent tab click shows composer without flash", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
|
||||
const elapsed = await measureTileTransition(page, () => clickNewChat(page), composer, 10_000);
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewChat(page),
|
||||
composerLocator(page),
|
||||
10_000,
|
||||
);
|
||||
|
||||
// Draft creation is fully in-memory — should be fast
|
||||
// We use a generous budget here because CI can be slow, but the key assertion
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
assertNewWorkspaceSidebarAndHeader,
|
||||
clickNewWorkspaceButton,
|
||||
closeBranchPicker,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
delayBrowserAgentCreatedStatus,
|
||||
expectComposerGithubAttachmentPill,
|
||||
expectPickerClosed,
|
||||
expectPickerOpen,
|
||||
expectPickerSelected,
|
||||
expectStartingRefPickerTriggerPr,
|
||||
openBranchPicker,
|
||||
openNewWorkspaceComposer,
|
||||
openStartingRefPicker,
|
||||
openProjectViaDaemon,
|
||||
openStartingRefPicker,
|
||||
selectBranchInPicker,
|
||||
selectGitHubPrInPicker,
|
||||
selectPickerOptionByKeyboard,
|
||||
} from "./helpers/new-workspace";
|
||||
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
||||
import {
|
||||
@@ -28,108 +35,6 @@ import {
|
||||
workspaceLabelFromPath,
|
||||
} from "./helpers/workspace-ui";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(rawMessage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseWebSocketJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getStringField(input: Record<string, unknown>, key: string): string | null {
|
||||
const value = input[key];
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
async function delayBrowserAgentCreatedStatus(page: Page) {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
|
||||
const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
||||
const createRequestIds = new Set<string>();
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let releaseRequested = false;
|
||||
let resolveCreateRequest: (() => void) | null = null;
|
||||
let resolveDelayedCreatedStatus: (() => void) | null = null;
|
||||
const createRequestSeen = new Promise<void>((resolve) => {
|
||||
resolveCreateRequest = resolve;
|
||||
});
|
||||
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
|
||||
resolveDelayedCreatedStatus = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonPortPattern, (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "create_agent_request") {
|
||||
const requestId = getStringField(sessionMessage, "requestId");
|
||||
if (requestId) {
|
||||
createRequestIds.add(requestId);
|
||||
resolveCreateRequest?.();
|
||||
}
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload =
|
||||
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
|
||||
? (sessionMessage.payload as Record<string, unknown>)
|
||||
: null;
|
||||
const requestId = payload ? getStringField(payload, "requestId") : null;
|
||||
|
||||
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
|
||||
resolveDelayedCreatedStatus?.();
|
||||
if (releaseRequested) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
|
||||
delayedForwards.push(() => ws.send(message));
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
releaseRequested = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
waitForCreateRequest: () => createRequestSeen,
|
||||
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("New workspace flow", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
@@ -401,6 +306,10 @@ test.describe("New workspace flow", () => {
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||
await composer.fill("Hello from e2e");
|
||||
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
@@ -505,6 +414,55 @@ test.describe("New workspace flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("branch picker opens via keyboard, navigates options, and selects on Enter", async ({
|
||||
page,
|
||||
}) => {
|
||||
const tempRepo = await createTempGitRepo("picker-keyboard-", { branches: ["main", "dev"] });
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
await selectPickerOptionByKeyboard(page, "dev");
|
||||
await expectPickerSelected(page, "dev");
|
||||
await expectPickerClosed(page);
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("branch picker closes on Escape without selecting an option", async ({ page }) => {
|
||||
const tempRepo = await createTempGitRepo("picker-escape-");
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
await openBranchPicker(page);
|
||||
await expectPickerOpen(page);
|
||||
await closeBranchPicker(page);
|
||||
await expectPickerClosed(page);
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
|
||||
const tempRepo = await createTempGitRepo("new-workspace-pr-ref-");
|
||||
|
||||
|
||||
124
packages/app/e2e/pr-pane.spec.ts
Normal file
124
packages/app/e2e/pr-pane.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
openPrPane,
|
||||
expectPrPaneTitle,
|
||||
expectPrPaneState,
|
||||
expectPrPaneCheckSummary,
|
||||
expectPrPaneActivityCount,
|
||||
} from "./helpers/pr-pane";
|
||||
import { gotoWorkspace } from "./helpers/launcher";
|
||||
import { hasGithubAuth, createTempGithubRepo, type GhRepoFixture } from "./helpers/github-fixtures";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
type WorkspaceSetupDaemonClient,
|
||||
} from "./helpers/workspace-setup";
|
||||
|
||||
const GITHUB_AUTH = hasGithubAuth();
|
||||
|
||||
test.describe("PR pane", () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
let seedClient: WorkspaceSetupDaemonClient;
|
||||
let repoFixture: GhRepoFixture;
|
||||
const workspaceByTitle = new Map<string, string>();
|
||||
|
||||
test.beforeAll(async () => {
|
||||
if (!GITHUB_AUTH) return;
|
||||
|
||||
seedClient = await connectWorkspaceSetupClient();
|
||||
|
||||
repoFixture = await createTempGithubRepo({
|
||||
category: "pr-pane",
|
||||
prs: [
|
||||
{ title: "Review selected start ref", state: "open" },
|
||||
{ title: "Merged feature branch", state: "merged" },
|
||||
{ title: "Closed without merge", state: "closed" },
|
||||
{ title: "Work in progress", state: "draft" },
|
||||
{
|
||||
title: "PR with mixed checks",
|
||||
state: "open",
|
||||
checks: [
|
||||
{ context: "build-1", state: "success" },
|
||||
{ context: "build-2", state: "success" },
|
||||
{ context: "deploy", state: "failure" },
|
||||
{ context: "security", state: "pending" },
|
||||
],
|
||||
},
|
||||
{ title: "PR with reviews", state: "open", commentCount: 3 },
|
||||
{ title: "PR with no checks", state: "open" },
|
||||
],
|
||||
});
|
||||
|
||||
for (const pr of repoFixture.prs) {
|
||||
const result = await seedClient.openProject(pr.localPath);
|
||||
if (!result.workspace) {
|
||||
throw new Error(result.error ?? `Failed to open project ${pr.localPath}`);
|
||||
}
|
||||
workspaceByTitle.set(pr.title, result.workspace.id);
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await repoFixture?.cleanup().catch(() => undefined);
|
||||
await seedClient?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
test.skip(!GITHUB_AUTH, "Requires GitHub authentication (gh auth login)");
|
||||
test.setTimeout(60_000);
|
||||
});
|
||||
|
||||
test("renders an open PR with title, state, and repo line", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("Review selected start ref")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneTitle(page, "Review selected start ref");
|
||||
await expectPrPaneState(page, "open");
|
||||
});
|
||||
|
||||
test("renders merged state label and icon", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("Merged feature branch")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneState(page, "merged");
|
||||
await expectPrPaneTitle(page, "Merged feature branch");
|
||||
});
|
||||
|
||||
test("renders closed state label and icon", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("Closed without merge")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneState(page, "closed");
|
||||
await expectPrPaneTitle(page, "Closed without merge");
|
||||
});
|
||||
|
||||
test("renders draft state label and icon", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("Work in progress")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneState(page, "draft");
|
||||
await expectPrPaneTitle(page, "Work in progress");
|
||||
});
|
||||
|
||||
test("renders check pills with correct passed/failed/pending counts", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with mixed checks")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneCheckSummary(page, { passed: 2, failed: 1, pending: 1 });
|
||||
});
|
||||
|
||||
test("renders activity rows with correct count", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with reviews")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneActivityCount(page, 3);
|
||||
});
|
||||
|
||||
test("renders gracefully with zero checks", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceByTitle.get("PR with no checks")!);
|
||||
await openPrPane(page);
|
||||
|
||||
await expectPrPaneCheckSummary(page, { passed: 0, failed: 0, pending: 0 });
|
||||
await expectPrPaneTitle(page, "PR with no checks");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,36 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { chmod, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test as base, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { expect, test as base } from "./fixtures";
|
||||
import { connectNewWorkspaceDaemonClient, openProjectViaDaemon } from "./helpers/new-workspace";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
blockPaseoConfigWrites,
|
||||
bumpPaseoConfigOnDisk,
|
||||
clickReloadProjectSettings,
|
||||
clickRetryProjectSettingsSave,
|
||||
clickSaveProjectSettings,
|
||||
corruptPaseoConfig,
|
||||
editWorktreeSetup,
|
||||
expectEmptyScriptList,
|
||||
expectHostIndicatorVisible,
|
||||
expectHostPickerHidden,
|
||||
expectNoEditableTarget,
|
||||
expectNoProjectSettingsError,
|
||||
expectProjectSettingsError,
|
||||
expectProjectSettingsFormHidden,
|
||||
expectProjectSettingsFormVisible,
|
||||
expectSaveButtonDisabled,
|
||||
expectScriptRowCount,
|
||||
expectWriteFailedCalloutActions,
|
||||
installDaemonConnectionGate,
|
||||
installReadTransportFailure,
|
||||
navigateToProjectSettings,
|
||||
openProjectSettings,
|
||||
openProjects,
|
||||
removeProjectScript,
|
||||
restorePaseoConfig,
|
||||
unblockPaseoConfigWrites,
|
||||
} from "./helpers/project-settings";
|
||||
|
||||
const updatedSetup = ["npm install", "npm run build"];
|
||||
|
||||
@@ -14,6 +41,7 @@ interface ProjectsSettingsProject {
|
||||
|
||||
interface ProjectsSettingsFixtures {
|
||||
editableProject: ProjectsSettingsProject;
|
||||
gitlabRemoteProject: ProjectsSettingsProject;
|
||||
}
|
||||
|
||||
const initialPaseoConfig = {
|
||||
@@ -46,35 +74,30 @@ const test = base.extend<ProjectsSettingsFixtures>({
|
||||
path: repo.path,
|
||||
});
|
||||
|
||||
await client.close();
|
||||
// Defensive: restore directory write permission in case the test left it blocked
|
||||
// (write_failed test), so that repo.cleanup() can remove files inside.
|
||||
await chmod(repo.path, 0o755).catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
},
|
||||
gitlabRemoteProject: async ({ page: _page }, provide) => {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const repo = await createTempGitRepo("projects-settings-gitlab-", {
|
||||
paseoConfig: initialPaseoConfig,
|
||||
originUrl: "https://gitlab.com/acme/app.git",
|
||||
});
|
||||
const openedProject = await openProjectViaDaemon(client, repo.path);
|
||||
|
||||
await provide({
|
||||
name: openedProject.projectDisplayName,
|
||||
path: repo.path,
|
||||
});
|
||||
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
},
|
||||
});
|
||||
|
||||
async function openProjects(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await page.getByTestId("settings-projects").click();
|
||||
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||
}
|
||||
|
||||
async function openProjectSettings(page: Page, projectName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
|
||||
await page
|
||||
.getByRole("textbox", { name: "Worktree setup commands" })
|
||||
.fill(setupCommands.join("\n"));
|
||||
}
|
||||
|
||||
async function saveProjectConfig(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Save project config" }).click();
|
||||
}
|
||||
|
||||
async function expectProjectConfigSaved(project: ProjectsSettingsProject): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
@@ -116,7 +139,150 @@ test.describe("Projects settings", () => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await editWorktreeSetup(page, updatedSetup);
|
||||
await saveProjectConfig(page);
|
||||
await clickSaveProjectSettings(page);
|
||||
await expectProjectConfigSaved(editableProject);
|
||||
});
|
||||
|
||||
test("user edits worktree setup on a non-GitHub remote project", async ({
|
||||
page,
|
||||
gitlabRemoteProject,
|
||||
}) => {
|
||||
expect(gitlabRemoteProject.name).toBe("acme/app");
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, gitlabRemoteProject.name);
|
||||
await editWorktreeSetup(page, updatedSetup);
|
||||
await clickSaveProjectSettings(page);
|
||||
await expectProjectConfigSaved(gitlabRemoteProject);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Projects settings — error UX", () => {
|
||||
test("stale-write callout appears on save, disables save, and reload clears it", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
// Bump the file on disk so the daemon detects a revision mismatch on save.
|
||||
await bumpPaseoConfigOnDisk(editableProject.path);
|
||||
|
||||
await clickSaveProjectSettings(page);
|
||||
|
||||
await expectProjectSettingsError(page, "stale");
|
||||
await expectSaveButtonDisabled(page);
|
||||
|
||||
await clickReloadProjectSettings(page);
|
||||
|
||||
await expectNoProjectSettingsError(page, "stale");
|
||||
await expectProjectSettingsFormVisible(page);
|
||||
});
|
||||
|
||||
test("invalid paseo.json shows read-error callout, reload after fix shows form", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await corruptPaseoConfig(editableProject.path);
|
||||
|
||||
await openProjects(page);
|
||||
await navigateToProjectSettings(page, editableProject.name);
|
||||
|
||||
await expectProjectSettingsError(page, "invalid");
|
||||
await expectProjectSettingsFormHidden(page);
|
||||
|
||||
// Restore a valid config so the reload succeeds.
|
||||
await restorePaseoConfig(editableProject.path, initialPaseoConfig);
|
||||
|
||||
await clickReloadProjectSettings(page);
|
||||
|
||||
await expectNoProjectSettingsError(page, "invalid");
|
||||
await expectProjectSettingsFormVisible(page);
|
||||
});
|
||||
|
||||
test("write_failed callout appears on save with blocked directory, retry re-attempts, reload clears it", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
await blockPaseoConfigWrites(editableProject.path);
|
||||
|
||||
await clickSaveProjectSettings(page);
|
||||
|
||||
await expectProjectSettingsError(page, "write_failed");
|
||||
await expectWriteFailedCalloutActions(page);
|
||||
|
||||
await clickRetryProjectSettingsSave(page);
|
||||
await expectProjectSettingsError(page, "write_failed");
|
||||
|
||||
await unblockPaseoConfigWrites(editableProject.path);
|
||||
await clickReloadProjectSettings(page);
|
||||
await expectNoProjectSettingsError(page, "write_failed");
|
||||
await expectProjectSettingsFormVisible(page);
|
||||
});
|
||||
|
||||
test("read-transport failure shows callout, reload recovers", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
// Drop the WS connection the moment a read_project_config_request is sent.
|
||||
// Subsequent connections are proxied transparently so Reload can succeed.
|
||||
await installReadTransportFailure(page);
|
||||
|
||||
await openProjects(page);
|
||||
await navigateToProjectSettings(page, editableProject.name);
|
||||
|
||||
await expectProjectSettingsError(page, "transport");
|
||||
await expectProjectSettingsFormHidden(page);
|
||||
|
||||
// The client reconnects after a ~1.5 s backoff; retry Reload until refetch succeeds.
|
||||
await expect(async () => {
|
||||
await clickReloadProjectSettings(page);
|
||||
await expectNoProjectSettingsError(page, "transport", 3_000);
|
||||
}).toPass({ timeout: 15_000 });
|
||||
await expectProjectSettingsFormVisible(page);
|
||||
});
|
||||
|
||||
test("project settings shows no-target state when daemon connection drops", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
const gate = await installDaemonConnectionGate(page);
|
||||
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
// Closing with code 1001 (Going Away) transitions DaemonClient to "error" state.
|
||||
// The NoEditableTarget UI renders via isHostGone check regardless of state.
|
||||
await gate.drop();
|
||||
|
||||
await expectNoEditableTarget(page);
|
||||
});
|
||||
|
||||
test("single-host project renders static host indicator, not a picker chip", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
await expectHostIndicatorVisible(page);
|
||||
await expectHostPickerHidden(page);
|
||||
});
|
||||
|
||||
test("script removal via kebab menu removes the row from the form", async ({
|
||||
page,
|
||||
editableProject,
|
||||
}) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
|
||||
await expectScriptRowCount(page, 1);
|
||||
|
||||
await removeProjectScript(page, "dev");
|
||||
|
||||
await expectScriptRowCount(page, 0);
|
||||
await expectEmptyScriptList(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
|
||||
import {
|
||||
expectSettingsHeader,
|
||||
openSettingsHost,
|
||||
expectHostLabelDisplayed,
|
||||
clickEditHostLabel,
|
||||
expectHostLabelEditMode,
|
||||
expectHostConnectionsCard,
|
||||
expectHostInjectMcpCard,
|
||||
expectHostActionCards,
|
||||
expectHostNoLocalOnlyRows,
|
||||
expectRetiredSidebarSectionsAbsent,
|
||||
expectHostPageVisible,
|
||||
expectLocalHostEntryFirst,
|
||||
} from "./helpers/settings";
|
||||
|
||||
function getSeededServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
@@ -18,17 +32,6 @@ function getSeededDaemonPort(): string {
|
||||
return port;
|
||||
}
|
||||
|
||||
async function openHostPage(page: Page, serverId: string) {
|
||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectHostLabelHeader(page: Page) {
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL);
|
||||
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||
}
|
||||
|
||||
test.describe("Settings host page", () => {
|
||||
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
|
||||
page,
|
||||
@@ -38,28 +41,13 @@ test.describe("Settings host page", () => {
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostPage(page, serverId);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
// Label renders in the detail header with a pencil edit affordance; the input is hidden until edit.
|
||||
await expectHostLabelHeader(page);
|
||||
|
||||
// Connections is its own section with a "Connections" heading and the seeded endpoint row.
|
||||
const connectionsCard = page.getByTestId("host-page-connections-card");
|
||||
await expect(connectionsCard).toBeVisible();
|
||||
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
connectionsCard.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
|
||||
).toBeVisible();
|
||||
|
||||
const injectMcpCard = page.getByTestId("host-page-inject-mcp-card");
|
||||
await expect(injectMcpCard).toBeVisible();
|
||||
await expect(injectMcpCard.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
|
||||
await expectSettingsHeader(page, TEST_HOST_LABEL);
|
||||
await expectHostLabelDisplayed(page);
|
||||
await expectHostConnectionsCard(page, port);
|
||||
await expectHostInjectMcpCard(page);
|
||||
await expectHostActionCards(page);
|
||||
});
|
||||
|
||||
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
|
||||
@@ -67,15 +55,11 @@ test.describe("Settings host page", () => {
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostPage(page, serverId);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("host-page-label-edit-button").click();
|
||||
|
||||
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveValue(TEST_HOST_LABEL);
|
||||
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
|
||||
await expectHostLabelDisplayed(page);
|
||||
await clickEditHostLabel(page);
|
||||
await expectHostLabelEditMode(page, TEST_HOST_LABEL);
|
||||
});
|
||||
|
||||
test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
|
||||
@@ -85,31 +69,17 @@ test.describe("Settings host page", () => {
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostPage(page, serverId);
|
||||
await openSettingsHost(page, serverId);
|
||||
|
||||
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
|
||||
// Pair-device now lives behind a row that only the local host sees
|
||||
// (gated by useIsLocalDaemon); the seeded host is remote, so it must
|
||||
// not appear. The daemon-lifecycle card is still local-host only.
|
||||
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
|
||||
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
|
||||
await expectHostNoLocalOnlyRows(page);
|
||||
});
|
||||
|
||||
test("settings sidebar does not expose retired top-level sections", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
|
||||
|
||||
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
|
||||
await expectRetiredSidebarSectionsAbsent(page);
|
||||
});
|
||||
|
||||
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({
|
||||
@@ -120,9 +90,10 @@ test.describe("Settings host page", () => {
|
||||
await gotoAppShell(page);
|
||||
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
|
||||
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
await expectHostLabelHeader(page);
|
||||
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
||||
await expectHostPageVisible(page, serverId);
|
||||
await expectSettingsHeader(page, TEST_HOST_LABEL);
|
||||
await expectHostLabelDisplayed(page);
|
||||
await expectHostActionCards(page);
|
||||
});
|
||||
|
||||
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
|
||||
@@ -164,18 +135,6 @@ test.describe("Settings host page", () => {
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const hostEntries = sidebar.locator('[data-testid^="settings-host-entry-"]');
|
||||
await expect(hostEntries.first()).toHaveAttribute(
|
||||
"data-testid",
|
||||
`settings-host-entry-${serverId}`,
|
||||
);
|
||||
|
||||
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
|
||||
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
|
||||
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
|
||||
await expectLocalHostEntryFirst(page, serverId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,72 +1,95 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function clickSidebarSection(page: Page, label: string) {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible();
|
||||
await sidebar.getByRole("button", { name: label, exact: true }).click();
|
||||
}
|
||||
import {
|
||||
openSettingsSection,
|
||||
expectSettingsHeader,
|
||||
openAddHostFlow,
|
||||
selectHostConnectionType,
|
||||
toggleHostAdvanced,
|
||||
openCompactSettings,
|
||||
expectCompactSettingsList,
|
||||
expectSettingsSidebarVisible,
|
||||
expectSettingsSidebarHidden,
|
||||
expectSettingsSidebarSections,
|
||||
goBackInSettings,
|
||||
expectSettingsBackButton,
|
||||
clickSettingsBackToWorkspace,
|
||||
verifyLegacyHostSettingsRedirect,
|
||||
openCompactSettingsHost,
|
||||
expectAddHostMethodOptions,
|
||||
fillDirectHostUri,
|
||||
expectDirectHostFormValues,
|
||||
expectDirectHostSslEnabled,
|
||||
expectDirectHostUriValue,
|
||||
expectDirectHostUriHidden,
|
||||
expectDiagnosticsContent,
|
||||
expectAboutContent,
|
||||
expectGeneralContent,
|
||||
} from "./helpers/settings";
|
||||
|
||||
test.describe("Settings sidebar navigation", () => {
|
||||
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
await clickSidebarSection(page, "Diagnostics");
|
||||
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("Diagnostics");
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expectSettingsHeader(page, "Diagnostics");
|
||||
await expectDiagnosticsContent(page);
|
||||
|
||||
await clickSidebarSection(page, "About");
|
||||
await expect(page).toHaveURL(/\/settings\/about$/);
|
||||
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("About");
|
||||
await openSettingsSection(page, "about");
|
||||
await expectSettingsHeader(page, "About");
|
||||
await expectAboutContent(page);
|
||||
|
||||
await clickSidebarSection(page, "General");
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("General");
|
||||
await openSettingsSection(page, "general");
|
||||
await expectSettingsHeader(page, "General");
|
||||
await expectGeneralContent(page);
|
||||
});
|
||||
|
||||
test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||
);
|
||||
await verifyLegacyHostSettingsRedirect(page);
|
||||
});
|
||||
|
||||
test("the + Add host button opens the add-host method modal", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openAddHostFlow(page);
|
||||
await expectAddHostMethodOptions(page);
|
||||
});
|
||||
|
||||
await page.getByTestId("settings-add-host").click();
|
||||
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Direct connection" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
|
||||
test("direct connection advanced URI round-trips SSL and password into the form", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openAddHostFlow(page);
|
||||
await selectHostConnectionType(page, "direct");
|
||||
|
||||
await toggleHostAdvanced(page);
|
||||
await fillDirectHostUri(page, "tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
|
||||
await toggleHostAdvanced(page);
|
||||
|
||||
await expectDirectHostFormValues(page, {
|
||||
host: "example.paseo.test",
|
||||
port: "7443",
|
||||
password: "shared-secret",
|
||||
});
|
||||
await expectDirectHostSslEnabled(page);
|
||||
await expectDirectHostUriHidden(page);
|
||||
|
||||
await toggleHostAdvanced(page);
|
||||
await expectDirectHostUriValue(
|
||||
page,
|
||||
"tcp://example.paseo.test:7443?ssl=true&password=shared-secret",
|
||||
);
|
||||
await toggleHostAdvanced(page);
|
||||
await expectDirectHostUriHidden(page);
|
||||
});
|
||||
|
||||
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const backRow = page.getByTestId("settings-back-to-workspace");
|
||||
await expect(backRow).toBeVisible();
|
||||
|
||||
await backRow.click();
|
||||
await clickSettingsBackToWorkspace(page);
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
});
|
||||
@@ -74,113 +97,57 @@ test.describe("Settings sidebar navigation", () => {
|
||||
test.describe("Settings — compact master-detail", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
async function openCompactSettingsRoot(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
// Wait for bootstrap to settle on a host route so storeReady is true before
|
||||
// we navigate into the protected settings stack. A direct page.goto("/settings")
|
||||
// on a cold load is eaten by Stack.Protected while bootstrap is still running.
|
||||
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
|
||||
|
||||
// Drive navigation the same way a user would: open the mobile drawer and tap
|
||||
// the Settings footer icon. This preserves the in-app router state instead of
|
||||
// triggering a full reload through Stack.Protected.
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(sidebarSettingsButton).toBeVisible();
|
||||
await sidebarSettingsButton.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectCompactSettingsRootList(page: Page) {
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
|
||||
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
|
||||
await openCompactSettingsRoot(page);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
|
||||
// Sidebar rows are present.
|
||||
await expect(
|
||||
page.getByTestId("settings-sidebar").getByRole("button", { name: "General", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("settings-sidebar")
|
||||
.getByRole("button", { name: "Diagnostics", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("settings-sidebar").getByRole("button", { name: "About", exact: true }),
|
||||
).toBeVisible();
|
||||
await expectSettingsSidebarSections(page, ["general", "diagnostics", "about"]);
|
||||
await expectCompactSettingsList(page);
|
||||
|
||||
// Section detail content is NOT rendered at the root.
|
||||
await expectCompactSettingsRootList(page);
|
||||
|
||||
const rootBackButton = page.getByRole("button", { name: "Back", exact: true });
|
||||
await expect(rootBackButton).toBeVisible();
|
||||
await rootBackButton.click();
|
||||
await expectSettingsBackButton(page);
|
||||
await goBackInSettings(page);
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
|
||||
await openCompactSettingsRoot(page);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
|
||||
await page
|
||||
.getByTestId("settings-sidebar")
|
||||
.getByRole("button", { name: "Diagnostics", exact: true })
|
||||
.click();
|
||||
await openSettingsSection(page, "diagnostics");
|
||||
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
||||
// Sidebar is no longer visible — we are on a detail screen.
|
||||
// (Expo Router stack keeps the previous screen in the DOM but hidden; check
|
||||
// only visible instances.)
|
||||
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||
await expectDiagnosticsContent(page);
|
||||
await expectSettingsSidebarHidden(page);
|
||||
await expectSettingsBackButton(page);
|
||||
});
|
||||
|
||||
test("back from a section detail returns to the /settings list", async ({ page }) => {
|
||||
await openCompactSettingsRoot(page);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
|
||||
await page
|
||||
.getByTestId("settings-sidebar")
|
||||
.getByRole("button", { name: "About", exact: true })
|
||||
.click();
|
||||
await openSettingsSection(page, "about");
|
||||
await expect(page).toHaveURL(/\/settings\/about$/);
|
||||
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await expectCompactSettingsRootList(page);
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||
await goBackInSettings(page);
|
||||
await expectCompactSettingsList(page);
|
||||
await expectSettingsBackButton(page);
|
||||
});
|
||||
|
||||
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openCompactSettingsRoot(page);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
|
||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||
);
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
|
||||
await openCompactSettingsHost(page);
|
||||
await expectSettingsBackButton(page);
|
||||
await expectSettingsSidebarHidden(page);
|
||||
});
|
||||
|
||||
test("back from a host detail returns to the /settings list", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openCompactSettingsRoot(page);
|
||||
await gotoAppShell(page);
|
||||
await openCompactSettings(page);
|
||||
|
||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await openCompactSettingsHost(page);
|
||||
await goBackInSettings(page);
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await expectSettingsSidebarVisible(page);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { expect, test } from "./fixtures";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
connectArchiveTabDaemonClient,
|
||||
createIdleAgent,
|
||||
openWorkspaceWithAgents,
|
||||
} from "./helpers/archive-tab";
|
||||
import { getActiveTabTestId, waitForTabBar } from "./helpers/launcher";
|
||||
import { waitForTabBar, expectAgentTabActive } from "./helpers/launcher";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
function getServerId(): string {
|
||||
@@ -17,12 +17,15 @@ function getServerId(): string {
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function pressSettingsToggleShortcut(page: Page) {
|
||||
async function pressSettingsToggleShortcut(page: import("@playwright/test").Page) {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+Comma`);
|
||||
}
|
||||
|
||||
async function expectSendBehavior(page: Page, expected: "interrupt" | "queue") {
|
||||
async function expectSendBehavior(
|
||||
page: import("@playwright/test").Page,
|
||||
expected: "interrupt" | "queue",
|
||||
) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
|
||||
@@ -35,12 +38,11 @@ async function expectSendBehavior(page: Page, expected: "interrupt" | "queue") {
|
||||
}
|
||||
|
||||
async function openAgentRouteAndExpectFocused(input: {
|
||||
page: Page;
|
||||
page: import("@playwright/test").Page;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
}) {
|
||||
const expectedActiveTabId = `workspace-tab-agent_${input.agentId}`;
|
||||
await input.page.goto(
|
||||
buildHostAgentDetailRoute(input.serverId, input.agentId, input.workspaceId),
|
||||
);
|
||||
@@ -49,10 +51,7 @@ async function openAgentRouteAndExpectFocused(input: {
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await waitForTabBar(input.page);
|
||||
await expect(
|
||||
input.page.getByTestId(expectedActiveTabId).filter({ visible: true }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await expect(getActiveTabTestId(input.page)).resolves.toBe(expectedActiveTabId);
|
||||
await expectAgentTabActive(input.page, input.agentId);
|
||||
}
|
||||
|
||||
test.describe("Settings toggle tab regression", () => {
|
||||
@@ -79,13 +78,7 @@ test.describe("Settings toggle tab regression", () => {
|
||||
|
||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||
await waitForTabBar(page);
|
||||
|
||||
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
|
||||
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||
await expectAgentTabActive(page, secondAgent.id);
|
||||
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
@@ -98,19 +91,11 @@ test.describe("Settings toggle tab regression", () => {
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path));
|
||||
await waitForTabBar(page);
|
||||
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||
await expectAgentTabActive(page, secondAgent.id);
|
||||
|
||||
await page.reload();
|
||||
await waitForTabBar(page);
|
||||
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||
await expectAgentTabActive(page, secondAgent.id);
|
||||
} finally {
|
||||
for (const agentId of agentIds) {
|
||||
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
||||
@@ -152,14 +137,10 @@ test.describe("Settings toggle tab regression", () => {
|
||||
agentId: secondAgent.id,
|
||||
});
|
||||
|
||||
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
await page.reload();
|
||||
await waitForTabBar(page);
|
||||
await expect(
|
||||
page.getByTestId(expectedActiveTabId).filter({ visible: true }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||
await expectAgentTabActive(page, secondAgent.id);
|
||||
}
|
||||
} finally {
|
||||
for (const agentId of agentIds) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user