mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
1544 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
283390e6d5 | ||
|
|
dcb04a4802 | ||
|
|
0dfd909646 | ||
|
|
00219cfd04 | ||
|
|
fbf10ba4c6 | ||
|
|
2a4945ad0b | ||
|
|
3ba4d09294 | ||
|
|
013e53881b | ||
|
|
9fb87b9d86 | ||
|
|
b557688ab3 | ||
|
|
599fcb34c5 | ||
|
|
5a777169fd | ||
|
|
470368d910 | ||
|
|
e41fee0366 | ||
|
|
25eb26db0f | ||
|
|
932a97e447 | ||
|
|
3415a77497 | ||
|
|
814c0660ab | ||
|
|
01728e7c22 | ||
|
|
b58e62136e | ||
|
|
7ccf97eb2f | ||
|
|
f4fded1472 | ||
|
|
b23e60a8f4 | ||
|
|
ea72c72998 | ||
|
|
cfe140deb8 | ||
|
|
e13634380c | ||
|
|
120c374361 | ||
|
|
ccf248c332 | ||
|
|
05d279cc65 | ||
|
|
221b76bf52 | ||
|
|
b090b4d237 | ||
|
|
c78c30ef54 | ||
|
|
34626e3b8f | ||
|
|
ee5a23d962 | ||
|
|
ec4f569f6e | ||
|
|
304e6d701a | ||
|
|
0245fa59f2 | ||
|
|
e250024b03 | ||
|
|
40ce317ac2 | ||
|
|
88f382cad3 | ||
|
|
2ba523ce05 | ||
|
|
7827adeaf0 | ||
|
|
7212cc2596 | ||
|
|
3b66b25f67 | ||
|
|
e9ba84ec65 | ||
|
|
df49fbe0ac | ||
|
|
15df680a64 | ||
|
|
90032570ec | ||
|
|
1e5722c996 | ||
|
|
175685a912 | ||
|
|
29689b15f0 | ||
|
|
0daf3c209b | ||
|
|
08fbddae4a | ||
|
|
d7659d3f0c | ||
|
|
e8843a2875 | ||
|
|
8a025a6c78 | ||
|
|
16cf9edfb1 | ||
|
|
eb9492f9b9 | ||
|
|
5e7a51364d | ||
|
|
d672bc19de | ||
|
|
c5c03ff956 | ||
|
|
f79af3ffbf | ||
|
|
f27598af98 | ||
|
|
b7ee28659f | ||
|
|
c69b42ac10 | ||
|
|
151a2c513a | ||
|
|
01f89c7c36 | ||
|
|
941b4fb760 | ||
|
|
a6b89bfdf7 | ||
|
|
291a124663 | ||
|
|
88ea88162c | ||
|
|
35de4b26e8 | ||
|
|
feeb6c2aa6 | ||
|
|
7f2592041b | ||
|
|
712cb8fc86 | ||
|
|
219050a7ab | ||
|
|
18903bcecb | ||
|
|
a2c68cc689 | ||
|
|
843d9234bd | ||
|
|
5b47b6594f | ||
|
|
521944f19b | ||
|
|
1ec448d2b9 | ||
|
|
b7310b9e90 | ||
|
|
f9fcd6e032 | ||
|
|
cfc27f2576 | ||
|
|
4dd3b0006b | ||
|
|
a8ffbc8003 | ||
|
|
4b3603604a | ||
|
|
ae3e88e7e1 | ||
|
|
de42858213 | ||
|
|
db8934c53e | ||
|
|
4b7199f53b | ||
|
|
d3f9655e33 | ||
|
|
2b37276577 | ||
|
|
83689ef0c5 | ||
|
|
9a4528129f | ||
|
|
231ebdcfb1 | ||
|
|
ef418d5292 | ||
|
|
d0f7d93fc4 | ||
|
|
32d9147499 | ||
|
|
ad0e754cc1 | ||
|
|
d152b4f055 | ||
|
|
c92e4e46d2 | ||
|
|
691e2ee7e6 | ||
|
|
6b84c8963d | ||
|
|
0c3d15ffb9 | ||
|
|
480b18c500 | ||
|
|
f9e18424e9 | ||
|
|
52eb307e8c | ||
|
|
50a12e2140 | ||
|
|
d118e2349e | ||
|
|
446dd9cfe6 | ||
|
|
5b2f3fd830 | ||
|
|
2dd8c27573 | ||
|
|
dc92863c35 | ||
|
|
ba9ed8b995 | ||
|
|
2f381a76ed | ||
|
|
cda3d1d46c | ||
|
|
731c570b25 | ||
|
|
177153e5e8 | ||
|
|
9c88850de0 | ||
|
|
ce4b0d54c7 | ||
|
|
9b25f8fc36 | ||
|
|
26e6d97e81 | ||
|
|
80102753e1 | ||
|
|
75b8ae640c | ||
|
|
e521e1f79d | ||
|
|
a62c7471d2 | ||
|
|
3edf812578 | ||
|
|
dfc8223bcc | ||
|
|
083cd34db4 | ||
|
|
9abc0217e9 | ||
|
|
576cc0b668 | ||
|
|
e21ef2fc9b | ||
|
|
c8b61f7ddc | ||
|
|
9c5974735e | ||
|
|
f65f82bac9 | ||
|
|
9a35f35bab | ||
|
|
8cc7e8951c | ||
|
|
38a41a71ff | ||
|
|
ce6393a933 | ||
|
|
339a0b6b43 | ||
|
|
1f7b4eb10f | ||
|
|
3560fb58bb | ||
|
|
4140e640fe | ||
|
|
434b28fc8e | ||
|
|
1f8f8e4036 | ||
|
|
b133285f6d | ||
|
|
4d5889ff38 | ||
|
|
de57f301f9 | ||
|
|
95c773ce6d | ||
|
|
c6680b3001 | ||
|
|
484e453401 | ||
|
|
4b867c2f41 | ||
|
|
368ce0339a | ||
|
|
8e63274ce6 | ||
|
|
d80c524e0e | ||
|
|
89efb007f9 | ||
|
|
407b4b68b0 | ||
|
|
637109eab0 | ||
|
|
3a0161d051 | ||
|
|
027b3839fc | ||
|
|
c3589bdc84 | ||
|
|
a5e6cc2d2b | ||
|
|
713c90ea42 | ||
|
|
e51fdada51 | ||
|
|
dd98ae9359 | ||
|
|
274a7fd217 | ||
|
|
a582fccef0 | ||
|
|
019bf65790 | ||
|
|
9218f70df3 | ||
|
|
fd05bf8bff | ||
|
|
9e3d8f0ec5 | ||
|
|
43b9123c5c | ||
|
|
b6afc66131 | ||
|
|
7993dc02dc | ||
|
|
6601db2673 | ||
|
|
e4badd95f4 | ||
|
|
02fc354686 | ||
|
|
aef54d5180 | ||
|
|
3c5bfd6e3d | ||
|
|
f422dabf1e | ||
|
|
2706d5e934 | ||
|
|
6b210df471 | ||
|
|
9a39ba4be0 | ||
|
|
fc86ce2a5f | ||
|
|
6edf19c7c6 | ||
|
|
80c319dc49 | ||
|
|
77c8c91ab5 | ||
|
|
6503245493 | ||
|
|
184902dd0d | ||
|
|
42cdabdf57 | ||
|
|
247983ee87 | ||
|
|
7b9cc3d423 | ||
|
|
8ee3c6ae1d | ||
|
|
244c40a62f | ||
|
|
3fe63d5a2c | ||
|
|
1582028902 | ||
|
|
7e7cc1ad56 | ||
|
|
27ddfb1ea7 | ||
|
|
807cce6ad9 | ||
|
|
73b28a7f60 | ||
|
|
8dad423635 | ||
|
|
abaa29e73c | ||
|
|
ae2a301cb6 | ||
|
|
006d57d546 | ||
|
|
a201e5a853 | ||
|
|
c402db8b1c | ||
|
|
6a03873d81 | ||
|
|
39db3ea5ff | ||
|
|
79bf5ac3c7 | ||
|
|
051507717c | ||
|
|
f3cb19529c | ||
|
|
cea97c83b3 | ||
|
|
3e2faab7f8 | ||
|
|
ebf8a21c08 | ||
|
|
515ba62be6 | ||
|
|
32a20cbeea | ||
|
|
9877936d13 | ||
|
|
1a473ed7f3 | ||
|
|
3832053736 | ||
|
|
b01e274cce | ||
|
|
92e701be8f | ||
|
|
c2c990e763 | ||
|
|
76b9f89f68 | ||
|
|
970bf828b3 | ||
|
|
dfc814147a | ||
|
|
831d990ef7 | ||
|
|
e09fd41adc | ||
|
|
8d32167826 | ||
|
|
1cfae98bf5 | ||
|
|
4cbfe95473 | ||
|
|
242f61937a | ||
|
|
d913c4a9b7 | ||
|
|
2cd296e38f | ||
|
|
95c1d1fe0f | ||
|
|
33ac657fed | ||
|
|
067125bd4f | ||
|
|
93095aa1d2 | ||
|
|
ac45f23213 | ||
|
|
b91749a316 | ||
|
|
a6423f7f0a | ||
|
|
403fb03c97 | ||
|
|
f8411d121e | ||
|
|
9ea75afd37 | ||
|
|
006894274e | ||
|
|
951fadc104 | ||
|
|
8600f183b1 | ||
|
|
002946d4dd | ||
|
|
fb02bc8795 | ||
|
|
53baa16bee | ||
|
|
67eddbd6a9 | ||
|
|
044546ae0c | ||
|
|
bd748b6edc | ||
|
|
427664dfa7 | ||
|
|
a0169bf7f4 | ||
|
|
79968ee02d | ||
|
|
afe0eaab5b | ||
|
|
04465f124c | ||
|
|
86b83c3366 | ||
|
|
4a5dd986e1 | ||
|
|
64065061c5 | ||
|
|
7b9636a22d | ||
|
|
8165de7b06 | ||
|
|
75f83b0b9b | ||
|
|
badec6f587 | ||
|
|
5a2708653c | ||
|
|
06b3fc2ec6 | ||
|
|
bd1fb311ea | ||
|
|
8991d34f78 | ||
|
|
128d6668ae | ||
|
|
d9d6509880 | ||
|
|
2d9cbb0453 | ||
|
|
e373c33108 | ||
|
|
6a36db9924 | ||
|
|
d3393b434a | ||
|
|
43bbf8cb12 | ||
|
|
cfa3b009e3 | ||
|
|
f411a2a383 | ||
|
|
c4d84ff2a3 | ||
|
|
73b0b60bdd | ||
|
|
5a232d8cf1 | ||
|
|
2238171955 | ||
|
|
fbc2371b95 | ||
|
|
f8815bf1c2 | ||
|
|
93cda8e60f | ||
|
|
c53d42acf8 | ||
|
|
2a519d96e5 | ||
|
|
07714773cc | ||
|
|
ada623c1c6 | ||
|
|
9fb0ab9b2d | ||
|
|
83bbc8f659 | ||
|
|
7819019cea | ||
|
|
23b20169ef | ||
|
|
e5b0ea6d1b | ||
|
|
4de4e90325 | ||
|
|
fa41ba76cd | ||
|
|
9f6570ca3e | ||
|
|
ccb956f98f | ||
|
|
f483e62231 | ||
|
|
c0bb34c514 | ||
|
|
6b5282ad43 | ||
|
|
72e4476acc | ||
|
|
5d76cc8840 | ||
|
|
b0e0786419 | ||
|
|
4d6a154f6e | ||
|
|
5c3cc99d8c | ||
|
|
14cc1cc83c | ||
|
|
82f97e942d | ||
|
|
59d9b2ebdd | ||
|
|
d8ff049aff | ||
|
|
f08bff1b2d | ||
|
|
63a46acaf1 | ||
|
|
aac780ddf7 | ||
|
|
49d5426059 | ||
|
|
d6585f7c3a | ||
|
|
ed2dc08443 | ||
|
|
4b82ca77c8 | ||
|
|
07d10f55f9 | ||
|
|
13bfdd8cc1 | ||
|
|
9a8b01a738 | ||
|
|
adf2f5921f | ||
|
|
3838a8b969 | ||
|
|
11524dc349 | ||
|
|
a001c72726 | ||
|
|
8344296789 | ||
|
|
40f8a8414a | ||
|
|
9386901896 | ||
|
|
6390f8d995 | ||
|
|
daefa8fb7f | ||
|
|
981b94fa56 | ||
|
|
827092a246 | ||
|
|
213767e3d5 | ||
|
|
6ffffc38a0 | ||
|
|
270c12d524 | ||
|
|
53ed34bacf | ||
|
|
e6bf64aed8 | ||
|
|
a5582e0ee0 | ||
|
|
754f1facb6 | ||
|
|
727aa0cdac | ||
|
|
a387eaf26a | ||
|
|
a11c246bdc | ||
|
|
2e95003154 | ||
|
|
1b4483615a | ||
|
|
4d0fe57a7a | ||
|
|
84db0cecd7 | ||
|
|
14ac6984f3 | ||
|
|
b6147e39ab | ||
|
|
a2b743a6d3 | ||
|
|
7442323ca2 | ||
|
|
086b3098b1 | ||
|
|
e0f82e7ad0 | ||
|
|
9cee1d8196 | ||
|
|
c2a8e56d33 | ||
|
|
de33dfd034 | ||
|
|
917c2f56f4 | ||
|
|
a3822ca299 | ||
|
|
41fd45588f | ||
|
|
54b8126deb | ||
|
|
e70fa2dc84 | ||
|
|
cbb690efd0 | ||
|
|
7c50b7a080 | ||
|
|
21742bbeb4 | ||
|
|
11b407c804 | ||
|
|
814098852c | ||
|
|
5c6f175db5 | ||
|
|
278acf5fe1 | ||
|
|
165ae58f75 | ||
|
|
a1f3256923 | ||
|
|
86bb5cc827 | ||
|
|
483312e1dc | ||
|
|
0b3c29b2ec | ||
|
|
1885d8602f | ||
|
|
57312d4f75 | ||
|
|
ffbb2ffd06 | ||
|
|
45a5ba8637 | ||
|
|
b8ade39f73 | ||
|
|
deca82653e | ||
|
|
4e9d80d118 | ||
|
|
5a20e78dff | ||
|
|
e6044264e2 | ||
|
|
beeb315c8d | ||
|
|
e635c6beca | ||
|
|
918949c7fa | ||
|
|
c1df523a77 | ||
|
|
60cc73f87e | ||
|
|
7a8f65805d | ||
|
|
43bec954ea | ||
|
|
751a1df682 | ||
|
|
79ddf6980a | ||
|
|
9d30b7e85a | ||
|
|
89b87c0235 | ||
|
|
fc96b260d1 | ||
|
|
3fe79535cc | ||
|
|
d40e4b6f0d | ||
|
|
8f04e65cf1 | ||
|
|
c5295a4ab3 | ||
|
|
f59af4c810 | ||
|
|
e012c8f52e | ||
|
|
312a6e22fe | ||
|
|
7f92f7f51d | ||
|
|
200453f032 | ||
|
|
d4a4015804 | ||
|
|
32b68cbcf4 | ||
|
|
c8cafbabff | ||
|
|
f0ac632732 | ||
|
|
25fd93d7e2 | ||
|
|
2090d685ed | ||
|
|
c70277f445 | ||
|
|
649eddeea8 | ||
|
|
30c7729a7a | ||
|
|
8651c334c7 | ||
|
|
d1702e8a40 | ||
|
|
80eb5dbe83 | ||
|
|
380c9927d7 | ||
|
|
345729c588 | ||
|
|
3cdadd362c | ||
|
|
5a836eb170 | ||
|
|
61c36b4e22 | ||
|
|
326dad93e4 | ||
|
|
c80d7a8165 | ||
|
|
89db6d3187 | ||
|
|
df0ce60a35 | ||
|
|
f21f861653 | ||
|
|
d55209b81e | ||
|
|
0f0efa1707 | ||
|
|
fb24029c69 | ||
|
|
7cba6cf414 | ||
|
|
16386ba6d7 | ||
|
|
9c90657d60 | ||
|
|
d0be7c3ee7 | ||
|
|
dbd1645dc9 | ||
|
|
dd99144587 | ||
|
|
dd6a396dbf | ||
|
|
1e0f62976d | ||
|
|
5241727b29 | ||
|
|
202fa3e8f3 | ||
|
|
7652ee10ca | ||
|
|
612979f95f | ||
|
|
d480101115 | ||
|
|
8d854a6342 | ||
|
|
795c3cac97 | ||
|
|
4d6774f0c2 | ||
|
|
d888da8591 | ||
|
|
5329a12222 | ||
|
|
74a65bd851 | ||
|
|
9c0b5616fe | ||
|
|
4d5e8d870a | ||
|
|
95ea02a87f | ||
|
|
c59a979446 | ||
|
|
6ed166641f | ||
|
|
e647c61fb5 | ||
|
|
19585709d5 | ||
|
|
369b46ea08 | ||
|
|
25ab97fe8b | ||
|
|
1f13eee4a7 | ||
|
|
cc6993042f | ||
|
|
3f0c9f2679 | ||
|
|
bc53cf0a04 | ||
|
|
367d945954 | ||
|
|
c54e2ccabe | ||
|
|
750d38d395 | ||
|
|
84600609f0 | ||
|
|
4190a0aa72 | ||
|
|
43a9606c55 | ||
|
|
e3962e1753 | ||
|
|
be3d5ed78d | ||
|
|
8b1fbb7b86 | ||
|
|
8d1def4615 | ||
|
|
7ad4482096 | ||
|
|
7ef47e9193 | ||
|
|
bdb300b919 | ||
|
|
0d0a8a791a | ||
|
|
4806cd9a51 | ||
|
|
61201d68a6 | ||
|
|
6dfcb4daa9 | ||
|
|
95e112c185 | ||
|
|
6f28027687 | ||
|
|
8122d501b0 | ||
|
|
8023e1f0c3 | ||
|
|
a2d652d8d8 | ||
|
|
bf76915884 | ||
|
|
f0c59ec534 | ||
|
|
8aa1833e2b | ||
|
|
b3d5401295 | ||
|
|
8008ea69dd | ||
|
|
42aaa4e418 | ||
|
|
0f7fa55a0f | ||
|
|
964bf3e13f | ||
|
|
e6ff15a1e0 | ||
|
|
80a7fd4f3e | ||
|
|
4547d597ef | ||
|
|
ec445e606c | ||
|
|
0095f61121 | ||
|
|
609d11ee8d | ||
|
|
a161a12a42 | ||
|
|
39b56af47b | ||
|
|
64b9b61223 | ||
|
|
7c18170d6a | ||
|
|
ca20945b93 | ||
|
|
cd4db5d81f | ||
|
|
d1fc271ad4 | ||
|
|
266eb375ff | ||
|
|
a9dcef80ed | ||
|
|
ae03e8283a | ||
|
|
5aa98f13f2 | ||
|
|
698a18e88f | ||
|
|
44cc7a3771 | ||
|
|
acb294b9eb | ||
|
|
cde10485d4 | ||
|
|
40fc81bbbc | ||
|
|
0d340959d7 | ||
|
|
b9269df3e4 | ||
|
|
dbc525c2ad | ||
|
|
56512d852b | ||
|
|
40e27f5a00 | ||
|
|
f35b0b8b7d | ||
|
|
fb5e2608e0 | ||
|
|
6d4cc53a0a | ||
|
|
149a415016 | ||
|
|
28f45a41a5 | ||
|
|
66bb8c6f04 | ||
|
|
fa552b0faa | ||
|
|
51e96fa303 | ||
|
|
9fd31bfa5a | ||
|
|
fdc8d3c8db | ||
|
|
aa1d33f0a0 | ||
|
|
6897a400e5 | ||
|
|
cff41d78ab | ||
|
|
4de2766114 | ||
|
|
3b8cfcbec6 | ||
|
|
30ffda6cae | ||
|
|
2bbdc7ed86 | ||
|
|
404b3b1dbf | ||
|
|
7f63fc6ceb | ||
|
|
944978ddb6 | ||
|
|
7a1e12f0c0 | ||
|
|
70985088f6 | ||
|
|
a27858e743 | ||
|
|
8789ef4ad8 | ||
|
|
a525b0e22e | ||
|
|
497159ada8 | ||
|
|
e22fb51975 | ||
|
|
321ffce6a3 | ||
|
|
a315021c75 | ||
|
|
ba1ecedca3 | ||
|
|
01af138939 | ||
|
|
faf85e038c | ||
|
|
d3cd46a810 | ||
|
|
05734e8b1b | ||
|
|
c2f3bb73a6 | ||
|
|
1ea5e5a769 | ||
|
|
78b7e51396 | ||
|
|
49e67363b2 | ||
|
|
b9a8ba054d | ||
|
|
2f77674c55 | ||
|
|
120c1b46a4 | ||
|
|
033c4bcbf2 | ||
|
|
0deaed3794 | ||
|
|
0f005172ce | ||
|
|
0e7dfcaf2a | ||
|
|
ebfad945e5 | ||
|
|
b5a0ee9954 | ||
|
|
8a9d738438 | ||
|
|
1d41a50e1f | ||
|
|
32fe4b3beb | ||
|
|
a692c616cb | ||
|
|
edd5503fe8 | ||
|
|
34bd8dfd1b | ||
|
|
18adfb4f1d | ||
|
|
4176bd8aa1 | ||
|
|
201eb6a671 | ||
|
|
9f09a19a09 | ||
|
|
edfd2564a3 | ||
|
|
c1ebb8c915 | ||
|
|
29af07e383 | ||
|
|
17bd036359 | ||
|
|
2683dae5f9 | ||
|
|
4215120d7c | ||
|
|
914a5dc6ae | ||
|
|
e6dec3fc1a | ||
|
|
b1b85833bb | ||
|
|
c7399084a4 | ||
|
|
a236bd4515 | ||
|
|
aa59e67df5 | ||
|
|
96f736fa6e | ||
|
|
1b309ae0c9 | ||
|
|
fa2f501dc7 | ||
|
|
02a7a170f6 | ||
|
|
56fdc47d9c | ||
|
|
74f7b9e416 | ||
|
|
ce77947654 | ||
|
|
0f0bfb551c | ||
|
|
f4b487df78 | ||
|
|
2a5ac7a013 | ||
|
|
4068554532 | ||
|
|
9c8bc2e208 | ||
|
|
d69d5c92ce | ||
|
|
b8e048c7fa | ||
|
|
75542c10d8 | ||
|
|
0e3e9bcd0c | ||
|
|
4786bbbb0b | ||
|
|
4f58f889d6 | ||
|
|
d389200dc3 | ||
|
|
c1420aff3c | ||
|
|
9cb0358fae | ||
|
|
65b3779da5 | ||
|
|
7a9348a5fd | ||
|
|
397d454de0 | ||
|
|
30842398e7 | ||
|
|
b27c1b0729 | ||
|
|
fcf2c14485 | ||
|
|
57eafda6ef | ||
|
|
4925b94743 | ||
|
|
f0b09d90fd | ||
|
|
bf91ea2cc9 | ||
|
|
f83a4d08da | ||
|
|
83f540f10a | ||
|
|
602b548745 | ||
|
|
d98d5457ed | ||
|
|
6bf8da8087 | ||
|
|
d39414064e | ||
|
|
ffcc35485d | ||
|
|
8ff94dc03e | ||
|
|
dace4f862f | ||
|
|
fc667d6312 | ||
|
|
aae4d9f8dd | ||
|
|
5eb8b300a3 | ||
|
|
6c9a832906 | ||
|
|
35430dab52 | ||
|
|
0eac4bc4b3 | ||
|
|
635de3d76a | ||
|
|
931d3ba81f | ||
|
|
5cf806dc77 | ||
|
|
529e743f1e | ||
|
|
cefc189331 | ||
|
|
666d3fe807 | ||
|
|
6a4f439541 | ||
|
|
65f8823e4e | ||
|
|
ac5e6df6c9 | ||
|
|
bf7d3c2775 | ||
|
|
5c93fbc955 | ||
|
|
5ac7b3f7c7 | ||
|
|
c742f17080 | ||
|
|
b4d6a5d6b8 | ||
|
|
43f01600ce | ||
|
|
e7cf9ee69d | ||
|
|
9c8b0c3aca | ||
|
|
8088c39fd6 | ||
|
|
1279f1d556 | ||
|
|
0defbc1dc3 | ||
|
|
76c6253ae0 | ||
|
|
5ec25687cd | ||
|
|
33a1557aed | ||
|
|
cbc2ce06e9 | ||
|
|
82466aaa9f | ||
|
|
d3e3a83a0d | ||
|
|
ee611d65b6 | ||
|
|
5ce4562eed | ||
|
|
21c7761403 | ||
|
|
682fc54778 | ||
|
|
e06b691d5d | ||
|
|
022eb33234 | ||
|
|
161b2c2378 | ||
|
|
52dfdb1913 | ||
|
|
bdaa6b65aa | ||
|
|
1900f43049 | ||
|
|
29b6f2a86f | ||
|
|
638c208609 | ||
|
|
7b1144dafe | ||
|
|
940bc6243b | ||
|
|
51b83768c7 | ||
|
|
cdbaa8d29c | ||
|
|
b2229a28b9 | ||
|
|
d888c8f126 | ||
|
|
102ef06c30 | ||
|
|
5cb424b2e6 | ||
|
|
fd9dfb0cc8 | ||
|
|
03380cfad0 | ||
|
|
6ce0e1e91f | ||
|
|
64c2515b94 | ||
|
|
e90241c445 | ||
|
|
06fbeb413b | ||
|
|
390a3402ab | ||
|
|
27ddc95862 | ||
|
|
c63240b18c | ||
|
|
a5aca2312b | ||
|
|
e01a0abdf2 | ||
|
|
07618f248f | ||
|
|
7b19dd736f | ||
|
|
7173342bcb | ||
|
|
6e5cad812e | ||
|
|
5d1b3383d7 | ||
|
|
f75e28a77f | ||
|
|
eabd561fde | ||
|
|
339023548d | ||
|
|
6eb26f8c6c | ||
|
|
fc55eaf12b | ||
|
|
5d09f7449e | ||
|
|
dc772021a8 | ||
|
|
93b2199c56 | ||
|
|
8d4d445c85 | ||
|
|
d3e5d6929d | ||
|
|
6362d429c7 | ||
|
|
c6e87744f1 | ||
|
|
8f83876a3c | ||
|
|
25213ce83a | ||
|
|
ce821afd27 | ||
|
|
cc6b0f80be | ||
|
|
5f0b6e7146 | ||
|
|
76a9c6f950 | ||
|
|
4dca672711 | ||
|
|
8e482b1613 | ||
|
|
3397e6c589 | ||
|
|
c4cccf5bd2 | ||
|
|
0130a637c8 | ||
|
|
8c2ea33da8 | ||
|
|
0d88ce2270 | ||
|
|
6907f6e71d | ||
|
|
84638ecd9c | ||
|
|
809e5fbbdc | ||
|
|
2dd597f5f5 | ||
|
|
fcfb9c99da | ||
|
|
a264058a30 | ||
|
|
702e2e7db9 | ||
|
|
c32bc847bf | ||
|
|
7a2d976081 | ||
|
|
2d9ca6983a | ||
|
|
e44e495481 | ||
|
|
bedf792616 | ||
|
|
5db9942125 | ||
|
|
a889dbcc28 | ||
|
|
5c1e869bc5 | ||
|
|
a693ff560c | ||
|
|
89baf7ff38 | ||
|
|
c65a851205 | ||
|
|
85acdbb05e | ||
|
|
4e32f9b7bd | ||
|
|
744ca7a2bc | ||
|
|
3110bae209 | ||
|
|
16efdb2c95 | ||
|
|
1e9b7f1157 | ||
|
|
018ebd5f29 | ||
|
|
4706d9e3bd | ||
|
|
ba0c4a3fff | ||
|
|
2a8da5d4c1 | ||
|
|
43542ac858 | ||
|
|
fefe260f0a | ||
|
|
a17f7d2d20 | ||
|
|
c5a69a1ad9 | ||
|
|
155c88254b | ||
|
|
22c83118d0 | ||
|
|
7e99529bde | ||
|
|
a6f6c169c8 | ||
|
|
d69fcb861d | ||
|
|
5e1b24b217 | ||
|
|
2ca7dd71e2 | ||
|
|
449a4b48d2 | ||
|
|
c3313ff82a | ||
|
|
1182092b94 | ||
|
|
8d1fb45f73 | ||
|
|
8f7de021f4 | ||
|
|
65573499af | ||
|
|
9154f8fc4d | ||
|
|
3d3e327378 | ||
|
|
42cfed514c | ||
|
|
99114ddd11 | ||
|
|
e73c332e4f | ||
|
|
1594e41602 | ||
|
|
4896cfe970 | ||
|
|
dcffe46f90 | ||
|
|
7b4b42068c | ||
|
|
100502ae1e | ||
|
|
178b4cd618 | ||
|
|
9a6a8ce497 | ||
|
|
325ab500b3 | ||
|
|
60bbfdde25 | ||
|
|
71ce95de90 | ||
|
|
255c2665cc | ||
|
|
176942bbf1 | ||
|
|
1ae2f9280e | ||
|
|
49402b854f | ||
|
|
9476b3fc1d | ||
|
|
29037abd54 | ||
|
|
23ffec5072 | ||
|
|
1e64dd5509 | ||
|
|
cacbbf4053 | ||
|
|
4ca04fc661 | ||
|
|
f924d33076 | ||
|
|
763cfa9333 | ||
|
|
fd894dc3d7 | ||
|
|
9ea181a072 | ||
|
|
a96f2d7652 | ||
|
|
44da0c67b2 | ||
|
|
55c4e58aa3 | ||
|
|
a2b1498c3f | ||
|
|
df617a4c8f | ||
|
|
a64292f2b0 | ||
|
|
7ff5933b08 | ||
|
|
bb9ef76017 | ||
|
|
5d5a79a2a8 | ||
|
|
59bc3bd3d1 | ||
|
|
d6413404e0 | ||
|
|
8a585e60f2 | ||
|
|
1d795f6c32 | ||
|
|
9bd5f852e7 | ||
|
|
48516f0b9c | ||
|
|
0bf8e8b5b2 | ||
|
|
994ee488b9 | ||
|
|
a854096c35 | ||
|
|
5d89f9444a | ||
|
|
63905950cc | ||
|
|
ffd07ec17c | ||
|
|
2d63bc3893 | ||
|
|
86cee8e43a | ||
|
|
c4c17df022 | ||
|
|
34c27fe77e | ||
|
|
55acb8a539 | ||
|
|
b730d83943 | ||
|
|
4c52f272fd | ||
|
|
f1ebe518f8 | ||
|
|
68c8efc5d3 | ||
|
|
73e6a42141 | ||
|
|
060f3d457c | ||
|
|
a91f79053c | ||
|
|
7b4db04a81 | ||
|
|
ac9c2c5642 | ||
|
|
99200eabba | ||
|
|
5f2bb87a17 | ||
|
|
897c18dd5f | ||
|
|
51a865cd24 | ||
|
|
9dc3d116b4 | ||
|
|
a4326ec5c0 | ||
|
|
255270b5ee | ||
|
|
59cb4d7499 | ||
|
|
d75dd33773 | ||
|
|
f1ffecfcd4 | ||
|
|
a7d5ab2497 | ||
|
|
cc09b61b19 | ||
|
|
26d69e2006 | ||
|
|
4b7c623592 | ||
|
|
b91884bc54 | ||
|
|
963c79265a | ||
|
|
ade1e338ea | ||
|
|
c13972c835 | ||
|
|
d8d04c545e | ||
|
|
613450bac8 | ||
|
|
cafff08a30 | ||
|
|
cb60f2a596 | ||
|
|
58d72bea87 | ||
|
|
953f7898e7 | ||
|
|
fd06e109be | ||
|
|
ba1bb1646e | ||
|
|
3ee11efcd0 | ||
|
|
1930a8a2f2 | ||
|
|
f7fd41a5f8 | ||
|
|
3af5b0f031 | ||
|
|
cce8dee21c | ||
|
|
2d02db6ae0 | ||
|
|
66732a2f48 | ||
|
|
30ebd4b777 | ||
|
|
e95554d782 | ||
|
|
5cf8b7549d | ||
|
|
9c4dee5364 | ||
|
|
c635eabff3 | ||
|
|
51411182fe | ||
|
|
f53c770a71 | ||
|
|
8d55764313 | ||
|
|
4b12ebd5c0 | ||
|
|
27c8cfbd4b | ||
|
|
07b077f1a2 | ||
|
|
d8243bfb72 | ||
|
|
5b3ec572cb | ||
|
|
806079c7ad | ||
|
|
ee50d3b8d0 | ||
|
|
1f6ffa9e0e | ||
|
|
266d042528 | ||
|
|
4b93f990d2 | ||
|
|
e026223c80 | ||
|
|
2190910e6f | ||
|
|
87f297e755 | ||
|
|
b50b065bcc | ||
|
|
4e8e2589bf | ||
|
|
0d3f59feed | ||
|
|
8eddb2ee18 | ||
|
|
0ba2f6b194 | ||
|
|
5b22aedaff | ||
|
|
e3b6e2dcfa | ||
|
|
0d9bda12e6 | ||
|
|
a94bc0720c | ||
|
|
7504d20b67 | ||
|
|
db39417be9 | ||
|
|
e0e4f228eb | ||
|
|
f6630e16ff | ||
|
|
82a235dada | ||
|
|
3784357b7e | ||
|
|
8d0a9f9224 | ||
|
|
e758734807 | ||
|
|
352a4e793a | ||
|
|
8047394154 | ||
|
|
edcd2fe5d5 | ||
|
|
7d02e24d84 | ||
|
|
e5fd61f131 | ||
|
|
487af3c822 | ||
|
|
a7439c8e56 | ||
|
|
930660074d | ||
|
|
fd030e8673 | ||
|
|
bb73147efd | ||
|
|
04b21f089c | ||
|
|
fb42c8eea2 | ||
|
|
971f74e2ab | ||
|
|
4318aa2bb1 | ||
|
|
a1761363fa | ||
|
|
096417d5ac | ||
|
|
35446e01a2 | ||
|
|
6c3559e90f | ||
|
|
409ab466ca | ||
|
|
efb8c8f229 | ||
|
|
b1d663557b | ||
|
|
a460c5e9b9 | ||
|
|
831e3289e7 | ||
|
|
094864d779 | ||
|
|
147482d6ed | ||
|
|
921ddf47a8 | ||
|
|
f287c7f972 | ||
|
|
9eb1f93296 | ||
|
|
95d56ddf15 | ||
|
|
62b1e94257 | ||
|
|
01bab92fed | ||
|
|
dae677edeb | ||
|
|
03a5323755 | ||
|
|
2e9f935dbc | ||
|
|
93845db70a | ||
|
|
d5085294df | ||
|
|
a3aed9b8b6 | ||
|
|
7ca428677c | ||
|
|
9ad3e7661b | ||
|
|
05a4e8f5a3 | ||
|
|
1b0fe4aa47 | ||
|
|
29876acae5 | ||
|
|
657b3e1ccd | ||
|
|
a2ab0af3f1 | ||
|
|
bb9e181c20 | ||
|
|
7bc1f04e27 | ||
|
|
394b9cac13 | ||
|
|
be41911083 | ||
|
|
8f11902cb7 | ||
|
|
e2b2b7c701 | ||
|
|
af35cccb5f | ||
|
|
6342057a6f | ||
|
|
89923b5f76 | ||
|
|
54b5a22688 | ||
|
|
ab3443fd52 | ||
|
|
95ad879f82 | ||
|
|
80e153f861 | ||
|
|
dfa376f5ca | ||
|
|
76c357c88c | ||
|
|
5adde123e3 | ||
|
|
57291b1fd9 | ||
|
|
89f285ffd0 | ||
|
|
e6c06e2263 | ||
|
|
ca443b3ecf | ||
|
|
d8e9298222 | ||
|
|
c667aca3e0 | ||
|
|
7892b3cbe1 | ||
|
|
f2000ff789 | ||
|
|
759815cfac | ||
|
|
68df304867 | ||
|
|
a2aac3797f | ||
|
|
f13178a9ad | ||
|
|
c1d71dfedc | ||
|
|
2e7bc49c4c | ||
|
|
55dd3a9b1a | ||
|
|
5b3aea8bfe | ||
|
|
e47eb64de0 | ||
|
|
eada5eec53 | ||
|
|
1ee7cace36 | ||
|
|
612731a5b5 | ||
|
|
f50252c3b1 | ||
|
|
3cd1552ddc | ||
|
|
caab3f9686 | ||
|
|
bf8ba4da8f | ||
|
|
3fcc1ecedb | ||
|
|
46d7512cc4 | ||
|
|
00e172798b | ||
|
|
bb560809c7 | ||
|
|
765a11c7ab | ||
|
|
08e37540eb | ||
|
|
1768e2787f | ||
|
|
6d467d4659 | ||
|
|
701480c11c | ||
|
|
9ce6b45cf9 | ||
|
|
7eebee2de0 | ||
|
|
ff2fc72581 | ||
|
|
cdd7071885 | ||
|
|
30cba9550e | ||
|
|
1318372e81 | ||
|
|
185990f46b | ||
|
|
508cd524a8 | ||
|
|
08f25f065a | ||
|
|
36327cad00 | ||
|
|
939b5c22db | ||
|
|
5567652c6c | ||
|
|
a65f4c1465 | ||
|
|
253319ee50 | ||
|
|
4c1b11f733 | ||
|
|
6b67e6569b | ||
|
|
f5a28a434d | ||
|
|
638c633a21 | ||
|
|
935e55eb27 | ||
|
|
c4782ec71c | ||
|
|
81ee887d03 | ||
|
|
c2c5bc815e | ||
|
|
bd635cf07e | ||
|
|
ad565c2555 | ||
|
|
ac991e064c | ||
|
|
fadeca82eb | ||
|
|
891dbb0f6e | ||
|
|
140e0ef965 | ||
|
|
8c49f74fd9 | ||
|
|
535774a235 | ||
|
|
fa45ab593b | ||
|
|
a54687d3ef | ||
|
|
6cc81e3ab4 | ||
|
|
7699d2fcbd | ||
|
|
77cdfbcb06 | ||
|
|
cbab9332a9 | ||
|
|
fa0346921a | ||
|
|
c6aff35db2 | ||
|
|
af8509d012 | ||
|
|
0e6aba2886 | ||
|
|
e969f42c68 | ||
|
|
876c08ebc2 | ||
|
|
8ba5df358a | ||
|
|
771acd11a1 | ||
|
|
d4735edd45 | ||
|
|
e36784e510 | ||
|
|
a237285808 | ||
|
|
ee6d8072ed | ||
|
|
c8d259b1df | ||
|
|
2da56634a1 | ||
|
|
27be8c3b1e | ||
|
|
988c432ded | ||
|
|
dce4e668f1 | ||
|
|
2c69008a4a | ||
|
|
19452c2742 | ||
|
|
956828fa55 | ||
|
|
b873523eab | ||
|
|
b862f7b72d | ||
|
|
af0f629073 | ||
|
|
a4f433195d | ||
|
|
12ea75ab65 | ||
|
|
a6f24508e8 | ||
|
|
d562fd40c0 | ||
|
|
cded2c52ab | ||
|
|
df99442123 | ||
|
|
b9b1601bb8 | ||
|
|
5efac0dfaf | ||
|
|
ea41dbc8e7 | ||
|
|
c9ca1697e5 | ||
|
|
ed3ed78ec3 | ||
|
|
37aaa7b155 | ||
|
|
4b4e55a246 | ||
|
|
d6977352aa | ||
|
|
87145f1e6a | ||
|
|
7f9dafab68 | ||
|
|
07720f1d99 | ||
|
|
c0e63f76da | ||
|
|
93f4b84b8b | ||
|
|
804d5d2487 | ||
|
|
babc724220 | ||
|
|
138c774297 | ||
|
|
50ea5a76fb | ||
|
|
54282409bb | ||
|
|
f5b66f4745 | ||
|
|
907074a454 | ||
|
|
1f764d418c | ||
|
|
e2248015aa | ||
|
|
1e5b809b70 | ||
|
|
d79ea8b15c | ||
|
|
c60ea67a9b | ||
|
|
4819011c97 | ||
|
|
9be754cdbf | ||
|
|
35ed62ad0f | ||
|
|
d705d68f2f | ||
|
|
43a1f46047 | ||
|
|
1911946ed1 | ||
|
|
1572c95b4d | ||
|
|
cc80d05e49 | ||
|
|
83b681cebb | ||
|
|
57f8c7615c | ||
|
|
d9bcf2a439 | ||
|
|
051868c74c | ||
|
|
ffee4a472a | ||
|
|
00a2eec5f6 | ||
|
|
859d234543 | ||
|
|
207bdd1fae | ||
|
|
d102a8fd64 | ||
|
|
fcbdb09796 | ||
|
|
59f3f4737a | ||
|
|
c80c3e0256 | ||
|
|
ad1cb4c200 | ||
|
|
68ab7b81a1 | ||
|
|
f1cf4ebe60 | ||
|
|
0a452fad04 | ||
|
|
eb618638d2 | ||
|
|
7b8a2fbcef | ||
|
|
928169a4f2 | ||
|
|
0af666e3ac | ||
|
|
d41808efe1 | ||
|
|
aec28c8b67 | ||
|
|
3b5daa1451 | ||
|
|
f6579dbeb4 | ||
|
|
ab40786882 | ||
|
|
5f065c2685 | ||
|
|
6b50ace2bd | ||
|
|
c46c03158e | ||
|
|
7395fc6fd1 | ||
|
|
c35d649f6d | ||
|
|
60e833245d | ||
|
|
f424503234 | ||
|
|
1d42542514 | ||
|
|
d1314a4d5d | ||
|
|
48c3308c31 | ||
|
|
ba149330b1 | ||
|
|
6731879931 | ||
|
|
b15159caeb | ||
|
|
ec8c51a014 | ||
|
|
84d3aa1962 | ||
|
|
0bc693f157 | ||
|
|
5ae7a0c9c2 | ||
|
|
e6bc752b68 | ||
|
|
78fc1fe862 | ||
|
|
84500cdcae | ||
|
|
480af26b57 | ||
|
|
2003f308f3 | ||
|
|
a042fbbe43 | ||
|
|
ee5577c2da | ||
|
|
24945a9498 | ||
|
|
f6f689d570 | ||
|
|
7703625e76 | ||
|
|
17b81fb132 | ||
|
|
111576e2ca | ||
|
|
862cc43db7 | ||
|
|
48924626df | ||
|
|
72ea9b7a72 | ||
|
|
e0f9b33d23 | ||
|
|
c7fe944e73 | ||
|
|
584f5ce05e | ||
|
|
614c085310 | ||
|
|
866aeb8ad9 | ||
|
|
23aaecd99a | ||
|
|
77fb74c188 | ||
|
|
70fb5f5bfd | ||
|
|
26c07b671f | ||
|
|
c9f2e01131 | ||
|
|
f4f3e4204d | ||
|
|
d7d1e2d169 | ||
|
|
6ab97c579e | ||
|
|
3d4ac57bd0 | ||
|
|
efb3df8233 | ||
|
|
a952112910 | ||
|
|
20fa1a3a3b | ||
|
|
7609ccee4c | ||
|
|
9065dcef54 | ||
|
|
4ab307b10f | ||
|
|
675d825f6b | ||
|
|
1328e0cc05 | ||
|
|
f0ba64f23b | ||
|
|
22c27dd583 | ||
|
|
35da664a09 | ||
|
|
1323cb13c4 | ||
|
|
3287f2d00b | ||
|
|
d15a1451b7 | ||
|
|
9d370a18b8 | ||
|
|
865e25b3a9 | ||
|
|
82ab598426 | ||
|
|
09fae62888 | ||
|
|
7e0a220fde | ||
|
|
ed9a15c0bd | ||
|
|
b763d4358e | ||
|
|
5f2d4ac122 | ||
|
|
3bdc90a661 | ||
|
|
b5212a69c9 | ||
|
|
0bc903fa21 | ||
|
|
e2f20f0e24 | ||
|
|
30a225ce9f | ||
|
|
30dd54a318 | ||
|
|
abc8ad3fd4 | ||
|
|
4897627943 | ||
|
|
18533ef52b | ||
|
|
faa9c9c491 | ||
|
|
11f6494c20 | ||
|
|
9364da3414 | ||
|
|
ed5bc3091e | ||
|
|
8ff51eb176 | ||
|
|
90fb5e1f33 | ||
|
|
cda08ec033 | ||
|
|
21c585abec | ||
|
|
a1d0492d8d | ||
|
|
745f2e2768 | ||
|
|
ef55fecfe2 | ||
|
|
cc675da49c | ||
|
|
a9d5502fad | ||
|
|
849f49b622 | ||
|
|
d89f1ea663 | ||
|
|
5701543746 | ||
|
|
57db31c662 | ||
|
|
eadca4a96b | ||
|
|
4a111099c4 | ||
|
|
6f34699e4b | ||
|
|
aa6c521394 | ||
|
|
e80db346da | ||
|
|
26c7f3f5f3 | ||
|
|
0057ee7b72 | ||
|
|
0a3e55734b | ||
|
|
0b88383b01 | ||
|
|
a364edde17 | ||
|
|
22c6df3f85 | ||
|
|
93a3a742ac | ||
|
|
0abca5e0c7 | ||
|
|
f275474fec | ||
|
|
f12dc6127b | ||
|
|
e526f375c8 | ||
|
|
3ae1102d13 | ||
|
|
d1632d80e3 | ||
|
|
cad4110e24 | ||
|
|
fc7369384b | ||
|
|
c45b414476 | ||
|
|
73bd0c1dba | ||
|
|
502dd2d2e2 | ||
|
|
80a2f8052c | ||
|
|
a495138374 | ||
|
|
7f69166e97 | ||
|
|
2268790656 | ||
|
|
3a02d1cdd6 | ||
|
|
2e560677e7 | ||
|
|
b4a6b098a4 | ||
|
|
12f8c0db96 | ||
|
|
d39c95e0e7 | ||
|
|
f74031b50d | ||
|
|
8e67f5a707 | ||
|
|
a194a901b4 | ||
|
|
049edbfcaf | ||
|
|
7c18616160 | ||
|
|
70191ceaa8 | ||
|
|
99bac0fd27 | ||
|
|
e8b55eccab | ||
|
|
40ebd1b730 | ||
|
|
b5102e7d7e | ||
|
|
f3036ad2c9 | ||
|
|
4a01b9ec90 | ||
|
|
2f13806a2c | ||
|
|
5f5599a1a8 | ||
|
|
f2680563bd | ||
|
|
79d658243d | ||
|
|
3994fa65c0 | ||
|
|
10671cf999 | ||
|
|
40b9b4672e | ||
|
|
44fc4bfeff | ||
|
|
402f3e32ae | ||
|
|
436a595839 | ||
|
|
8a360281c6 | ||
|
|
730f34753a | ||
|
|
013f573cff | ||
|
|
ee8a81d2e1 | ||
|
|
bed03be332 | ||
|
|
debe4b543b | ||
|
|
1c601e09b6 | ||
|
|
ba54aae206 | ||
|
|
36961f83bf | ||
|
|
6db99c03cb | ||
|
|
999d100464 | ||
|
|
9190d86148 | ||
|
|
4b5cb5a3e2 | ||
|
|
847e1d2c60 | ||
|
|
970df6478b | ||
|
|
3371f17606 | ||
|
|
0f65fe894c | ||
|
|
2ac27a3e3e | ||
|
|
defcc54af2 | ||
|
|
fe84c5c9a3 | ||
|
|
2ff8041e15 | ||
|
|
1d3e551e7f | ||
|
|
79795b6f49 | ||
|
|
16da3300e3 | ||
|
|
69d8427bd9 | ||
|
|
d8ccbd5c32 | ||
|
|
be93f8b240 | ||
|
|
b09a731d85 | ||
|
|
554017b0b8 | ||
|
|
9f089be946 | ||
|
|
213f155c9c | ||
|
|
9e6b45e2f0 | ||
|
|
355e56db53 | ||
|
|
a79134ec9e | ||
|
|
91dde29146 | ||
|
|
586b48e150 | ||
|
|
aeefa22ddf | ||
|
|
9f3ef07322 | ||
|
|
e2cb67462d | ||
|
|
b60d253926 | ||
|
|
ee156adffb | ||
|
|
c99b78f5b0 | ||
|
|
4c6d21af4a | ||
|
|
327b315610 | ||
|
|
78849fa0bf | ||
|
|
e5014a5f57 | ||
|
|
5bf698ff84 | ||
|
|
eb5f011161 | ||
|
|
acfb933ee8 | ||
|
|
c37684b246 | ||
|
|
e2068e3d72 | ||
|
|
443eb16e67 | ||
|
|
240dc26013 | ||
|
|
6b07555a46 | ||
|
|
b69bd5271b | ||
|
|
cf4cae2c7d | ||
|
|
d51f18a2f7 | ||
|
|
006db65f08 | ||
|
|
f21221c1e1 | ||
|
|
9faa88e13b | ||
|
|
faf1eed0ab | ||
|
|
8fc37eac52 | ||
|
|
9e76d1c2d6 | ||
|
|
5609c89517 | ||
|
|
4e4a751921 | ||
|
|
6b4978b428 | ||
|
|
e1f4e6fafb | ||
|
|
f09b43eef0 | ||
|
|
2813f35eb1 | ||
|
|
90dfe36e3e | ||
|
|
7588c1791b | ||
|
|
bfa7f65c3d | ||
|
|
51bbebcdd5 | ||
|
|
7b4ca8394b | ||
|
|
438a9f6d48 | ||
|
|
9604b8d57b | ||
|
|
2a0b0b9109 | ||
|
|
f3338ee824 | ||
|
|
e73d40b260 | ||
|
|
89a25276a5 | ||
|
|
244eed8696 | ||
|
|
dce7316931 | ||
|
|
d69addaad2 | ||
|
|
845cf68d38 | ||
|
|
2b17aa1a1d | ||
|
|
d32c196fd5 | ||
|
|
a0266e29e3 | ||
|
|
752d29c146 | ||
|
|
36660b3cc1 | ||
|
|
bf355aaaf3 | ||
|
|
98d91fd696 | ||
|
|
8dfc866d40 | ||
|
|
15e9569157 | ||
|
|
483dd7cb6d | ||
|
|
6a0e48c10c | ||
|
|
7a4be5233c | ||
|
|
965704da20 | ||
|
|
f760255d50 | ||
|
|
f3acdedfb1 | ||
|
|
cc45c3772f | ||
|
|
a3e271a1e7 | ||
|
|
7b22fc5c3f | ||
|
|
a15b52efc8 | ||
|
|
7f11b93e0f | ||
|
|
02d74777b2 | ||
|
|
cf148ba3af | ||
|
|
19b6aaa2f3 | ||
|
|
97737be91c | ||
|
|
e3d7dabb87 | ||
|
|
a90a7f454c | ||
|
|
8a60dc30d6 | ||
|
|
06f8722f25 | ||
|
|
e3552f6365 | ||
|
|
7c6eb2ad74 | ||
|
|
2721ce331a | ||
|
|
ca787271b3 | ||
|
|
87948e956a | ||
|
|
1e5e0f625d | ||
|
|
6f7b3db4fa | ||
|
|
93b5cc530c | ||
|
|
785124eb9f | ||
|
|
2a1ef17107 | ||
|
|
2d5d0dcacd | ||
|
|
faa5aaab6f | ||
|
|
03e1915316 | ||
|
|
b339c5e61d | ||
|
|
0c44e7db80 | ||
|
|
870d95f35e | ||
|
|
cfb9784ea9 | ||
|
|
484edb1e1e | ||
|
|
356faa0563 | ||
|
|
7d76da3249 | ||
|
|
1d1c7058f1 | ||
|
|
6b51088f39 | ||
|
|
efa6a6aed3 | ||
|
|
ba054b4dfb | ||
|
|
604b41db9b | ||
|
|
8dd94757d7 | ||
|
|
a99edcc0b2 | ||
|
|
1985bd6669 | ||
|
|
674573938a | ||
|
|
7821a8a8af | ||
|
|
226ece2bdd | ||
|
|
cec0b96adb | ||
|
|
9d64c3e01e | ||
|
|
bb300fa2f8 | ||
|
|
8a41c92488 | ||
|
|
5f76fbb222 | ||
|
|
d512932233 | ||
|
|
cb24822cac | ||
|
|
304d42d969 | ||
|
|
7f29baf915 | ||
|
|
74c99a827d | ||
|
|
5b270444d5 | ||
|
|
885b575d14 | ||
|
|
8371ea0c7d | ||
|
|
6dc49ccbc7 | ||
|
|
6f2b967599 | ||
|
|
bdfed2db4b | ||
|
|
21b9743c61 | ||
|
|
73fce89edd | ||
|
|
ee8e2ab691 | ||
|
|
26ea214d64 | ||
|
|
7578846c27 | ||
|
|
430ad54d77 | ||
|
|
f47ea7afe5 | ||
|
|
92991ffdb8 | ||
|
|
b21764fd40 | ||
|
|
4f1783fc76 | ||
|
|
b5b4e60916 | ||
|
|
952ff0dd72 | ||
|
|
111b9d95f4 | ||
|
|
6de7756952 | ||
|
|
3c066f5dd9 | ||
|
|
bf1cded8a1 | ||
|
|
e9b57a5ac6 | ||
|
|
cb92f39e73 | ||
|
|
f3796e2ba5 | ||
|
|
74ac17f910 | ||
|
|
73eabd2eea | ||
|
|
97f0f4b266 | ||
|
|
888af29485 | ||
|
|
f418f3ab02 | ||
|
|
92b5f1e963 | ||
|
|
223c8d7d0b | ||
|
|
c8cef9bec8 | ||
|
|
01faa3467d | ||
|
|
18d4df339f | ||
|
|
16984c138d | ||
|
|
cdf59e8315 | ||
|
|
c7cbf89f92 | ||
|
|
ac1106b259 | ||
|
|
e1067c623b | ||
|
|
a8e867dc7b | ||
|
|
080fb74642 | ||
|
|
2e201cab09 | ||
|
|
ec5b416b8a | ||
|
|
2566171b4c | ||
|
|
3a6a8cfa38 | ||
|
|
6648cb8917 | ||
|
|
4ca97c4fdf | ||
|
|
aa2f35656e | ||
|
|
c0420ac1f9 | ||
|
|
91634ca6f2 | ||
|
|
3355f7d8d9 | ||
|
|
c4523c70b5 | ||
|
|
a060ae1256 | ||
|
|
094ce49d4f | ||
|
|
0832b12608 | ||
|
|
9d2ec0e9e1 | ||
|
|
5c773371d6 | ||
|
|
3f06063b4b | ||
|
|
b167abfda5 | ||
|
|
a43008e9c7 | ||
|
|
a134132418 | ||
|
|
cbf5621a73 | ||
|
|
ff4f2eeb56 | ||
|
|
560e0f2362 | ||
|
|
f3ebe0545f | ||
|
|
89a117e1c1 | ||
|
|
317d33a9a7 | ||
|
|
42786956c7 | ||
|
|
9b6f5e028c | ||
|
|
84e3140f19 | ||
|
|
1974c33971 | ||
|
|
3e877c9f24 | ||
|
|
6c4fcd1486 | ||
|
|
fed842e9f1 | ||
|
|
f4b98d1d81 | ||
|
|
bfea00df78 | ||
|
|
0c710b09f5 | ||
|
|
2652bdda2e | ||
|
|
6f2944ec7f | ||
|
|
40f376b824 | ||
|
|
8ee9e7274d | ||
|
|
44c3ee670f | ||
|
|
8bcfde8184 | ||
|
|
55b450c8b9 | ||
|
|
74a9a3c33b | ||
|
|
1fdad9e2a7 | ||
|
|
52336425bc | ||
|
|
afc74d67e2 | ||
|
|
88f7763430 | ||
|
|
e9e267ff14 | ||
|
|
d6b7c2ed2d | ||
|
|
18fbd1dfbf | ||
|
|
409a3ac5df | ||
|
|
ccd42751d2 | ||
|
|
c1e4b0fcbf | ||
|
|
0d28dae444 | ||
|
|
1c83e10c6d | ||
|
|
231b83dbc2 | ||
|
|
d7e2ab4437 | ||
|
|
c3c8103563 | ||
|
|
e906bffbb9 | ||
|
|
c123c98f5f | ||
|
|
e6a7c4de42 | ||
|
|
5dae7f854f | ||
|
|
0350f00840 | ||
|
|
cc7483ea12 | ||
|
|
6dad3212da | ||
|
|
e22647af62 | ||
|
|
d128a32d2f | ||
|
|
6b23509b96 | ||
|
|
f8e3204572 | ||
|
|
603e95de09 | ||
|
|
bff1a5accc | ||
|
|
5db14ee50c | ||
|
|
810ac63e21 | ||
|
|
0f66aa9d67 | ||
|
|
4df517d84b | ||
|
|
e1d51dfec9 | ||
|
|
7d1122a371 | ||
|
|
1473820cc4 | ||
|
|
ff0be38cff | ||
|
|
7bda40bd4e | ||
|
|
fb6b5ca886 | ||
|
|
8d379a6215 | ||
|
|
d4a9ee482a | ||
|
|
2f98f073ce | ||
|
|
719ae73c7d | ||
|
|
dd5218ebdb |
15
.github/FUNDING.yml
vendored
Normal file
15
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [boudra]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
51
.github/workflows/android-apk-release.yml
vendored
51
.github/workflows/android-apk-release.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "android-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -16,7 +17,7 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
|
||||
jobs:
|
||||
publish-android-apk:
|
||||
@@ -31,6 +32,36 @@ jobs:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
|
||||
- name: Resolve release tag
|
||||
shell: bash
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Ensure GitHub release exists
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
release_args=(
|
||||
release create "$RELEASE_TAG"
|
||||
--repo "${{ github.repository }}"
|
||||
--title "Paseo $RELEASE_TAG"
|
||||
--notes ""
|
||||
)
|
||||
|
||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||
release_args+=(--prerelease)
|
||||
fi
|
||||
|
||||
if ! gh "${release_args[@]}"; then
|
||||
echo "Release creation raced with another workflow; continuing."
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -94,24 +125,6 @@ jobs:
|
||||
echo "asset_name=$asset_name" >> "$GITHUB_OUTPUT"
|
||||
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Wait for GitHub release tag
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in $(seq 1 90); do
|
||||
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||
echo "Found release for tag $RELEASE_TAG"
|
||||
exit 0
|
||||
fi
|
||||
echo "Release for $RELEASE_TAG is not available yet (attempt $attempt/90)."
|
||||
sleep 20
|
||||
done
|
||||
|
||||
echo "Timed out waiting for GitHub release tag $RELEASE_TAG."
|
||||
exit 1
|
||||
|
||||
- name: Upload APK to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
246
.github/workflows/ci.yml
vendored
Normal file
246
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,246 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Check formatting
|
||||
run: npx oxfmt --check .
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Build server dependency
|
||||
run: npm run build --workspace=@getpaseo/server
|
||||
|
||||
- name: Typecheck all packages
|
||||
run: npm run typecheck
|
||||
|
||||
server-tests:
|
||||
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 dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Run server tests
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
server-tests-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- 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
|
||||
|
||||
app-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Run app unit tests
|
||||
run: npm run test --workspace=@getpaseo/app
|
||||
|
||||
playwright:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Build server dependency
|
||||
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
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
run: npm run test:e2e --workspace=@getpaseo/app
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
- name: Upload test artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: playwright-results
|
||||
path: |
|
||||
packages/app/test-results/
|
||||
packages/app/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
relay-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build relay
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Run relay tests
|
||||
run: npm run test --workspace=@getpaseo/relay
|
||||
|
||||
cli-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Run CLI tests
|
||||
run: npm run test --workspace=@getpaseo/cli
|
||||
env:
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
||||
PASEO_DICTATION_ENABLED: "0"
|
||||
PASEO_VOICE_MODE_ENABLED: "0"
|
||||
23
.github/workflows/deploy-app.yml
vendored
23
.github/workflows/deploy-app.yml
vendored
@@ -2,11 +2,11 @@ name: Deploy App
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packages/app/**'
|
||||
- 'packages/server/src/**'
|
||||
- '.github/workflows/deploy-app.yml'
|
||||
tags:
|
||||
- "v*"
|
||||
- "!v*-beta.*"
|
||||
- "app-v*"
|
||||
- "!app-v*-beta.*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -18,16 +18,19 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
scope: '@boudra'
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --workspace=@getpaseo/app --include-workspace-root
|
||||
run: npm ci
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/app
|
||||
|
||||
|
||||
9
.github/workflows/deploy-relay.yml
vendored
9
.github/workflows/deploy-relay.yml
vendored
@@ -4,8 +4,8 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packages/relay/**'
|
||||
- '.github/workflows/deploy-relay.yml'
|
||||
- "packages/relay/**"
|
||||
- ".github/workflows/deploy-relay.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -17,8 +17,8 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --workspace=@getpaseo/relay --include-workspace-root
|
||||
@@ -30,4 +30,3 @@ jobs:
|
||||
run: cd packages/relay && npx wrangler deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
|
||||
18
.github/workflows/deploy-website.yml
vendored
18
.github/workflows/deploy-website.yml
vendored
@@ -4,15 +4,19 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packages/website/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'patches/**'
|
||||
- '.github/workflows/deploy-website.yml'
|
||||
- "CHANGELOG.md"
|
||||
- "packages/website/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "patches/**"
|
||||
- ".github/workflows/deploy-website.yml"
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: ${{ github.event_name != 'release' || (!github.event.release.prerelease && !github.event.release.draft) }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -20,8 +24,8 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --workspace=@getpaseo/website --include-workspace-root
|
||||
|
||||
489
.github/workflows/desktop-release.yml
vendored
489
.github/workflows/desktop-release.yml
vendored
@@ -3,111 +3,468 @@ name: Desktop Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- 'desktop-v*'
|
||||
- "v*"
|
||||
- "desktop-v*"
|
||||
- "desktop-macos-v*"
|
||||
- "desktop-linux-v*"
|
||||
- "desktop-windows-v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Existing tag to build (e.g. v0.1.0)'
|
||||
description: "Existing tag to build (e.g. v0.1.0)"
|
||||
required: true
|
||||
type: string
|
||||
platform:
|
||||
description: "Optional desktop platform to build."
|
||||
required: false
|
||||
default: "all"
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- macos
|
||||
- linux
|
||||
- windows
|
||||
checkout_ref:
|
||||
description: "Optional branch/ref to checkout while using tag for release metadata."
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
publish:
|
||||
description: "Publish built artifacts to GitHub Releases."
|
||||
required: false
|
||||
default: "true"
|
||||
type: choice
|
||||
options:
|
||||
- "true"
|
||||
- "false"
|
||||
rollout_hours:
|
||||
description: "Linear rollout duration in hours. Use 0 for instant rollout."
|
||||
required: false
|
||||
default: "24"
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: desktop-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
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"
|
||||
DESKTOP_PACKAGE_PATH: "packages/desktop"
|
||||
|
||||
jobs:
|
||||
publish-tauri:
|
||||
create-release:
|
||||
if: ${{ (github.event_name == 'push' && !startsWith(github.ref_name, 'desktop-macos-v') && !startsWith(github.ref_name, 'desktop-linux-v') && !startsWith(github.ref_name, 'desktop-windows-v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.platform == 'all' && github.event.inputs.publish != 'false') }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: scripts
|
||||
ref: ${{ env.CHECKOUT_REF }}
|
||||
|
||||
- name: Resolve release metadata
|
||||
shell: bash
|
||||
run: node scripts/emit-release-env.mjs --source-tag "$SOURCE_TAG" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Create GitHub release
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if gh release view "$RELEASE_TAG" --repo "${{ github.repository }}" > /dev/null 2>&1; then
|
||||
echo "Release $RELEASE_TAG already exists, skipping creation"
|
||||
else
|
||||
prerelease_flag=""
|
||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||
prerelease_flag="--prerelease"
|
||||
fi
|
||||
gh release create "$RELEASE_TAG" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--title "Paseo $RELEASE_TAG" \
|
||||
--notes "" \
|
||||
$prerelease_flag || {
|
||||
echo "Release creation raced with another workflow; continuing."
|
||||
}
|
||||
fi
|
||||
|
||||
publish-macos:
|
||||
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 == 'macos')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-macos-v')))) }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-14
|
||||
electron_arch: arm64
|
||||
- runner: macos-15-intel
|
||||
electron_arch: x64
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
runs-on: macos-latest
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
ref: ${{ env.CHECKOUT_REF }}
|
||||
|
||||
- name: Resolve release metadata
|
||||
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'
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
scope: '@boudra'
|
||||
|
||||
- name: Install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin
|
||||
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: Build web app for Tauri
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Set desktop version from tag
|
||||
- name: Set desktop package version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const rawTag = process.env.RELEASE_TAG;
|
||||
if (!rawTag) throw new Error('RELEASE_TAG env var is missing');
|
||||
const version = process.env.DESKTOP_VERSION;
|
||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||
|
||||
const version = rawTag.replace(/^desktop-/, '').replace(/^v/, '');
|
||||
console.log(`Using desktop version ${version} from tag ${rawTag}`);
|
||||
|
||||
const tauriConfPath = path.join('packages', 'desktop', 'src-tauri', 'tauri.conf.json');
|
||||
const tauriConfText = fs.readFileSync(tauriConfPath, 'utf8');
|
||||
const tauriRe = /("version"\s*:\s*")([^"]+)(")/;
|
||||
if (!tauriRe.test(tauriConfText)) {
|
||||
throw new Error(`Failed to find version field in ${tauriConfPath}`);
|
||||
}
|
||||
fs.writeFileSync(tauriConfPath, tauriConfText.replace(tauriRe, `$1${version}$3`));
|
||||
|
||||
const cargoTomlPath = path.join('packages', 'desktop', 'src-tauri', 'Cargo.toml');
|
||||
const cargoLines = fs.readFileSync(cargoTomlPath, 'utf8').split(/\r?\n/);
|
||||
let inPackage = false;
|
||||
let updated = false;
|
||||
const nextLines = cargoLines.map((line) => {
|
||||
if (/^\[package\]\s*$/.test(line)) inPackage = true;
|
||||
else if (inPackage && /^\[/.test(line)) inPackage = false;
|
||||
|
||||
if (inPackage && /^version\s*=\s*".*"\s*$/.test(line)) {
|
||||
updated = true;
|
||||
return `version = "${version}"`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
if (!updated) throw new Error(`Failed to update Cargo package version in ${cargoTomlPath}`);
|
||||
fs.writeFileSync(cargoTomlPath, `${nextLines.join('\n')}\n`);
|
||||
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
packageJson.version = version;
|
||||
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
- name: Build and publish Tauri release
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
- name: Build web app for desktop
|
||||
run: npm run build:web --workspace=@getpaseo/app
|
||||
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EP_GH_IGNORE_TIME: true
|
||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
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
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
|
||||
|
||||
- name: Upload manifest artifact
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
projectPath: packages/desktop
|
||||
tagName: ${{ env.RELEASE_TAG }}
|
||||
releaseName: Paseo ${{ env.RELEASE_TAG }}
|
||||
releaseBody: See the assets to download and install this version.
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
args: --target aarch64-apple-darwin
|
||||
name: mac-manifest-${{ matrix.electron_arch }}
|
||||
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')))) }}
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ env.CHECKOUT_REF }}
|
||||
|
||||
- name: Resolve release metadata
|
||||
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: Set desktop package version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const version = process.env.DESKTOP_VERSION;
|
||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||
|
||||
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
packageJson.version = version;
|
||||
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
|
||||
|
||||
- name: Build desktop release
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EP_GH_IGNORE_TIME: true
|
||||
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
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
|
||||
|
||||
publish-windows:
|
||||
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 == 'windows')) || (github.event_name == 'push' && (startsWith(github.ref_name, 'v') || startsWith(github.ref_name, 'desktop-v') || startsWith(github.ref_name, 'desktop-windows-v')))) }}
|
||||
permissions:
|
||||
contents: write
|
||||
packages: read
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ env.CHECKOUT_REF }}
|
||||
|
||||
- name: Resolve release metadata
|
||||
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: Set desktop package version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const version = process.env.DESKTOP_VERSION;
|
||||
if (!version) throw new Error('DESKTOP_VERSION env var is missing');
|
||||
|
||||
const packageJsonPath = path.join(process.env.DESKTOP_PACKAGE_PATH, 'package.json');
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
packageJson.version = version;
|
||||
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:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EP_GH_IGNORE_TIME: true
|
||||
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
|
||||
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
|
||||
|
||||
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') }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: desktop-rollout-${{ github.event.inputs.tag || github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
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: Stamp rollout metadata
|
||||
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"
|
||||
shopt -s nullglob
|
||||
files=( ./*.yml )
|
||||
if (( ${#files[@]} == 0 )); then
|
||||
echo "::error::No manifests matched ${RELEASE_CHANNEL}*.yml on $RELEASE_TAG"
|
||||
exit 1
|
||||
fi
|
||||
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")"
|
||||
node ../scripts/stamp-rollout.mjs --release-date "$timestamp" --rollout-hours "$ROLLOUT_HOURS" "${files[@]}"
|
||||
ROLLOUT_HOURS_EXPECTED="$ROLLOUT_HOURS" RELEASE_DATE_EXPECTED="$timestamp" node -e '
|
||||
const yaml = require("js-yaml");
|
||||
const fs = require("fs");
|
||||
const expectedHours = Number(process.env.ROLLOUT_HOURS_EXPECTED);
|
||||
const expectedDate = process.env.RELEASE_DATE_EXPECTED;
|
||||
if (!Number.isFinite(expectedHours) || expectedHours < 0) {
|
||||
throw new Error(`expected non-negative rolloutHours, got ${process.env.ROLLOUT_HOURS_EXPECTED}`);
|
||||
}
|
||||
for (const f of process.argv.slice(1)) {
|
||||
const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {};
|
||||
if (m.rolloutHours !== expectedHours) {
|
||||
throw new Error(`${f}: rolloutHours=${m.rolloutHours}, expected ${expectedHours}`);
|
||||
}
|
||||
if (m.releaseDate !== expectedDate) {
|
||||
throw new Error(`${f}: releaseDate=${m.releaseDate}, expected ${expectedDate}`);
|
||||
}
|
||||
if (typeof m.version !== "string" || m.version.length === 0) {
|
||||
throw new Error(`${f}: missing or invalid version`);
|
||||
}
|
||||
}
|
||||
' "${files[@]}"
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
152
.github/workflows/desktop-rollout.yml
vendored
Normal file
152
.github/workflows/desktop-rollout.yml
vendored
Normal file
@@ -0,0 +1,152 @@
|
||||
name: Desktop Rollout
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing release tag to re-stamp (e.g. v0.1.42)."
|
||||
required: true
|
||||
type: string
|
||||
rollout_hours:
|
||||
description: "Total rollout duration since the original release date, in hours. Use 0 to admit everyone immediately."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: desktop-rollout-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
SOURCE_TAG: ${{ inputs.tag }}
|
||||
|
||||
jobs:
|
||||
stamp:
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
package.json
|
||||
package-lock.json
|
||||
scripts
|
||||
|
||||
- name: Resolve release metadata
|
||||
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 release manifests
|
||||
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"
|
||||
shopt -s nullglob
|
||||
files=( ./*.yml )
|
||||
if (( ${#files[@]} == 0 )); then
|
||||
echo "::error::No manifests matched ${RELEASE_CHANNEL}*.yml on $RELEASE_TAG"
|
||||
exit 1
|
||||
fi
|
||||
echo "Downloaded ${#files[@]} manifest(s):"
|
||||
printf ' %s\n' "${files[@]}"
|
||||
|
||||
- name: Capture before state
|
||||
id: before
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd release-manifests
|
||||
summary=$(node -e '
|
||||
const yaml = require("js-yaml");
|
||||
const fs = require("fs");
|
||||
for (const f of process.argv.slice(1)) {
|
||||
const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {};
|
||||
console.log(` ${f}: rolloutHours=${m.rolloutHours ?? "<unset>"} releaseDate=${m.releaseDate ?? "<unset>"}`);
|
||||
}
|
||||
' ./*.yml)
|
||||
echo "$summary"
|
||||
{
|
||||
echo "summary<<EOF"
|
||||
echo "$summary"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Re-stamp rolloutHours
|
||||
env:
|
||||
NEW_HOURS: ${{ inputs.rollout_hours }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd release-manifests
|
||||
node ../scripts/stamp-rollout.mjs --rollout-hours "$NEW_HOURS" ./*.yml
|
||||
|
||||
- name: Validate rewritten manifests
|
||||
env:
|
||||
EXPECTED: ${{ inputs.rollout_hours }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd release-manifests
|
||||
node -e '
|
||||
const yaml = require("js-yaml");
|
||||
const fs = require("fs");
|
||||
const expected = Number(process.env.EXPECTED);
|
||||
if (!Number.isFinite(expected) || expected < 0) {
|
||||
throw new Error(`EXPECTED must be a non-negative number, got ${process.env.EXPECTED}`);
|
||||
}
|
||||
for (const f of process.argv.slice(1)) {
|
||||
const m = yaml.load(fs.readFileSync(f, "utf8")) ?? {};
|
||||
if (m.rolloutHours !== expected) {
|
||||
throw new Error(`${f}: rolloutHours=${m.rolloutHours}, expected ${expected}`);
|
||||
}
|
||||
if (typeof m.version !== "string" || m.version.length === 0) {
|
||||
throw new Error(`${f}: missing or invalid version`);
|
||||
}
|
||||
}
|
||||
' ./*.yml
|
||||
|
||||
- name: Upload to release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd release-manifests
|
||||
gh release upload "$RELEASE_TAG" ./*.yml --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Write summary
|
||||
env:
|
||||
BEFORE: ${{ steps.before.outputs.summary }}
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
echo "## Rollout updated"
|
||||
echo ""
|
||||
echo "**Tag:** \`$RELEASE_TAG\`"
|
||||
echo "**Channel:** \`$RELEASE_CHANNEL\`"
|
||||
echo "**New rolloutHours:** \`${{ inputs.rollout_hours }}\`"
|
||||
echo ""
|
||||
echo "### Before"
|
||||
echo '```'
|
||||
echo "$BEFORE"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
45
.github/workflows/fix-nix-hash.yml
vendored
Normal file
45
.github/workflows/fix-nix-hash.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
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
Normal file
59
.github/workflows/nix-build.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
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
|
||||
67
.github/workflows/release-notes-sync.yml
vendored
Normal file
67
.github/workflows/release-notes-sync.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: Release Notes Sync
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "CHANGELOG.md"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag to sync (e.g. v0.1.14). Leave empty to use top changelog entry."
|
||||
required: false
|
||||
type: string
|
||||
create_if_missing:
|
||||
description: "Create release if missing (normally only needed for tag events)."
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: release-notes-sync-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync-release-notes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Sync release body from changelog
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REF: ${{ github.ref }}
|
||||
INPUT_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && github.ref_name || github.event.inputs.tag }}
|
||||
INPUT_CREATE_IF_MISSING: ${{ github.event.inputs.create_if_missing }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
args=(--repo "$REPO")
|
||||
|
||||
if [ -n "${INPUT_TAG:-}" ]; then
|
||||
args+=(--tag "$INPUT_TAG")
|
||||
fi
|
||||
|
||||
create_if_missing="false"
|
||||
if [[ "$EVENT_NAME" = "push" && "$REF" == refs/tags/v* ]]; then
|
||||
create_if_missing="true"
|
||||
elif [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${INPUT_CREATE_IF_MISSING:-false}" = "true" ]; then
|
||||
create_if_missing="true"
|
||||
fi
|
||||
|
||||
if [ "$create_if_missing" = "true" ]; then
|
||||
args+=(--create-if-missing)
|
||||
fi
|
||||
|
||||
node scripts/sync-release-notes-from-changelog.mjs "${args[@]}"
|
||||
29
.github/workflows/server-ci.yml
vendored
29
.github/workflows/server-ci.yml
vendored
@@ -4,17 +4,17 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'packages/server/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- '.github/workflows/server-ci.yml'
|
||||
- "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'
|
||||
- "packages/server/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".github/workflows/server-ci.yml"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -27,8 +27,8 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Fetch origin/main (worktree tests)
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
@@ -36,8 +36,17 @@ jobs:
|
||||
- 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 }}
|
||||
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -46,6 +46,9 @@ test-results/
|
||||
# Vercel
|
||||
.vercel/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
.vercel
|
||||
@@ -71,6 +74,12 @@ valknut-report.json/
|
||||
**/.paseo-provider-history/
|
||||
.claude/settings.local.json
|
||||
**/.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
.claude/worktrees/
|
||||
.plans/
|
||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
|
||||
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
|
||||
|
||||
/artifacts
|
||||
packages/desktop/.cache/
|
||||
packages/desktop/src-tauri/resources/managed-runtime/
|
||||
|
||||
9
.mise.toml
Normal file
9
.mise.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[env]
|
||||
ANDROID_HOME = "{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0"
|
||||
_.path = [
|
||||
"{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/platform-tools",
|
||||
"{{env.HOME}}/.local/share/mise/installs/android-sdk/1.0/emulator",
|
||||
]
|
||||
|
||||
[tools]
|
||||
java = "17"
|
||||
15
.oxfmtrc.json
Normal file
15
.oxfmtrc.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"useTabs": false,
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100,
|
||||
"singleQuote": false,
|
||||
"jsxSingleQuote": false,
|
||||
"quoteProps": "as-needed",
|
||||
"trailingComma": "all",
|
||||
"semi": true,
|
||||
"arrowParens": "always",
|
||||
"bracketSameLine": false,
|
||||
"bracketSpacing": true,
|
||||
"ignorePatterns": ["*.lock"]
|
||||
}
|
||||
89
.oxlintrc.json
Normal file
89
.oxlintrc.json
Normal file
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "error",
|
||||
"perf": "error"
|
||||
},
|
||||
"rules": {
|
||||
"react/react-in-jsx-scope": "off",
|
||||
|
||||
"no-await-in-loop": "off",
|
||||
|
||||
"no-unused-expressions": "error",
|
||||
"no-useless-catch": "error",
|
||||
"preserve-caught-error": "error",
|
||||
"require-await": "off",
|
||||
"no-async-promise-executor": "error",
|
||||
"no-useless-escape": "error",
|
||||
"no-empty-pattern": "error",
|
||||
"no-self-assign": "error",
|
||||
"no-shadow": "error",
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
"unicorn/no-array-sort": "off",
|
||||
|
||||
"unicorn/no-useless-spread": "error",
|
||||
"unicorn/no-useless-fallback-in-spread": "error",
|
||||
"unicorn/no-new-array": "error",
|
||||
"unicorn/no-empty-file": "error",
|
||||
|
||||
"promise/always-return": "error",
|
||||
"promise/no-multiple-resolved": "error",
|
||||
|
||||
"react/no-array-index-key": "error",
|
||||
"react/jsx-no-useless-fragment": "error",
|
||||
"react/jsx-no-constructed-context-values": "error",
|
||||
"react/no-unescaped-entities": "error",
|
||||
"react/button-has-type": "error",
|
||||
"react/jsx-max-depth": ["error", { "max": 6 }],
|
||||
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "error",
|
||||
|
||||
"react-perf/jsx-no-new-array-as-prop": "error",
|
||||
"react-perf/jsx-no-new-function-as-prop": "error",
|
||||
"react-perf/jsx-no-new-object-as-prop": "error",
|
||||
"react-perf/jsx-no-jsx-as-prop": "error",
|
||||
|
||||
"oxc/no-map-spread": "error",
|
||||
"oxc/no-async-endpoint-handlers": "error",
|
||||
"oxc/only-used-in-recursion": "error",
|
||||
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/prefer-as-const": "error",
|
||||
"typescript/no-this-alias": "error",
|
||||
"typescript/consistent-type-definitions": ["error", "interface"],
|
||||
|
||||
"import/no-unassigned-import": [
|
||||
"error",
|
||||
{
|
||||
"allow": [
|
||||
"**/*.css",
|
||||
"**/expo-router/entry",
|
||||
"**/event-target-polyfill",
|
||||
"**/dotenv/config",
|
||||
"**/react-native",
|
||||
"**/@/styles/unistyles",
|
||||
"**/src/styles/unistyles",
|
||||
"**/@/test/window-local-storage"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"no-nested-ternary": "error",
|
||||
"no-unneeded-ternary": "error",
|
||||
|
||||
"complexity": ["error", { "max": 20 }],
|
||||
"max-depth": ["error", { "max": 4 }],
|
||||
"max-nested-callbacks": ["error", { "max": 3 }]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/e2e/fixtures.ts"],
|
||||
"rules": {
|
||||
"no-empty-pattern": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
.next
|
||||
.expo
|
||||
build
|
||||
*.tsbuildinfo
|
||||
|
||||
# Coverage
|
||||
coverage
|
||||
|
||||
# Lock files
|
||||
*.lock
|
||||
package-lock.json
|
||||
|
||||
# Generated
|
||||
android
|
||||
ios
|
||||
.turbo
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
rust 1.85.1
|
||||
nodejs 22.20.0
|
||||
rust 1.85.1
|
||||
nodejs 22.20.0
|
||||
java 21
|
||||
android-sdk latest
|
||||
|
||||
905
CHANGELOG.md
905
CHANGELOG.md
@@ -1,99 +1,984 @@
|
||||
# Changelog
|
||||
|
||||
## [0.1.9] - 2026-02-17
|
||||
## 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
|
||||
|
||||
- Project settings page with a built-in `paseo.json` editor.
|
||||
- Cold start restores your last open workspace.
|
||||
- Tool call badges have a button to open the referenced file directly.
|
||||
- Open the current branch on GitHub from a workspace's open menu. ([#583](https://github.com/getpaseo/paseo/pull/583) by [@Myriad-Dreamin](https://github.com/Myriad-Dreamin))
|
||||
- Enable or disable providers from Settings without editing config files.
|
||||
- Paseo prompts you to configure a worktree setup script when one is missing.
|
||||
- Choose whether the daemon shuts down when you close the desktop app.
|
||||
|
||||
### Improved
|
||||
|
||||
- Provider settings and model selection have been redesigned.
|
||||
- Voice mode transcription endpoint is configurable for OpenAI-compatible providers. ([#570](https://github.com/getpaseo/paseo/pull/570) by [@yuruiz](https://github.com/yuruiz))
|
||||
- Adding a project no longer waits for GitHub PR status to load.
|
||||
- Startup splash screen is cleaner — just the logo with a subtle shimmer.
|
||||
- `paseo.json` setup and teardown accept a single command string, not just an array.
|
||||
- Archiving a worktree is instant instead of waiting for the backend to confirm.
|
||||
- Agent timelines and git diff lists no longer jump around while loading or streaming.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `paseo loop run` and `paseo run` now respect the `--provider` and `--model` flags. ([#594](https://github.com/getpaseo/paseo/pull/594) by [@VincenzoRocchi](https://github.com/VincenzoRocchi))
|
||||
- Pi provider shows up when only DeepSeek or other non-OpenAI/Anthropic/OpenRouter API keys are set.
|
||||
- Custom models from `additionalModels` and `profileModels` are honored when picking a default for new agents.
|
||||
- File preview line numbers stay on one line past line 99.
|
||||
- Cmd+Q on macOS quits the desktop app instead of leaving it running in the background.
|
||||
- Terminal sessions recover cleanly after rendering hiccups, including the initial resize for nvim.
|
||||
- Terminal protocol query responses no longer leak into the browser.
|
||||
- Assistant link color matches the theme again.
|
||||
- File links with line numbers (like `foo.ts:42`) open correctly from assistant messages.
|
||||
- Claude's Grep results show up in the search detail body.
|
||||
- Reopening a worktree lands under the right project.
|
||||
- Agents from disabled or unavailable providers stay visible in history.
|
||||
- New CLI agents now require a provider instead of failing silently.
|
||||
- Git diff headers no longer truncate.
|
||||
- Provider diagnostic modal scrolls on short screens.
|
||||
- Provider diagnostics show the real error and underlying child-process output instead of a generic message.
|
||||
- Archived workspaces no longer interfere with working-directory resolution.
|
||||
- Triple-click on a message no longer extends the selection into adjacent bubbles.
|
||||
- The packaged desktop app preserves your zsh prompt.
|
||||
|
||||
## 0.1.62 - 2026-04-23
|
||||
|
||||
### Added
|
||||
|
||||
- Sidebar warning when your app and daemon versions drift apart, with a shortcut to settings.
|
||||
|
||||
### Improved
|
||||
|
||||
- Workspaces appear in the sidebar immediately on startup instead of waiting for git registration.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pull request status resolves correctly for PRs opened from forks.
|
||||
- Installing the paseo CLI from the macOS desktop app now works in packaged builds.
|
||||
- Agents launched from the desktop app no longer inherit Electron-only environment variables.
|
||||
|
||||
## 0.1.61 - 2026-04-23
|
||||
|
||||
### Added
|
||||
|
||||
- `additionalModels` option in provider config lets you add or relabel models without replacing the full list — entries merge with runtime-discovered models (ACP) or your static `models` list. See the [Providers docs](https://paseo.sh/docs/providers).
|
||||
- New [Providers docs page](https://paseo.sh/docs/providers) covering first-class providers and every custom provider config pattern in one place.
|
||||
|
||||
### Improved
|
||||
|
||||
- Pi loads your installed extensions on startup so their models show up in the model picker.
|
||||
- Resizing the explorer sidebar no longer rerenders the rest of the workspace.
|
||||
- Images in assistant messages (both file paths and inline data URLs) persist as local attachments and open in the file pane.
|
||||
|
||||
## 0.1.60 - 2026-04-22
|
||||
|
||||
### Added
|
||||
|
||||
- Scripts and services per worktree — define named commands in `paseo.json`, and long-running services get supervised with their own ports and nice proxy URLs like `http://web.my-app.localhost:6767`. See the [worktrees guide](https://paseo.sh/docs/worktrees).
|
||||
- Launch scripts and services for a worktree directly from the workspace header.
|
||||
- New Setup tab in every workspace showing setup, teardown, and script progress live.
|
||||
- GitHub checks and PR reviews in the explorer sidebar, with a hover card for the full breakdown.
|
||||
- New worktree creation flow lets you pick a base branch or check out an existing GitHub pull request.
|
||||
- Attach GitHub issues and pull requests to an agent as part of its prompt context.
|
||||
- Pull request pane in the workspace sidebar.
|
||||
- Redesigned Settings screen with modular section navigation.
|
||||
- Per-host provider configuration — set providers, models, and credentials independently on each remote host.
|
||||
- Direct Pi integration replaces the ACP bridge, with faster streaming and fewer hiccups.
|
||||
- Beta release channel — opt in from Settings to receive beta desktop builds before they are promoted to stable.
|
||||
- New-workspace picker ranks branches by recency with fast search.
|
||||
|
||||
### Improved
|
||||
|
||||
- Workspace and tab switching are dramatically faster on desktop and mobile — you can keep many workspaces open in parallel without lag.
|
||||
- Agent streams render more smoothly during heavy tool output.
|
||||
- App startup routes through a stable connection and lands on the right screen without flicker.
|
||||
- Provider refresh is reliable and no longer stalls on transient failures.
|
||||
- Git and GitHub state stay in sync with local changes like commits, branch switches, and pushes.
|
||||
- Composer attachments redesigned with a cleaner pill layout and an image lightbox.
|
||||
- In-app notifications route to whichever surface you're actually looking at.
|
||||
- Keyboard shortcuts keep working while Settings is open.
|
||||
- Escape reliably interrupts the active agent.
|
||||
- Checking out a pull request from a fork lands on an owner-prefixed branch so multiple forks don't collide.
|
||||
- `paseo ls` defaults to active agents; pass `-a` to include archived.
|
||||
- GitHub branch and PR picker loads faster — queries are deferred until the picker opens.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Composer textarea shrinks back down after sending on web.
|
||||
- New workspace drafts clear after submit instead of sticking around.
|
||||
- Replacing a running agent cleans up the previous one without leaving it behind.
|
||||
- Agent notifications no longer get swallowed by a backgrounded focused client.
|
||||
- Removed workspace folders disappear from the workspace list again.
|
||||
- Codex keeps fast mode after you approve a plan. ([#526](https://github.com/getpaseo/paseo/pull/526) by [@therainisme](https://github.com/therainisme))
|
||||
- Workspace tab focus is preserved across page refreshes.
|
||||
- Settings screen no longer pushes its header down with extra spacing.
|
||||
- Branch switcher title no longer overflows on narrow rows.
|
||||
- iOS image picker no longer leaves the screen unresponsive after cancelling.
|
||||
- Archiving a worktree recovers cleanly if a previous attempt was interrupted.
|
||||
- Images in agent messages with `~`-prefixed paths load instead of spinning forever.
|
||||
- Tool call blocks expand correctly on mobile while an agent is still streaming.
|
||||
- Timeline no longer stutters when catch-up and projected ranges overlap.
|
||||
- Codex no longer flashes idle when a replacement turn is in progress.
|
||||
- Branch state recovers correctly when a rebase is in progress.
|
||||
- Workspace hover card no longer clips near screen edges.
|
||||
|
||||
## 0.1.59 - 2026-04-16
|
||||
|
||||
### Added
|
||||
|
||||
- Opus 4.7 in the Claude model picker, with a 1M-context variant.
|
||||
- Extra High reasoning effort for Opus 4.7, between High and Max.
|
||||
|
||||
## 0.1.58 - 2026-04-16
|
||||
|
||||
### Added
|
||||
|
||||
- Markdown files render as formatted markdown in the file pane. ([#427](https://github.com/getpaseo/paseo/pull/427) by [@aaronflorey](https://github.com/aaronflorey))
|
||||
- Cmd+L (Ctrl+L on Windows/Linux) focuses the agent message input.
|
||||
- Provider models refresh on a freshness TTL; Settings shows last-updated time and any fetch errors. ([#426](https://github.com/getpaseo/paseo/pull/426))
|
||||
- `disallowedTools` option in provider config to block specific tools from an agent.
|
||||
|
||||
### Improved
|
||||
|
||||
- Windows: agents launch reliably from npm `.cmd` shims, paths with spaces, and JSON config args — fixes `spawn EINVAL` startup errors. ([#454](https://github.com/getpaseo/paseo/pull/454))
|
||||
- OpenCode permission prompts include the requesting tool's context. ([#398](https://github.com/getpaseo/paseo/pull/398) by [@aaronflorey](https://github.com/aaronflorey))
|
||||
- OpenCode todo and compaction events render in the timeline. ([#429](https://github.com/getpaseo/paseo/pull/429) by [@aaronflorey](https://github.com/aaronflorey))
|
||||
- OpenCode sessions archive cleanly when closed. ([#408](https://github.com/getpaseo/paseo/pull/408) by [@aaronflorey](https://github.com/aaronflorey))
|
||||
- OpenCode slash commands recover from SSE timeouts. ([#407](https://github.com/getpaseo/paseo/pull/407) by [@aaronflorey](https://github.com/aaronflorey))
|
||||
- Paseo MCP tools work against archived agents, matching the CLI. ([#423](https://github.com/getpaseo/paseo/pull/423))
|
||||
- Native scrollbars match the active theme across all web views. ([#399](https://github.com/getpaseo/paseo/pull/399) by [@ethersh](https://github.com/ethersh))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Code file previews can be selected and copied on iOS. ([#447](https://github.com/getpaseo/paseo/pull/447) by [@muzhi1991](https://github.com/muzhi1991))
|
||||
- File preview no longer shows stale content when reopening the same file. ([#411](https://github.com/getpaseo/paseo/pull/411) by [@muzhi1991](https://github.com/muzhi1991))
|
||||
- File explorer reinitialises when the client reconnects after a page refresh. ([#442](https://github.com/getpaseo/paseo/pull/442) by [@1996fanrui](https://github.com/1996fanrui))
|
||||
- Generic ACP providers no longer receive duplicated command arguments. ([#444](https://github.com/getpaseo/paseo/pull/444) by [@edvardchen](https://github.com/edvardchen))
|
||||
- Workspace headers no longer show a branch icon for non-git workspaces.
|
||||
- Branch switcher layout is stable on mobile.
|
||||
- Model names no longer truncate mid-word in the picker rows.
|
||||
- Messages appear in the correct order after reconnecting on mobile.
|
||||
- Clearing agent attention no longer throws on timeout.
|
||||
|
||||
## 0.1.56 - 2026-04-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- Projects with empty git repositories (no commits yet) no longer crash the app on startup.
|
||||
- A single problematic project can no longer prevent the rest of your workspaces from loading.
|
||||
|
||||
## 0.1.55 - 2026-04-14
|
||||
|
||||
### 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).
|
||||
- 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.
|
||||
- Cmd+, (Ctrl+, on Windows/Linux) opens settings.
|
||||
|
||||
### Improved
|
||||
|
||||
- Git operations are dramatically faster — workspace status, PR checks, and branch data all use a shared cached snapshot service instead of shelling out to git on every request. Running 20+ workspaces simultaneously is now smooth.
|
||||
- Windows support — the daemon and CLI run natively on Windows with proper shell quoting, executable resolution, and path handling.
|
||||
- iPad and tablet layouts work correctly across all screen sizes.
|
||||
- IME composition (Chinese, Japanese, Korean input) no longer submits prematurely when pressing Enter.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Creating a worktree no longer briefly flashes it as a standalone project before placing it under the correct repository.
|
||||
- Worktree creation spinner stays visible throughout the process instead of disappearing on mouse-out.
|
||||
- Workspace navigation updates correctly when switching between workspaces in the same project.
|
||||
- Desktop workspace header alignment and model selector no longer overflow on narrow windows.
|
||||
- Loading indicators are visible in light mode.
|
||||
|
||||
## 0.1.54 - 2026-04-12
|
||||
|
||||
### Added
|
||||
|
||||
- Inline image previews in agent messages — screenshots and images generated by agents render directly in the conversation instead of showing as raw markdown links.
|
||||
|
||||
### Improved
|
||||
|
||||
- Paseo tools are no longer injected into agents by default — opt in from Settings when you need agent-to-agent orchestration.
|
||||
- Agent provider and mode are now resolved server-side, so CLI commands like `paseo run` use consistent defaults without client-side lookups.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Shift+Enter now correctly inserts a newline in agent terminal input instead of submitting.
|
||||
- Windows: MCP configuration is no longer mangled when spawning Claude agents.
|
||||
- Branch ahead/behind count no longer errors for branches with no remote tracking branch.
|
||||
|
||||
## 0.1.53 - 2026-04-12
|
||||
|
||||
### Added
|
||||
|
||||
- Agents get Paseo tools automatically — every new agent gets access to terminals, schedules, worktrees, and other agents through MCP. Toggle it off in Settings under "Inject Paseo tools".
|
||||
- Git pull — pull remote changes directly from the workspace header. Promoted to the primary action when your branch is behind origin.
|
||||
- Child agent notifications — parent agents are automatically notified when a child agent finishes, errors, or needs permission approval.
|
||||
- Agent reload — `paseo agent reload` restarts an agent's underlying process from the CLI.
|
||||
- Middle-click to close tabs on desktop.
|
||||
- Keyboard shortcut to cycle themes.
|
||||
|
||||
### Improved
|
||||
|
||||
- Unavailable git actions now explain why in a toast instead of being silently greyed out.
|
||||
- Streaming markdown on mobile renders significantly faster.
|
||||
- Sidebar, branch switcher, and agent panel no longer re-render unnecessarily — noticeable on large workspaces.
|
||||
- Paseo tool calls in agent timelines show the Paseo logo and human-readable names.
|
||||
- Relay and pairing URLs are stripped from daemon logs.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Closed agent tabs no longer reappear after reconnecting.
|
||||
- Desktop notification badge counts match across all workspaces.
|
||||
- Host switcher status syncs correctly when switching between hosts.
|
||||
|
||||
## 0.1.52 - 2026-04-10
|
||||
|
||||
### Added
|
||||
|
||||
- Theme selector — choose from six themes including Midnight, Claude, and Ghostty dark variants.
|
||||
- Branch switching — switch git branches directly from the workspace header, with automatic stash and restore for uncommitted changes.
|
||||
- Auto-download updates — desktop updates download silently in the background so they're ready to install when you are.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Layout now responds correctly when resizing the window or rotating a tablet — previously the app could get stuck in mobile layout on a large screen.
|
||||
- Terminal no longer causes massive memory spikes from snapshot thrashing during heavy output.
|
||||
- Typing in the terminal works reliably — special keys, Ctrl combos, and paste are handled natively by the terminal emulator.
|
||||
- Initializing agents no longer show a loading spinner as if they're running.
|
||||
- Reconnecting to a running agent now works even when session persistence is unavailable.
|
||||
- Error screens on desktop are now scrollable.
|
||||
- Model list refreshes in the background when you open the model selector.
|
||||
- Draft agent feature preferences (like thinking mode) are remembered across sessions.
|
||||
|
||||
## 0.1.51 - 2026-04-09
|
||||
|
||||
### Added
|
||||
|
||||
- Image attachments for OpenCode — attach screenshots and images to OpenCode agent prompts.
|
||||
- WebStorm — added to the "Open in editor" list alongside Cursor, VS Code, and Zed.
|
||||
- Send behavior setting — choose whether pressing Enter while an agent is running interrupts immediately or queues your message.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Model selector no longer crashes on iPad.
|
||||
- Pairing now uses the correct hostname, fixing connection failures on some network setups.
|
||||
- OpenCode agents show the correct terminal state and refresh models reliably.
|
||||
- Follow-up messages to agents that just finished a turn now work correctly.
|
||||
- Commands now load properly for Pi agents.
|
||||
- Internal debug output no longer appears in Claude agent timelines.
|
||||
- QR scan screen cleaned up with simpler visuals.
|
||||
|
||||
## 0.1.50 - 2026-04-07
|
||||
|
||||
### Added
|
||||
|
||||
- Context window meter — see how much of the context window your agent has used, with color thresholds at 70% and 90%. Works with Claude Code, Codex, and OpenCode.
|
||||
- Open in editor — jump from any workspace straight into Cursor, VS Code, Zed, or your file manager. Paseo remembers your choice.
|
||||
- Side-by-side diffs — toggle between unified and split-column diff views, with a whitespace visibility option.
|
||||
- Spoken messages — when using voice mode, agent speech now appears as regular messages in the conversation instead of raw tool output.
|
||||
- Plan actions — plan cards now show the actions your agent supports (e.g. "Implement", "Deny") instead of generic accept/reject buttons.
|
||||
- Background git fetch — ahead/behind counts in the Changes pane stay up to date automatically.
|
||||
|
||||
### Improved
|
||||
|
||||
- Workspaces load instantly on connect instead of waiting for a full sync.
|
||||
- File explorer and diff pane remember which folders are expanded when you switch tabs.
|
||||
- Closing a workspace tab is now instant.
|
||||
- Settings shows a Refresh button for providers and displays error details inline.
|
||||
- Reload agent moved away from the close button to prevent accidental taps.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Voice mode no longer drifts into false speech detection during long sessions.
|
||||
- Garbled overlapping text on plan cards.
|
||||
- Changes pane could show stale diffs when working with git worktrees.
|
||||
- Restarting an agent quickly could crash the session.
|
||||
- Copilot no longer pauses for permission prompts in autopilot mode.
|
||||
- Connection and pairing dialogs now display correctly on tablets.
|
||||
- Orchestration errors from agents are now surfaced instead of silently lost.
|
||||
- Diff stats no longer reset to zero when reconnecting.
|
||||
|
||||
## 0.1.49 - 2026-04-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- Models and providers now load reliably on first connect instead of requiring a manual refresh.
|
||||
- Model picker only shows models from the agent's own provider, not every provider on the server.
|
||||
- Model lists stay consistent regardless of which screen you open first.
|
||||
|
||||
## 0.1.48 - 2026-04-05
|
||||
|
||||
### Added
|
||||
|
||||
- Provider diagnostics — tap a provider in Settings to see binary path, version, model count, and status at a glance. Helps troubleshoot why an agent type isn't available.
|
||||
- Provider snapshot system — daemon now pushes real-time provider availability and model lists to the app, replacing the old poll-based approach. Models and modes update live as providers come online or go offline.
|
||||
- Codex question handling — Codex agents can now ask the user questions mid-session (e.g. "which file?") and receive answers inline, matching the Claude Code question flow.
|
||||
- Reload tab action — right-click a workspace tab to reload its agent list without restarting the app.
|
||||
|
||||
### Improved
|
||||
|
||||
- Model selector redesigned — grouped by provider with status badges, search, and better touch targets on mobile.
|
||||
- Enter key now submits question card answers and confirms dictation, matching the expected keyboard flow.
|
||||
- Removed noisy agent lifecycle toasts that fired on every state change.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Desktop app now resolves the user's full login shell environment at startup, fixing tools like `codex`, `node`, `bun`, and `direnv` not being found when Paseo is launched from Finder or Dock. Terminals spawned by Paseo now inherit the same PATH and environment variables as a normal terminal session. Approach adapted from VS Code's battle-tested shell environment resolution.
|
||||
- Input field on running agent screens now correctly receives keyboard focus.
|
||||
- Mobile model selector alignment and sizing.
|
||||
|
||||
## 0.1.47 - 2026-04-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Voice TTS in Electron — sherpa now requests copied buffers and the voice MCP bridge sets `ELECTRON_RUN_AS_NODE`, preventing "external buffers not allowed" crashes.
|
||||
- QR pairing in desktop — CLI JSON output parsing now tolerates Node deprecation warnings in stdout.
|
||||
- STT segment race condition — segment ID and audio buffer are snapshotted before the async transcription call, so rapid commits no longer interleave.
|
||||
- Per-host "Add connection" button removed — it blocked multi-host setups by scoping new connections to a single server.
|
||||
|
||||
## 0.1.46 - 2026-04-04
|
||||
|
||||
### Fixed
|
||||
|
||||
- Voice activation in packaged builds — Silero VAD model is now copied out of the Electron asar archive so native code can read it.
|
||||
- App version sent in probe client hello so the daemon's version gate no longer hides Pi/Copilot from reconnected sessions.
|
||||
- `worktreeRoot` schema made backward-compatible for old clients and daemons that don't send the field.
|
||||
- Punycode deprecation warning (DEP0040) suppressed in CLI and desktop daemon entrypoints.
|
||||
|
||||
## 0.1.45 - 2026-04-04
|
||||
|
||||
### Added
|
||||
|
||||
- Pi (pi.dev) agent provider — connect Pi as a new ACP-based agent type with thinking levels and tool call support.
|
||||
- Copilot agent provider re-enabled after ACP compatibility fixes.
|
||||
- `paseo .` and `paseo <path>` open the desktop app with the given project, similar to `code .`.
|
||||
- Provider-declared features system — providers can expose dynamic toggles and selects that the app renders automatically. First consumer: Codex fast mode.
|
||||
- Codex plan mode — start agents in plan-only mode with a dedicated plan card UI for reviewing proposed changes before execution.
|
||||
- OpenCode custom agents and slash commands — user-defined agents from opencode.json now appear in the mode picker, and slash commands accept optional arguments.
|
||||
- Desktop Integrations settings — install the Paseo CLI and orchestration skills directly from the app without touching the terminal.
|
||||
- Daemon status dialog in desktop settings for quick health checks.
|
||||
- Auto-restart daemon on version mismatch — the desktop app detects when the running daemon is outdated and restarts it automatically.
|
||||
- Setup hint and paseo.sh link on the mobile welcome screen so new App Store users know what to do next.
|
||||
|
||||
### Improved
|
||||
|
||||
- Desktop startup is faster — existing daemon connections are raced against bootstrap so the app is usable sooner.
|
||||
- Settings sections reordered for better grouping (integrations and daemon together).
|
||||
- Sidebar projects and workspaces now persist across sessions, with a context menu to remove projects.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Sidebar crash when switching iOS theme (Unistyles/Reanimated interaction).
|
||||
- Silero VAD crash caused by external buffer mode in CircularBuffer.
|
||||
- Bulk close now correctly archives stored agents instead of leaving orphans.
|
||||
- Pinned archived agents are no longer pruned when closing tabs.
|
||||
- OpenCode event stream starvation during slash command execution.
|
||||
- Duplicate workspaces when multiple git worktrees share the same root.
|
||||
- `gh` executable resolution for desktop users whose login shell sets a different PATH.
|
||||
- Agent creation timeout increased to 60s to handle slow first-launch scenarios.
|
||||
- Forward-compatible provider handling so older app clients don't break on new provider types.
|
||||
- Input event listener race condition in the web scrollbar hook.
|
||||
- Open-project screen content now vertically centered.
|
||||
- Website download page fetches the release version at runtime with asset validation, fixing stale links.
|
||||
|
||||
## 0.1.44 - 2026-04-03
|
||||
|
||||
### Fixed
|
||||
|
||||
- Desktop app now stops the daemon cleanly before auto-update restarts.
|
||||
- Disabled claude-acp and copilot providers from the agent registry.
|
||||
- Keyboard focus scope resolution now checks multiple candidates for broader compatibility.
|
||||
- OpenCode interrupt now reaches correct terminal state parity with tool-call flows.
|
||||
- Shell injection, symlink escape, and pairing endpoint security hardening.
|
||||
|
||||
## 0.1.43 - 2026-04-02
|
||||
|
||||
### Added
|
||||
|
||||
- Copilot agent support via ACP base provider — connect GitHub Copilot as a new agent type.
|
||||
- Searchable model favorites — quickly find and pin preferred models.
|
||||
- Slash command support for OpenCode agents.
|
||||
|
||||
### Improved
|
||||
|
||||
- Refined model selector UX with better mobile sheet behavior.
|
||||
- Workspace status now uses amber alert styling for "needs input" state.
|
||||
- Themed scrollbar on message input for consistent styling.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Ctrl+C/V copy and paste now works correctly in the terminal on Windows and Linux.
|
||||
- Shell arguments with spaces are now properly quoted on Windows.
|
||||
- Claude models with 1M context support are now correctly reported.
|
||||
|
||||
## 0.1.42 - 2026-04-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Claude Code failing to launch on Windows when installed to a path with spaces (e.g. `C:\Program Files\...`).
|
||||
|
||||
## 0.1.41 - 2026-04-01
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed agent spawning on Windows — all providers (Claude, Codex, OpenCode) now use shell mode so npm shims and `.cmd` wrappers resolve correctly.
|
||||
- Fixed terminal creation on Windows defaulting to a Unix shell instead of `cmd.exe`.
|
||||
- Fixed path handling across the app to support Windows drive-letter paths (`C:\...`) and UNC paths (`\\...`).
|
||||
- Fixed executable resolution on Windows to work with `nvm4w` and similar Node version managers.
|
||||
- Eliminated white flash on window resize in dark mode by setting the native window background color to match the theme.
|
||||
- Fixed titlebar drag region — replaced the fragile pointer-event approach with VS Code's proven static CSS `app-region: drag` pattern.
|
||||
- Fixed context menu for copy/paste across the desktop app.
|
||||
- Fixed shortcut rebinding UI to show held modifier keys and recognize additional keys (Tab, Delete, Home, End, Page Up/Down, Insert, F1–F12).
|
||||
- Removed the 40-item cap on activity timeline output so long agent sessions display their full history.
|
||||
|
||||
### Improved
|
||||
|
||||
- Improved light mode theming with dedicated workspace background, scrollbar handle colors, and lighter shadows.
|
||||
- Window controls overlay on Windows/Linux reduced from 48px to 29px height for a more compact titlebar.
|
||||
|
||||
## 0.1.40 - 2026-04-01
|
||||
|
||||
### Added
|
||||
|
||||
- Workspace tabs can now be closed in batches.
|
||||
|
||||
### Improved
|
||||
|
||||
- Provider model lists are now cached per server and provider, reducing redundant model lookups in the UI.
|
||||
|
||||
### Fixed
|
||||
|
||||
- OpenCode reasoning content no longer appears duplicated as assistant text.
|
||||
- Daemon no longer crashes when a Codex binary is missing or fails to spawn.
|
||||
- Archive tab now correctly reconciles agent visibility after archiving.
|
||||
- File diff tracking in workspaces now works correctly on Linux.
|
||||
- iPad layout now renders correctly in desktop mode.
|
||||
- macOS auto-updater now correctly delivers both arm64 and x64 binaries — previously whichever architecture finished building last would overwrite the other's update manifest.
|
||||
|
||||
## 0.1.39 - 2026-03-30
|
||||
|
||||
### Added
|
||||
|
||||
- **Terminal management from the CLI** — new `paseo terminal` command group lets you list, create, and interact with workspace terminals without leaving your terminal.
|
||||
- **Material file icons in the explorer** — the file explorer tree now shows language-specific icons (TypeScript, JSON, Markdown, etc.) so you can spot files at a glance.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed iOS sidebar scroll flicker caused by redundant overflow clipping.
|
||||
- Centralized window controls padding into a shared hook, eliminating layout inconsistencies across platforms.
|
||||
|
||||
## 0.1.38 - 2026-03-30
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed daemon startup race where the app could time out connecting on first launch because the PID file advertised a listen address before the server was ready.
|
||||
- Fixed daemon log rotation losing startup traces — trace-level WebSocket logs no longer include full message payloads.
|
||||
|
||||
## 0.1.37 - 2026-03-29
|
||||
|
||||
### Added
|
||||
|
||||
- Custom window controls on Windows and Linux — the native titlebar is replaced with overlay controls that match the app's design.
|
||||
- Desktop file logging with electron-log for easier debugging of daemon and app issues.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed broken PATH propagation and Claude binary resolution on Windows.
|
||||
- Dictation errors now show a visible toast instead of failing silently.
|
||||
|
||||
## 0.1.36 - 2026-03-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Windows drive-letter path handling across the codebase.
|
||||
- Fixed stale Nix hash with automatic lockfile-change detection.
|
||||
|
||||
### Added
|
||||
|
||||
- Added metrics collection and terminal performance tests.
|
||||
|
||||
## 0.1.35 - 2026-03-26
|
||||
|
||||
### Improved
|
||||
|
||||
- Faster app startup by redirecting to the welcome screen immediately and showing host connection status inline.
|
||||
- Codex file deletions now display correctly as removed lines in diffs.
|
||||
- OpenCode questions are now surfaced in the permission UI.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed queued prompt dispatch after idle transition.
|
||||
- Replaced bash-only `mapfile` with a portable `while-read` loop in the chat script.
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for Nix and NixOS installation.
|
||||
|
||||
## 0.1.34 - 2026-03-25
|
||||
|
||||
### Added
|
||||
|
||||
- Added `paseo archive` as a top-level alias for `paseo agent archive`.
|
||||
- Added the `PASEO_AGENT_ID` environment variable for Claude and Codex agents.
|
||||
- Added a redesigned command autocomplete with a detail card and dropdown styling.
|
||||
- Linked Android download surfaces to the Google Play Store.
|
||||
|
||||
### Improved
|
||||
|
||||
- Autonomous turns now complete gracefully on interrupt instead of being canceled.
|
||||
- Thinking/model selection now always resolves to a real option instead of showing a generic Default choice.
|
||||
- Restored per-provider form preferences and removed the Auto model fallback.
|
||||
- Improved Codex activity logs with clearer tool-call summaries.
|
||||
- Reduced unnecessary re-renders in the agent panel and input area for smoother interaction.
|
||||
- Improved chat transcript readability.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `paseo send --no-wait` not taking effect.
|
||||
- Fixed stale abort results contaminating replacement turns after an interrupt.
|
||||
- Fixed Claude interrupt handling and autonomous wake reliability.
|
||||
- Fixed nested Claude Code session detection and provider availability checks.
|
||||
- Fixed agent input focus scoping across panels.
|
||||
- Fixed terminal snapshot ordering when subscribing.
|
||||
- Fixed `chat read --since` to accept message IDs.
|
||||
- Fixed keyboard pane focus syncing with the active panel.
|
||||
- Fixed assistant text selection on web.
|
||||
- Fixed archived-agent notifications still appearing in chat rooms.
|
||||
- Fixed the attach-images button interaction in the message composer.
|
||||
- Pruned wrong-platform native binaries from Electron desktop builds.
|
||||
|
||||
## 0.1.33 - 2026-03-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the desktop app failing to reopen after closing on macOS — the daemon and agent processes were registering with Launch Services as instances of the main app, blocking subsequent launches.
|
||||
- Fixed dictation not working in the packaged desktop app — the microphone entitlement was missing from the hardened runtime configuration.
|
||||
- Fixed leaked Claude Code child processes when agents were closed — the SDK query stream was not being properly shut down.
|
||||
- The notification test button now surfaces errors instead of failing silently.
|
||||
|
||||
## 0.1.32 - 2026-03-23
|
||||
|
||||
### Added
|
||||
|
||||
- Fully rebindable keyboard shortcuts with chord support — all shortcuts are now declarative with proper Cmd (Mac) vs Ctrl (Windows/Linux) separation.
|
||||
- Migrated the desktop app from Tauri to Electron, with macOS notarization, code signing, and Linux Wayland support.
|
||||
- Added line numbers and word-wrap toggle to file previews.
|
||||
- Added an archived agent callout with an unarchive button so you can restore agents directly from the chat view.
|
||||
- Added workspace kind indicators in the sidebar (e.g. worktree vs standalone).
|
||||
- Expanded diff syntax highlighting to cover more languages.
|
||||
- Added status bar tooltips for project and agent status.
|
||||
|
||||
### Improved
|
||||
|
||||
- Redesigned the mobile tab switcher as a compact header row with quick access to new agents and terminals.
|
||||
- Streamlined workspace creation — worktrees are now created inline with a single action instead of a multi-step flow.
|
||||
- Agent history now streams from disk on reconnect, so you see past messages immediately instead of a blank screen.
|
||||
- Automatic cleanup of stale workspaces: deleted worktree directories and fully-archived workspaces are pruned automatically.
|
||||
- After archiving a workspace, the app now redirects to the next available workspace instead of leaving you on a dead screen.
|
||||
- Reopening an archived agent tab now keeps it open instead of collapsing back to archived state.
|
||||
- Reduced unnecessary re-renders across the workspace screen, sidebar, and agent list for smoother scrolling and interaction.
|
||||
- Agent list no longer refreshes in the background when the screen is unfocused, saving resources.
|
||||
- Desktop key repeat now works correctly on macOS.
|
||||
- Desktop notifications on macOS are more reliable.
|
||||
- Daemon startup no longer blocks on model downloads.
|
||||
- Better error messages from the daemon — RPC errors now include the actual underlying details.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed user messages appearing as assistant output in the timeline when messages contained structured content blocks.
|
||||
- Fixed archived workspace routing so navigating to an archived session no longer breaks the app.
|
||||
- Fixed Linux AppImage failing to launch on Wayland-only desktops.
|
||||
- Fixed desktop window drag coordinates being applied when they shouldn't be.
|
||||
|
||||
## 0.1.30 - 2026-03-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added terminal tabs, split pane controls, and drop previews for workspace layouts.
|
||||
- Added a combined model selector and agent mode visuals across key UI surfaces.
|
||||
- Added Open Graph metadata improvements for richer website sharing previews.
|
||||
|
||||
### Improved
|
||||
|
||||
- Improved workspace navigation with better active-workspace tracking and keyboard-driven pane interactions.
|
||||
- Improved terminal scrollbar behavior, pane focus handling, and status bar/message input spacing.
|
||||
- Improved project picker path display and general workspace UI polish.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed agent startup reliability by tightening PATH resolution and surfacing missing provider binaries in status.
|
||||
- Fixed workspace route syncing, drag hit areas, and git diff panel header styling regressions.
|
||||
- Fixed website mobile horizontal scrolling and ensured the workspace audio module builds during EAS installs.
|
||||
|
||||
## 0.1.28 - 2026-03-15
|
||||
|
||||
### Added
|
||||
|
||||
- Added OpenCode build and plan modes.
|
||||
- Added website landing pages for Claude Code, Codex, and OpenCode.
|
||||
|
||||
### Improved
|
||||
|
||||
- Improved the git action menu for more reliable repository actions.
|
||||
- Improved the mobile settings screen, workspace header actions, and welcome screen presentation.
|
||||
- Updated the website hero copy and added a sponsor callout section.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed assistant file links so they open the correct workspace files from chat.
|
||||
|
||||
## 0.1.27 - 2026-03-13
|
||||
|
||||
### Added
|
||||
|
||||
- Added voice runtime with new audio engine architecture for voice interactions.
|
||||
- Added Grep tool support in Claude tool-call mapping.
|
||||
- Added ability to open workspace files directly from agent chat messages.
|
||||
- Added desktop notifications via a custom native bridge.
|
||||
|
||||
### Improved
|
||||
|
||||
- Improved image picker, markdown rendering, and UI interactions.
|
||||
- Improved shell environment detection using shell-env.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed platform-specific markdown link rendering.
|
||||
- Fixed Linux AppImage CLI resource paths.
|
||||
- Fixed Codex replacement stream being killed by stale turn notifications.
|
||||
|
||||
## 0.1.26 - 2026-03-12
|
||||
|
||||
### Added
|
||||
|
||||
- Added single-instance desktop behavior, Android APK download access, and refreshed splash screen styling.
|
||||
- Added bundled Codex and OpenCode binaries in the server so setup no longer depends on global installs.
|
||||
- Added Windows support with improved cross-platform shell execution.
|
||||
|
||||
### Improved
|
||||
|
||||
- Improved desktop runtime behavior on Windows by suppressing console windows and defaulting app data to `~/.paseo`.
|
||||
- Added a Discord link to the website navigation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed desktop Claude agent startup from the managed runtime and rotated logs correctly on restart.
|
||||
- Fixed the home route to hide browser chrome when appropriate.
|
||||
- Fixed Expo Metro compatibility by updating the `exclusionList` import.
|
||||
- Fixed noisy shell output interfering with executable lookup.
|
||||
- Fixed Windows resource-path handling by stripping the extended-length path prefix.
|
||||
|
||||
## 0.1.25 - 2026-03-11
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed desktop app failing to start the built-in daemon on fresh macOS installs. The DMG was not notarized and code-signing stripped entitlements from the bundled Node runtime, causing Gatekeeper to block execution.
|
||||
- Fixed Linux AppImage build by restoring the AppImage bundle format and stripping CUDA dependencies from onnxruntime.
|
||||
|
||||
## 0.1.24 - 2026-03-10
|
||||
|
||||
### Improved
|
||||
|
||||
- Improved command center keyboard navigation and new tab shortcut.
|
||||
- Simplified desktop release pipeline for faster and more reliable builds.
|
||||
|
||||
## 0.1.21 - 2026-03-10
|
||||
|
||||
### Improved
|
||||
|
||||
- Improved desktop release reliability by fixing the Windows managed-runtime build path during GitHub Actions releases.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a desktop release CI failure caused by a Unix-only server build script on Windows runners.
|
||||
- Fixed server CI to build the relay dependency before running tests, restoring relay E2EE test coverage on clean runners.
|
||||
- Fixed a Claude redesign test that depended on the local Claude CLI being installed.
|
||||
|
||||
## 0.1.20 - 2026-03-10
|
||||
|
||||
### Added
|
||||
|
||||
- Added workspace sidebar git actions with quick diff stats and archive controls.
|
||||
- Added refreshed website downloads and homepage presentation for desktop installs.
|
||||
|
||||
### Improved
|
||||
|
||||
- Desktop release packaging now rebuilds and validates the bundled managed runtime during CI, improving installer reliability for macOS users.
|
||||
- Improved desktop and web stream rendering, settings polish, and React 19.1.4 compatibility.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Claude interrupt/restart regressions and strengthened managed-daemon smoke coverage for desktop releases.
|
||||
|
||||
## 0.1.19 - 2026-03-09
|
||||
|
||||
### Added
|
||||
|
||||
- Added a draft GitHub release flow so maintainers can upload and review desktop and Android release assets before publishing the final release.
|
||||
|
||||
## 0.1.18 - 2026-03-06
|
||||
|
||||
### Added
|
||||
|
||||
- Added a desktop `Mod+W` shortcut to close the current tab.
|
||||
|
||||
### Improved
|
||||
|
||||
- New and newly selected terminals now take focus automatically so you can type immediately.
|
||||
- Kept newly created workspaces and projects in a more stable order in the sidebar.
|
||||
- Improved project naming for GitHub remotes and expanded project icon discovery to Phoenix `priv/static` assets.
|
||||
- Updated the website desktop download link to use the universal macOS DMG.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restored automatic agent metadata generation for Claude runs.
|
||||
|
||||
## 0.1.17 - 2026-03-06
|
||||
|
||||
### Added
|
||||
|
||||
- New workspace-first navigation model with workspace tabs, file tabs, and sortable tab groups.
|
||||
- Keyboard shortcuts for workspace and tab navigation, with shortcut badges in the sidebar.
|
||||
- Workspace-level archive actions with improved worktree archiving flow and context menu support.
|
||||
- In-chat task notifications rendered as synthetic tool-call events for clearer status tracking.
|
||||
|
||||
### Improved
|
||||
|
||||
- Desktop builds now ship as a universal macOS binary (Apple Silicon + Intel).
|
||||
- More reliable workspace routing and tab identity handling across refreshes and deep links.
|
||||
- Better sidebar drag-and-drop behavior with explicit drag handles and nested list interactions.
|
||||
- Smoother terminal/file rendering and WebGL-backed terminal performance improvements.
|
||||
- Stronger provider error surfacing and updated Claude model/runtime handling.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed orphan workspace runs caused by non-canonical tab routes.
|
||||
- Fixed mobile terminal tab remount/routing restore issues.
|
||||
- Fixed agent metadata title/branch update reliability.
|
||||
- Fixed stream/timeline ordering and cursor synchronization issues in the app.
|
||||
- Fixed reversed edge-wheel scroll behavior in chat/tool stream views.
|
||||
|
||||
## 0.1.16 - 2026-02-22
|
||||
|
||||
### Added
|
||||
|
||||
- Update the Paseo desktop app and local daemon directly from Settings.
|
||||
- Microphone and notification permission controls in Settings.
|
||||
- Thinking/reasoning mode — agents can use extended thinking when the provider supports it.
|
||||
- Autonomous run mode — let agents keep working without manual approval at each step.
|
||||
- `paseo wait` now shows a snapshot of recent agent activity while you wait.
|
||||
|
||||
### Improved
|
||||
|
||||
- Smoother streaming with less UI flicker and scroll jumping during long agent runs.
|
||||
- Faster agent sidebar list rendering.
|
||||
- Archiving an agent now stops it first instead of archiving a half-running session.
|
||||
- Agent titles no longer reset when refreshing.
|
||||
- More reliable relay connections.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Claude background tasks desyncing the chat.
|
||||
- Fixed duplicate user messages appearing in the timeline.
|
||||
- Fixed a startup crash caused by an OpenCode SDK update.
|
||||
- Fixed spurious "needs attention" notifications from background agent activity.
|
||||
|
||||
## 0.1.15 - 2026-02-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added a public changelog page on the website so users can browse release notes.
|
||||
|
||||
### Improved
|
||||
|
||||
- Redesigned the website get-started experience into a clearer two-step flow.
|
||||
- Simplified website GitHub navigation and changelog headings.
|
||||
- Improved app draft/new-agent UX with clearer working directory placeholder and empty-state messaging.
|
||||
- Enabled drag interactions in previously unhandled areas on the desktop draft screen.
|
||||
- Hid empty filter groups in the left sidebar.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed archived-agent navigation by redirecting archived agent routes to draft.
|
||||
- Fixed duplicate `/rewind` user-message behavior.
|
||||
|
||||
## 0.1.14 - 2026-02-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added Claude `/rewind` command support.
|
||||
- Added slash command access in the draft agent composer.
|
||||
- Added `@` workspace file autocomplete in chat prompts.
|
||||
- Added support for pasting images directly into prompt attachments.
|
||||
- Added optimistic image previews for pending user message attachments.
|
||||
- Added shared desktop/web overlay scroll handles, including file preview panes.
|
||||
|
||||
### Improved
|
||||
|
||||
- Improved worktree flow after shipping, including better merged PR detection.
|
||||
- Improved draft workflow by enabling the explorer sidebar immediately after CWD selection.
|
||||
- Improved new worktree-agent defaults by prefilling CWD to the main repository.
|
||||
- Improved desktop command autocomplete behavior to match combobox interactions.
|
||||
- Improved git sync UX by simplifying sync labels and only showing Sync when a branch diverges from origin.
|
||||
- Improved desktop settings and permissions UX on desktop.
|
||||
- Improved scrollbar visibility, drag interactions, tracking, and animation timing on web/desktop.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed worktree archive/setup lifecycle issues, including terminal cleanup and archive timing.
|
||||
- Fixed worktree path collisions by hashing CWD for collision-safe worktree roots.
|
||||
- Fixed terminal sizing when switching back to an agent session.
|
||||
- Fixed accidental terminal closure risk by adding confirmation for running shell commands.
|
||||
- Fixed archive loading-state consistency across the sidebar and agent screen.
|
||||
- Fixed autocomplete popover stability and workspace suggestion ranking.
|
||||
- Fixed dictation timeouts caused by dangling non-final segments.
|
||||
- Fixed server lock ownership when spawned as a child process by using parent PID ownership.
|
||||
- Fixed hidden directory leakage in server CWD suggestions.
|
||||
- Fixed agent attention notification payload consistency across providers.
|
||||
- Fixed daemon version badge visibility in settings when daemon version data is unavailable.
|
||||
|
||||
## 0.1.9 - 2026-02-17
|
||||
|
||||
### Improved
|
||||
|
||||
- Unified structured-output generation through a single shared schema-validation and retry pipeline.
|
||||
- Reused provider availability checks for structured generation fallback selection.
|
||||
- Added structured generation waterfall ordering for internal metadata and git text generation: Claude Haiku, then Codex, then OpenCode.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed CLI `run --output-schema` to use the shared structured-output path instead of ad-hoc JSON parsing.
|
||||
- Fixed `run --output-schema` failures where providers returned empty `lastMessage` by recovering from timeline assistant output.
|
||||
- Fixed internal commit message, pull request text, and agent metadata generation to follow one consistent structured pipeline.
|
||||
|
||||
## [0.1.8] - 2026-02-17
|
||||
## 0.1.8 - 2026-02-17
|
||||
|
||||
### Added
|
||||
|
||||
- Added a cross-platform confirm dialog flow for daemon restarts.
|
||||
|
||||
### Improved
|
||||
|
||||
- Simplified local speech bootstrap and daemon startup locking behavior.
|
||||
- Updated website hero copy to emphasize local execution.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed stuck "send while running" recovery across app and server session handling.
|
||||
- Fixed Claude session identity preservation when reloading existing agents.
|
||||
- Fixed combobox option behavior and related interactions.
|
||||
- Fixed Tauri file-drop listener cleanup to avoid uncaught unlisten errors.
|
||||
- Fixed desktop file-drop listener cleanup to avoid uncaught unlisten errors.
|
||||
- Fixed web tool-detail wheel event routing at scroll edges.
|
||||
|
||||
## [0.1.7] - 2026-02-16
|
||||
## 0.1.7 - 2026-02-16
|
||||
|
||||
### Added
|
||||
|
||||
- Improved agent workspace flows with better directory suggestions.
|
||||
- Added iOS TestFlight and Android app access request forms on the website.
|
||||
|
||||
### Improved
|
||||
|
||||
- Unified daemon startup behavior between dev and CLI paths for more predictable local runs.
|
||||
- Improved website app download and update guidance.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevented an initial desktop combobox `0,0` position flash.
|
||||
- Fixed CLI version output issues.
|
||||
- Hardened server runtime loading for local speech dependencies.
|
||||
|
||||
## [0.1.6] - 2026-02-16
|
||||
## 0.1.6 - 2026-02-16
|
||||
|
||||
### Notes
|
||||
|
||||
- No major visible product changes in this patch release.
|
||||
|
||||
## [0.1.5] - 2026-02-16
|
||||
## 0.1.5 - 2026-02-16
|
||||
|
||||
### Added
|
||||
|
||||
- Added terminal reattach support and better worktree terminal handling.
|
||||
- Added global keyboard shortcut help in the app.
|
||||
- Added sidebar host filtering and improved agent workflow controls.
|
||||
|
||||
### Improved
|
||||
|
||||
- Improved worktree setup visibility by streaming setup progress.
|
||||
- Improved terminal streaming reliability and lifecycle handling.
|
||||
- Preserved explorer tab state so context survives navigation better.
|
||||
|
||||
## [0.1.4] - 2026-02-14
|
||||
## 0.1.4 - 2026-02-14
|
||||
|
||||
### Added
|
||||
|
||||
- Added voice capability status reporting in the client.
|
||||
- Added background local speech model downloads with runtime gating.
|
||||
- Added adaptive dictation finish timing based on server-provided budgets.
|
||||
- Added relay reconnect behavior with grace periods and branch suggestions.
|
||||
|
||||
### Improved
|
||||
|
||||
- Improved connection selection and agent hydration reliability.
|
||||
- Improved timeline loading with cursor-based fetch behavior.
|
||||
- Improved first-run experience by bootstrapping a default localhost connection.
|
||||
- Improved inline code rendering by auto-linkifying URLs.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed Linux checkout diff watch behavior to avoid recursive watches.
|
||||
- Fixed stale relay client timer behavior.
|
||||
- Fixed unnecessary git diff header auto-scroll on collapse.
|
||||
|
||||
## [0.1.3] - 2026-02-12
|
||||
## 0.1.3 - 2026-02-12
|
||||
|
||||
### Added
|
||||
|
||||
- Added CLI onboarding command.
|
||||
- Added CLI `--output-schema` support for structured agent output.
|
||||
- Added CLI agent metadata update support for names and labels.
|
||||
- Added provider availability detection with normalization of legacy default model IDs.
|
||||
|
||||
### Improved
|
||||
|
||||
- Improved file explorer refresh feedback and unresolved checkout fallback handling.
|
||||
- Added better voice interrupt handling with a speech-start grace period.
|
||||
- Improved CLI defaults to list all non-archived agents by default.
|
||||
- Improved website UX with clearer install CTA and privacy policy access.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed dev runner entry issues and sherpa TTS initialization behavior.
|
||||
|
||||
## [0.1.2] - 2026-02-11
|
||||
## 0.1.2 - 2026-02-11
|
||||
|
||||
### Notes
|
||||
|
||||
- No major visible product changes in this patch release.
|
||||
|
||||
## [0.1.1] - 2026-02-11
|
||||
## 0.1.1 - 2026-02-11
|
||||
|
||||
### Added
|
||||
|
||||
- Initial `0.1.x` release line.
|
||||
|
||||
293
CLAUDE.md
293
CLAUDE.md
@@ -1,222 +1,111 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket.
|
||||
|
||||
**Key features:**
|
||||
- Real-time streaming of agent output
|
||||
- Voice commands for hands-free interaction
|
||||
- Push notifications when tasks complete
|
||||
- Multi-agent orchestration across projects
|
||||
|
||||
**Not a cloud sandbox** - Paseo connects directly to your actual development environment. Your code stays on your machine.
|
||||
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine.
|
||||
|
||||
**Supported agents:** Claude Code, Codex, and OpenCode.
|
||||
|
||||
## Monorepo Structure
|
||||
## Repository map
|
||||
|
||||
This is an npm workspace monorepo:
|
||||
|
||||
- **packages/server**: The Paseo daemon that runs on your machine. Manages agent processes, provides WebSocket API for real-time streaming, and exposes an MCP server for agent control.
|
||||
- **packages/app**: Cross-platform client (Expo). Connects to one or more servers, displays agent output, handles voice input, and sends push notifications.
|
||||
- **packages/cli**: The `paseo` CLI that is used to manage the deamon, and acts as a client to it with Docker-style commands like `paseo run/ls/logs/wait`
|
||||
- **packages/website**: Marketing site at paseo.sh (TanStack Router + Cloudflare Workers).
|
||||
- `packages/server` — Daemon: agent lifecycle, WebSocket API, MCP server
|
||||
- `packages/app` — Mobile + web client (Expo)
|
||||
- `packages/cli` — Docker-style CLI (`paseo run/ls/logs/wait`)
|
||||
- `packages/relay` — E2E encrypted relay for remote access
|
||||
- `packages/desktop` — Electron desktop wrapper
|
||||
- `packages/website` — Marketing site (paseo.sh)
|
||||
|
||||
## Development Server
|
||||
## Documentation
|
||||
|
||||
The `npm run dev` script automatically picks an available port for the development server.
|
||||
| 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 |
|
||||
|
||||
When running in a worktree or alongside the main checkout, set `PASEO_HOME` to isolate state:
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
PASEO_HOME=~/.paseo-blue npm run dev
|
||||
npm run dev # Start daemon + Expo in Tmux
|
||||
npm run cli -- ls -a -g # List all agents
|
||||
npm run cli -- daemon status # Check daemon status
|
||||
npm run typecheck # Always run after changes
|
||||
npm run lint # Always run after changes
|
||||
npm run format # Auto-format with Biome
|
||||
npm run format:check # Check formatting without writing
|
||||
```
|
||||
|
||||
- `PASEO_HOME` – path for runtime state (agent data, sockets, etc.). Defaults to `~/.paseo`; set this to a unique directory when running a secondary server instance.
|
||||
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
|
||||
|
||||
## Running and checking logs
|
||||
## Critical rules
|
||||
|
||||
Both the server and Expo app are running in a Tmux session. See CLAUDE.local.md for system-specific session details.
|
||||
- **NEVER restart the main Paseo daemon on port 6767 without permission** — it manages all running agents. If you're an agent, restarting it kills your own process.
|
||||
- **NEVER assume a timeout means the service needs restarting** — timeouts can be transient.
|
||||
- **NEVER add auth checks to tests** — agent providers handle their own auth.
|
||||
- **NEVER run the full test suite locally.** The test suites are heavy and will freeze the machine, especially if multiple agents run them in parallel. Rules:
|
||||
- Run only the specific test file you changed: `npx vitest run <file> --bail=1`
|
||||
- Never run `npm run test` for an entire workspace unless explicitly asked.
|
||||
- If you must run a broad suite, pipe output to a file and read it afterward: `npx vitest run <file> --bail=1 > /tmp/test-output.txt 2>&1` then read the file.
|
||||
- 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.**
|
||||
- **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?"
|
||||
|
||||
## Platform gating
|
||||
|
||||
The app runs on iOS, Android, web (browser), and web (Electron desktop). Code is cross-platform by default. Gate only when you must. Import gates from `@/constants/platform`.
|
||||
|
||||
### The four gates
|
||||
|
||||
| Gate | Type | When to use |
|
||||
| -------------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `isWeb` | constant | DOM APIs — `document`, `window`, `<div>`, `addEventListener`, `ResizeObserver`. This is the **exception**, not the default. |
|
||||
| `isNative` | constant | Native-only APIs — Haptics, `StatusBar.currentHeight`, push tokens, camera/scanner, `expo-av`. |
|
||||
| `getIsElectron()` | cached fn | Desktop wrapper features — file dialogs, titlebar drag region, daemon management, app updates, dock badges. |
|
||||
| `useIsCompactFormFactor()` | hook | Layout decisions — sidebar overlay vs pinned, modal vs full screen, single-panel vs split. From `@/constants/layout`. |
|
||||
|
||||
### Decision matrix
|
||||
|
||||
| I need to... | Use |
|
||||
| -------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| Access DOM (`document`, `window`, `<div>`, `addEventListener`) | `if (isWeb)` |
|
||||
| Use a native-only API (Haptics, push tokens, camera) | `if (isNative)` |
|
||||
| Use an Electron bridge (file dialog, titlebar, updates) | `if (getIsElectron())` |
|
||||
| Switch layout between phone and tablet/desktop | `useIsCompactFormFactor()` |
|
||||
| Show something on hover, always-visible on native | `isHovered \|\| isNative \|\| isCompact` (hover only works on web) |
|
||||
| Gate to iOS or Android specifically | `Platform.OS === "ios"` / `Platform.OS === "android"` (rare, keep inline) |
|
||||
|
||||
### Rules
|
||||
|
||||
- **Default is cross-platform.** Don't gate unless you have a specific reason.
|
||||
- **Prefer Metro file extensions over `if` statements.** When a module has fundamentally different implementations per platform, use `.web.ts` / `.native.ts` file extensions instead of runtime `if (isWeb)` branches. Metro resolves the correct file at build time — the unused platform code is never bundled. Reserve `if (isWeb)` for small, inline checks (a single line or a few props). If you find yourself writing a large `if (isWeb) { ... } else { ... }` block, split into separate files instead.
|
||||
```
|
||||
hooks/
|
||||
use-audio-recorder.web.ts ← uses Web Audio API
|
||||
use-audio-recorder.native.ts ← uses expo-audio
|
||||
```
|
||||
Import as `@/hooks/use-audio-recorder` — Metro picks the right file automatically.
|
||||
- **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.
|
||||
- **Don't use Platform.OS as a proxy for layout capabilities.** Use breakpoints for layout decisions, not platform checks.
|
||||
- **Import `isWeb`/`isNative` from `@/constants/platform`.** Never write `const isWeb = Platform.OS === "web"` locally.
|
||||
|
||||
## Debugging
|
||||
|
||||
### Daemon and CLI
|
||||
|
||||
The Paseo daemon communicates via WebSocket. In the main checkout:
|
||||
- Daemon runs at `localhost:6767`
|
||||
- Expo app at `localhost:8081`
|
||||
- State lives in `$PASEO_HOME`
|
||||
|
||||
In worktrees or when running `npm run dev`, ports and home directories may differ. Never assume the defaults.
|
||||
|
||||
Use `npm run cli` to run the local CLI (instead of the globally linked `paseo` which points to the main checkout). Always run `npm run cli -- --help` or load the `/paseo` skill before using it - do not guess commands.
|
||||
|
||||
Use `--host <host:port>` to point the CLI at a different daemon (e.g., `--host localhost:7777`).
|
||||
|
||||
### Quick reference CLI commands
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### Agent state
|
||||
|
||||
Agent data is stored at:
|
||||
```
|
||||
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
```
|
||||
|
||||
To find an agent by ID:
|
||||
```bash
|
||||
find $PASEO_HOME/agents -name "{agent-id}.json"
|
||||
```
|
||||
|
||||
To find an agent by title or other content:
|
||||
```bash
|
||||
rg -l "some title text" $PASEO_HOME/agents/
|
||||
rg -l "spiteful-toad" $PASEO_HOME/agents/
|
||||
```
|
||||
|
||||
### Provider session files
|
||||
|
||||
Get the session ID from the agent JSON file (`persistence.sessionId`), then:
|
||||
|
||||
**Claude sessions:**
|
||||
```
|
||||
~/.claude/projects/{cwd-with-dashes}/{session-id}.jsonl
|
||||
```
|
||||
|
||||
**Codex sessions:**
|
||||
```
|
||||
~/.codex/sessions/{YYYY}/{MM}/{DD}/rollout-{timestamp}-{session-id}.jsonl
|
||||
```
|
||||
|
||||
## Android
|
||||
|
||||
Take screenshots like this: `adb exec-out screencap -p > screenshot.png`
|
||||
|
||||
### Android variants (vanilla Expo)
|
||||
|
||||
Use `APP_VARIANT` in `packages/app/app.config.js` to control app name + package ID (no custom Gradle flavor plugin):
|
||||
|
||||
- `production` -> app name `Paseo`, package `sh.paseo`
|
||||
- `development` -> app name `Paseo Debug`, package `sh.paseo.debug`
|
||||
|
||||
EAS profiles live in `packages/app/eas.json` as `development`, `production`, and `production-apk`.
|
||||
|
||||
`development` uses Android `debug`.
|
||||
|
||||
### Local build + install (Android device)
|
||||
|
||||
From `packages/app`:
|
||||
|
||||
```bash
|
||||
# development (debug)
|
||||
APP_VARIANT=development npx expo prebuild --platform android --clean --non-interactive
|
||||
APP_VARIANT=development npx expo run:android --variant=debug
|
||||
|
||||
# production (release)
|
||||
APP_VARIANT=production npx expo prebuild --platform android --clean --non-interactive
|
||||
APP_VARIANT=production npx expo run:android --variant=release
|
||||
```
|
||||
|
||||
From repo root:
|
||||
|
||||
```bash
|
||||
npm run android:development
|
||||
npm run android:production
|
||||
```
|
||||
|
||||
`npm run android:prod` and `npm run android:release` are aliases for `npm run android:production`.
|
||||
|
||||
### Cloud build + submit (EAS Workflows)
|
||||
|
||||
Tag pushes like `v0.1.0` trigger `packages/app/.eas/workflows/release-mobile.yml` on Expo servers.
|
||||
Tag pushes like `v0.1.0` also trigger `.github/workflows/android-apk-release.yml` on GitHub Actions to publish an APK asset on the matching GitHub Release.
|
||||
|
||||
That workflow does:
|
||||
- Build iOS with the `production` profile
|
||||
- Build Android with the `production` profile
|
||||
- Submit each build with the `production` submit profile
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
# List recent mobile workflow runs
|
||||
cd packages/app && npx eas workflow:runs --workflow release-mobile.yml --limit 10
|
||||
|
||||
# Inspect one run (jobs, status, outputs)
|
||||
cd packages/app && npx eas workflow:view <run-id>
|
||||
|
||||
# Stream logs for all steps in one failed job
|
||||
cd packages/app && npx eas workflow:logs <job-id> --non-interactive --all-steps
|
||||
```
|
||||
|
||||
## Testing with Playwright MCP
|
||||
|
||||
**CRITICAL:** When asked to test the app, you MUST use the Playwright MCP connecting to Metro at `http://localhost:8081`.
|
||||
|
||||
Use the Playwright MCP to test the app in Metro web. Navigate to `http://localhost:8081` to interact with the app UI.
|
||||
|
||||
**Important:** 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 navigation breaks the state.
|
||||
|
||||
## Expo troubleshooting
|
||||
|
||||
Run `npx expo-doctor` to diagnose version mismatches and native module issues.
|
||||
|
||||
## Release playbook
|
||||
|
||||
Use the scripted release flow from repo root. Avoid manual version bumps, manual tags, or ad hoc publish commands unless debugging.
|
||||
|
||||
```bash
|
||||
# Recommended: full patch release (bump, check, publish, push branch+tag)
|
||||
npm run release:patch
|
||||
|
||||
# Manual, step-by-step fallback:
|
||||
npm run version:all:patch # npm version across all workspaces (creates commit + local tag)
|
||||
npm run release:check
|
||||
npm run release:publish
|
||||
npm run release:push # pushes HEAD and current version tag (triggers desktop + Android APK + EAS mobile workflows)
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `version:all:*` bumps the root package version and runs the root `version` lifecycle script to sync workspace versions and internal `@getpaseo/*` dependency versions before the release commit/tag is created.
|
||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale local package types during release checks.
|
||||
- If `release:publish` fails after a successful publish of one workspace, re-run `npm run release:publish`; npm will skip already-published versions and continue where possible.
|
||||
- If a user asks to "release paseo" (without specifying major/minor), treat it as a patch release and run `npm run release:patch`.
|
||||
- All workspaces share one version by design. Keep versions synchronized and release together.
|
||||
- The website Mac download CTA URL is derived from `packages/website/package.json` version at build time, so no manual update is required after release.
|
||||
|
||||
Release completion checklist:
|
||||
- `npm run release:patch` completes successfully.
|
||||
- GitHub `Desktop Release` workflow for the new `v*` tag is green.
|
||||
- GitHub `Android APK Release` workflow for the same tag is green.
|
||||
- EAS `release-mobile.yml` workflow for the same tag is green (Expo queues can take longer on the free plan).
|
||||
|
||||
## Orchestrator Mode
|
||||
|
||||
- **When agent control tool calls fail**, make sure you list agents before trying to launch another one. It could just be a wait timeout.
|
||||
- **Always prefix agent titles** so we can tell which ones are running under you (e.g., "🎭 Feature Implementation", "🎭 Design Discussion").
|
||||
- **Launch agents in the most permissive mode**: Use full access or bypass permissions mode.
|
||||
- **Set cwd to the repository root** - The agent's working directory should usually be the repo root
|
||||
|
||||
**CRITICAL: ALWAYS RUN TYPECHECK AFTER EVERY CHANGE.**
|
||||
|
||||
## Agent Authentication
|
||||
|
||||
All agent providers (Claude, Codex, OpenCode) handle their own authentication outside of environment variables. They are authenticated without providing any extra configuration—Paseo does not manage API keys or tokens for agents.
|
||||
|
||||
**Do not add auth checks to tests.** If auth fails for whatever reason, let the user know instead of patching the code or adding conditional skips.
|
||||
|
||||
## NEVER DO THESE THINGS
|
||||
|
||||
- **NEVER restart the main Paseo daemon on port 6767 without permission** - This is the production daemon that launches and manages agents. If you are reading this, you are probably running as an agent under it. Restarting it will kill your own process and all other running agents. The daemon is managed by the user in Tmux.
|
||||
- **NEVER assume a timeout means the service needs restarting** - Timeouts can be transient network issues, not service failures
|
||||
- **NEVER add authentication checks to tests** - Agent providers handle their own auth. If tests fail due to auth issues, report it rather than adding conditional skips or env var checks
|
||||
Find the complete daemon logs and traces in the $PASEO_HOME/daemon.log
|
||||
|
||||
168
CONTRIBUTING.md
Normal file
168
CONTRIBUTING.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Contributing to Paseo
|
||||
|
||||
Thanks for taking the time to contribute.
|
||||
|
||||
## How this project works
|
||||
|
||||
Paseo is a BDFL project. Product direction, scope, and what ships are the maintainer's call.
|
||||
|
||||
This means:
|
||||
|
||||
- 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.
|
||||
|
||||
This is not meant to discourage contributions. It is meant to set clear expectations so nobody wastes their time.
|
||||
|
||||
## How to contribute
|
||||
|
||||
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.
|
||||
|
||||
If you want to propose a direction change, start a conversation.
|
||||
|
||||
## 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.
|
||||
684
LICENSE
684
LICENSE
@@ -1,21 +1,671 @@
|
||||
MIT License
|
||||
Copyright (c) 2025-present Mohamed Boudra
|
||||
|
||||
Copyright (c) 2025 Mohamed Boudra
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
* All third party components incorporated into the Paseo Software are
|
||||
licensed under the original license provided by the owner of the
|
||||
applicable component.
|
||||
* All content outside of the above mentioned restrictions is available
|
||||
under the "AGPLv3" license as defined below.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
111
README.md
111
README.md
@@ -4,39 +4,124 @@
|
||||
|
||||
<h1 align="center">Paseo</h1>
|
||||
|
||||
<p align="center">Manage coding agents from your phone and desktop.</p>
|
||||
<p align="center">
|
||||
<a href="https://github.com/getpaseo/paseo/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/getpaseo/paseo?style=flat&logo=github" alt="GitHub stars">
|
||||
</a>
|
||||
<a href="https://github.com/getpaseo/paseo/releases">
|
||||
<img src="https://img.shields.io/github/v/release/getpaseo/paseo?style=flat&logo=github" alt="GitHub release">
|
||||
</a>
|
||||
<a href="https://x.com/moboudra">
|
||||
<img src="https://img.shields.io/badge/%40moboudra-555?logo=x" alt="X">
|
||||
</a>
|
||||
<a href="https://discord.gg/jz8T2uahpH">
|
||||
<img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/paseo-mockup.png" alt="Paseo app screenshot" width="100%">
|
||||
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/mobile-mockup.png" alt="Paseo mobile app" width="100%">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
> **Early development** — Features may break or change without notice. Use at your own risk.
|
||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
|
||||
Paseo is a self-hosted daemon for Claude Code, Codex, and OpenCode. Agents run on your machine with your full dev environment. Connect from phone, desktop, or web.
|
||||
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||
- **Multi-provider:** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
|
||||
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Paseo runs a local server called the daemon that manages your coding agents. Clients like the desktop app, mobile app, web app, and CLI connect to it.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
You need at least one agent CLI installed and configured with your credentials:
|
||||
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||
- [Codex](https://github.com/openai/codex)
|
||||
- [OpenCode](https://github.com/anomalyco/opencode)
|
||||
|
||||
### Desktop app (recommended)
|
||||
|
||||
Download it from [paseo.sh/download](https://paseo.sh/download) or the [GitHub releases page](https://github.com/getpaseo/paseo/releases). Open the app and the daemon starts automatically. Nothing else to install.
|
||||
|
||||
To connect from your phone, scan the QR code shown in Settings.
|
||||
|
||||
### CLI / headless
|
||||
|
||||
Install the CLI and start Paseo:
|
||||
|
||||
```bash
|
||||
npm install -g @getpaseo/cli
|
||||
paseo
|
||||
```
|
||||
|
||||
Then open the app and connect to your daemon.
|
||||
This shows a QR code in the terminal. Connect from any client. This path is useful for servers and remote machines.
|
||||
|
||||
For full setup and configuration, see:
|
||||
|
||||
- [Docs](https://paseo.sh/docs)
|
||||
- [Configuration reference](https://paseo.sh/docs/configuration)
|
||||
|
||||
## CLI
|
||||
|
||||
Everything you can do in the app, you can do from the terminal.
|
||||
|
||||
```bash
|
||||
paseo run --provider claude/opus-4.6 "implement user authentication"
|
||||
paseo run --provider codex/gpt-5.4 --worktree feature-x "implement feature X"
|
||||
|
||||
paseo ls # list running agents
|
||||
paseo attach abc123 # stream live output
|
||||
paseo send abc123 "also add tests" # follow-up task
|
||||
|
||||
# run on a remote daemon
|
||||
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)
|
||||
|
||||
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.
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Quick monorepo package map:
|
||||
|
||||
- `packages/server`: Paseo daemon (agent process orchestration, WebSocket API, MCP server)
|
||||
- `packages/app`: Expo client (iOS, Android, web)
|
||||
- `packages/cli`: `paseo` CLI for daemon and agent workflows
|
||||
- `packages/desktop`: Tauri desktop app
|
||||
- `packages/desktop`: Electron desktop app
|
||||
- `packages/relay`: Relay package for remote connectivity
|
||||
- `packages/website`: Marketing site and documentation (`paseo.sh`)
|
||||
|
||||
@@ -49,12 +134,22 @@ npm run dev
|
||||
# run individual surfaces
|
||||
npm run dev:server
|
||||
npm run dev:app
|
||||
npm run dev:desktop
|
||||
npm run dev:website
|
||||
|
||||
# build the daemon
|
||||
npm run build:daemon
|
||||
|
||||
# repo-wide checks
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
<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%">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
AGPL-3.0
|
||||
|
||||
24
SECURITY.md
24
SECURITY.md
@@ -22,7 +22,7 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
|
||||
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 AES-256-GCM.
|
||||
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl box).
|
||||
|
||||
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.
|
||||
|
||||
@@ -31,19 +31,31 @@ The relay sees only: IP addresses, timing, message sizes, and session IDs. It ca
|
||||
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
|
||||
- **Read your traffic** — All messages are encrypted with AES-256-GCM after the handshake
|
||||
- **Forge messages** — GCM provides authenticated encryption; tampered messages are rejected
|
||||
- **Replay old messages** — Each session derives fresh encryption keys
|
||||
- **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.
|
||||
|
||||
### Trust model
|
||||
|
||||
The QR code or pairing link is the trust anchor. It contains the daemon's public key, which is required to establish the encrypted connection. Treat it like a password — don't share it publicly.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Host header validation and CORS origin checks are defense-in-depth controls for localhost exposure. They help block DNS rebinding and browser-based attacks, but they do not replace network isolation.
|
||||
|
||||
## DNS rebinding protection
|
||||
|
||||
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 uses a host allowlist to validate the `Host` header on incoming requests. Requests with unrecognized hosts are rejected.
|
||||
Paseo validates the `Host` header on incoming requests against configured hostnames. Requests with unrecognized hosts are rejected.
|
||||
|
||||
## Agent authentication
|
||||
|
||||
@@ -51,4 +63,4 @@ Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their
|
||||
|
||||
## Reporting vulnerabilities
|
||||
|
||||
If you discover a security vulnerability, please report it privately by emailing mo@faro.so. Do not open a public issue.
|
||||
If you discover a security vulnerability, please report it privately by emailing hello@moboudra.com. Do not open a public issue.
|
||||
|
||||
1
cli-client-id
Normal file
1
cli-client-id
Normal file
@@ -0,0 +1 @@
|
||||
cid_518a41c4c44340aea1120d2b760fc6c6
|
||||
161
docs/AD-HOC-DAEMON-TESTING.md
Normal file
161
docs/AD-HOC-DAEMON-TESTING.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# Ad-hoc daemon testing
|
||||
|
||||
Spin up an isolated daemon programmatically without touching the main daemon on port 6767.
|
||||
|
||||
## Quick start
|
||||
|
||||
```typescript
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import pino from "pino";
|
||||
import { createPaseoDaemon } from "./bootstrap.js";
|
||||
import { DaemonClient } from "./test-utils/daemon-client.js";
|
||||
|
||||
const logger = pino({ level: "warn" });
|
||||
const paseoHomeRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-test-"));
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
await mkdir(paseoHome, { recursive: true });
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
|
||||
|
||||
const daemon = await createPaseoDaemon(
|
||||
{
|
||||
listen: "127.0.0.1:0", // OS picks a free port
|
||||
paseoHome,
|
||||
corsAllowedOrigins: [],
|
||||
hostnames: true,
|
||||
mcpEnabled: false,
|
||||
staticDir,
|
||||
mcpDebug: false,
|
||||
agentClients: {},
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
relayEnabled: false,
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
appBaseUrl: "https://app.paseo.sh",
|
||||
// Add custom config here, e.g.:
|
||||
// providerOverrides: { ... },
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
await daemon.start();
|
||||
const target = daemon.getListenTarget();
|
||||
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
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||
|
||||
// ... do your testing ...
|
||||
|
||||
await client.close();
|
||||
await daemon.stop();
|
||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
```
|
||||
|
||||
Run with:
|
||||
|
||||
```bash
|
||||
npx tsx packages/server/src/server/your-script.ts
|
||||
```
|
||||
|
||||
## Using the test helper
|
||||
|
||||
For simpler cases, `createTestPaseoDaemon` + `DaemonClient` handles temp dirs and port selection:
|
||||
|
||||
```typescript
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
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",
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||
|
||||
// ... test ...
|
||||
|
||||
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).
|
||||
|
||||
## Common client methods
|
||||
|
||||
```typescript
|
||||
// Provider discovery
|
||||
const snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
|
||||
const models = await client.listProviderModels("claude");
|
||||
const modes = await client.listProviderModes("claude");
|
||||
|
||||
// Agent lifecycle
|
||||
const agent = await client.createAgent({ provider: "claude", cwd: "/tmp" });
|
||||
await client.sendMessage(agent.id, "Hello");
|
||||
const updated = await client.waitForAgentUpsert(agent.id, (s) => s.status === "idle");
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
### 1. appVersion gates provider visibility
|
||||
|
||||
The daemon hides non-legacy providers (anything other than claude, codex, opencode) from clients that don't send an `appVersion >= 0.1.45`. The `DaemonClient` sends no version by default, so custom providers like ACP-based ones will be invisible in snapshot responses.
|
||||
|
||||
Always pass `appVersion`:
|
||||
|
||||
```typescript
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${port}/ws`,
|
||||
appVersion: "0.1.54",
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Provider snapshots are async
|
||||
|
||||
After the daemon starts, providers are probed in the background. The first `getProvidersSnapshot()` call will likely return `status: "loading"` for most providers. Poll until the provider you care about is no longer loading:
|
||||
|
||||
```typescript
|
||||
let snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const entry = snapshot.entries.find((e) => e.provider === "gemini");
|
||||
if (entry && entry.status !== "loading") break;
|
||||
await new Promise((r) => setTimeout(r, 2_000));
|
||||
snapshot = await client.getProvidersSnapshot({ cwd: "/tmp" });
|
||||
}
|
||||
```
|
||||
|
||||
### 3. fetchAgents is required before most operations
|
||||
|
||||
Call `client.fetchAgents()` after connecting. The daemon session expects this handshake before it processes other requests — without it, messages like `get_providers_snapshot_request` will silently hang.
|
||||
|
||||
### 4. listen: "127.0.0.1:0" for port allocation
|
||||
|
||||
Always use port `0` so the OS picks a free port. Never hardcode a port — it will collide with the main daemon or other test runs.
|
||||
|
||||
### 5. Script must live inside packages/server
|
||||
|
||||
The test utilities use relative imports through the TypeScript project. Place your script somewhere under `packages/server/src/` and import from there. Scripts outside the repo will fail with module resolution errors.
|
||||
|
||||
### 6. Cleanup on failure
|
||||
|
||||
Wrap your test logic in try/finally to ensure the daemon stops and temp dirs are cleaned up, even if an assertion fails:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
// ... test logic ...
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.stop().catch(() => undefined);
|
||||
await rm(paseoHomeRoot, { recursive: true, force: true });
|
||||
}
|
||||
```
|
||||
|
||||
### 7. ACP providers spawn real processes
|
||||
|
||||
When testing ACP providers (e.g., Gemini with `extends: "acp"`), the daemon will spawn real processes to probe for models and modes. The binary must be installed and on PATH. Probing can take 5-15 seconds depending on the provider.
|
||||
69
docs/ANDROID.md
Normal file
69
docs/ANDROID.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Android
|
||||
|
||||
## App variants
|
||||
|
||||
Controlled by `APP_VARIANT` in `packages/app/app.config.js` (vanilla Expo, no custom Gradle plugin):
|
||||
|
||||
| Variant | App name | Package ID |
|
||||
| ------------- | ----------- | ---------------- |
|
||||
| `production` | Paseo | `sh.paseo` |
|
||||
| `development` | Paseo Debug | `sh.paseo.debug` |
|
||||
|
||||
EAS profiles: `development`, `production`, and `production-apk` in `packages/app/eas.json`.
|
||||
|
||||
`development` uses Android `debug`.
|
||||
|
||||
## Local build + install
|
||||
|
||||
From repo root:
|
||||
|
||||
```bash
|
||||
npm run android:development # Debug build
|
||||
npm run android:production # Release build
|
||||
npm run android:clear # Remove generated Android project
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
# Release
|
||||
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=production npx expo run:android --variant=release
|
||||
|
||||
# Clear generated Android project
|
||||
rm -rf android
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
|
||||
```bash
|
||||
adb exec-out screencap -p > screenshot.png
|
||||
```
|
||||
|
||||
## Cloud build + submit (EAS)
|
||||
|
||||
Stable tag pushes like `v0.1.0` trigger:
|
||||
|
||||
- `packages/app/.eas/workflows/release-mobile.yml` on Expo servers (iOS + Android build + submit)
|
||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release)
|
||||
|
||||
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.
|
||||
|
||||
### Useful commands
|
||||
|
||||
```bash
|
||||
cd packages/app
|
||||
|
||||
# List recent workflow runs
|
||||
npx eas workflow:runs --workflow release-mobile.yml --limit 10
|
||||
|
||||
# Inspect a run
|
||||
npx eas workflow:view <run-id>
|
||||
|
||||
# Stream logs for a failed job
|
||||
npx eas workflow:logs <job-id> --non-interactive --all-steps
|
||||
```
|
||||
194
docs/ARCHITECTURE.md
Normal file
194
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# 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
|
||||
158
docs/ATTACHMENT_BASED_REVIEW_CONTEXT_PLAN.md
Normal file
158
docs/ATTACHMENT_BASED_REVIEW_CONTEXT_PLAN.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# 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.
|
||||
188
docs/CODING_STANDARDS.md
Normal file
188
docs/CODING_STANDARDS.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# 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
|
||||
587
docs/CUSTOM-PROVIDERS.md
Normal file
587
docs/CUSTOM-PROVIDERS.md
Normal file
@@ -0,0 +1,587 @@
|
||||
# Custom Provider Configuration
|
||||
|
||||
Paseo supports configuring custom agent providers through `config.json` (located at `$PASEO_HOME/config.json`, typically `~/.paseo/config.json`). You can extend built-in providers with different API backends, add ACP-compatible agents, set custom binaries, disable providers, and create multiple profiles for the same underlying provider.
|
||||
|
||||
All provider configuration lives under `agents.providers` in config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"agents": {
|
||||
"providers": {
|
||||
"provider-id": { ... }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`).
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Extending a built-in provider](#extending-a-built-in-provider)
|
||||
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
|
||||
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
|
||||
- [Multiple profiles for the same provider](#multiple-profiles-for-the-same-provider)
|
||||
- [Custom binary for a provider](#custom-binary-for-a-provider)
|
||||
- [Disabling a provider](#disabling-a-provider)
|
||||
- [ACP providers](#acp-providers)
|
||||
- [Provider override reference](#provider-override-reference)
|
||||
|
||||
---
|
||||
|
||||
## Extending a built-in provider
|
||||
|
||||
Use `extends` to create a new provider entry that inherits from a built-in provider (claude, codex, copilot, opencode, pi). The new provider gets its own entry in the provider list, with its own label, environment, and model definitions.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-claude": {
|
||||
"extends": "claude",
|
||||
"label": "My Claude",
|
||||
"description": "Claude with custom API endpoint",
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "sk-ant-...",
|
||||
"ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Required fields for custom providers:
|
||||
|
||||
- `extends` — which built-in provider to inherit from (or `"acp"`)
|
||||
- `label` — display name in the UI
|
||||
|
||||
---
|
||||
|
||||
## Z.AI (Zhipu) coding plan
|
||||
|
||||
[Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Register at [z.ai](https://z.ai) and subscribe to a coding plan
|
||||
2. Create an API key from the Z.AI dashboard
|
||||
3. Add a provider entry in config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"zai": {
|
||||
"extends": "claude",
|
||||
"label": "ZAI",
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "<your-zai-api-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
|
||||
"API_TIMEOUT_MS": "3000000"
|
||||
},
|
||||
"models": [
|
||||
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
|
||||
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
|
||||
{ "id": "glm-5.1", "label": "GLM 5.1" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Available models
|
||||
|
||||
| Model | Tier |
|
||||
| ------------- | ------------------- |
|
||||
| `glm-5.1` | Advanced (flagship) |
|
||||
| `glm-5-turbo` | Advanced |
|
||||
| `glm-4.7` | Standard |
|
||||
| `glm-4.5-air` | Lightweight |
|
||||
|
||||
### Notes
|
||||
|
||||
- `ANTHROPIC_AUTH_TOKEN` is used instead of `ANTHROPIC_API_KEY` — this is the z.ai API key
|
||||
- The `API_TIMEOUT_MS` env var extends the request timeout (z.ai can be slower than direct Anthropic)
|
||||
- If you get auth errors, run `/logout` inside Claude Code before switching to the z.ai provider
|
||||
- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors.
|
||||
- Automated setup is also available: `npx @z_ai/coding-helper`
|
||||
- Official docs: [docs.z.ai/devpack/tool/claude](https://docs.z.ai/devpack/tool/claude)
|
||||
|
||||
---
|
||||
|
||||
## Alibaba Cloud (Qwen) coding plan
|
||||
|
||||
[Alibaba Cloud Model Studio](https://www.alibabacloud.com/en/campaign/ai-scene-coding) offers a coding plan that routes Claude Code requests to Qwen models through an Anthropic-compatible API. Like z.ai, these are **not** Anthropic Claude models.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Go to the [Coding Plan page](https://modelstudio.console.alibabacloud.com/ap-southeast-1/?tab=globalset#/efm/coding_plan) on Alibaba Cloud Model Studio (Singapore region)
|
||||
2. Subscribe to the Pro plan ($50/month)
|
||||
3. Obtain your plan-specific API key (format: `sk-sp-xxxxx`) — this is different from a standard Model Studio key
|
||||
4. Add a provider entry in config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"qwen": {
|
||||
"extends": "claude",
|
||||
"label": "Qwen (Alibaba)",
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<your-coding-plan-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
|
||||
},
|
||||
"models": [
|
||||
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
|
||||
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" },
|
||||
{ "id": "kimi-k2.5", "label": "Kimi K2.5" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API endpoints
|
||||
|
||||
| Mode | Base URL |
|
||||
| ------------------------------- | ----------------------------------------------------------- |
|
||||
| Coding plan (subscription) | `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` |
|
||||
| Pay-as-you-go (no subscription) | `https://dashscope-intl.aliyuncs.com/apps/anthropic` |
|
||||
|
||||
For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk-xxxxx`) instead of `ANTHROPIC_AUTH_TOKEN`.
|
||||
|
||||
### Available models
|
||||
|
||||
**Recommended for coding plan:**
|
||||
|
||||
| Model | Notes |
|
||||
| ------------------ | --------------------------- |
|
||||
| `qwen3.5-plus` | Vision capable, recommended |
|
||||
| `qwen3-coder-next` | Optimized for coding |
|
||||
| `kimi-k2.5` | Vision capable |
|
||||
| `glm-5` | Zhipu GLM |
|
||||
| `MiniMax-M2.5` | MiniMax |
|
||||
|
||||
**Additional models (pay-as-you-go):**
|
||||
`qwen3-max`, `qwen3.5-flash`, `qwen3-coder-plus`, `qwen3-coder-flash`, `qwen3-vl-plus`, `qwen3-vl-flash`
|
||||
|
||||
### Notes
|
||||
|
||||
- API keys must be created in the **Singapore region**
|
||||
- The coding plan is for personal use only in interactive coding tools
|
||||
- Web search (`WebSearch` tool) is an Anthropic-only server-side feature — third-party endpoints don't support it. Add `"disallowedTools": ["WebSearch"]` to avoid errors.
|
||||
- Official docs: [alibabacloud.com/help/en/model-studio/claude-code-coding-plan](https://www.alibabacloud.com/help/en/model-studio/claude-code-coding-plan)
|
||||
|
||||
---
|
||||
|
||||
## Multiple profiles for the same provider
|
||||
|
||||
You can create multiple entries that extend the same built-in provider. Each gets its own entry in the provider list with independent credentials, models, and environment.
|
||||
|
||||
Example: two different Anthropic accounts as separate profiles:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"claude-work": {
|
||||
"extends": "claude",
|
||||
"label": "Claude (Work)",
|
||||
"description": "Work Anthropic account",
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "sk-ant-work-..."
|
||||
}
|
||||
},
|
||||
"claude-personal": {
|
||||
"extends": "claude",
|
||||
"label": "Claude (Personal)",
|
||||
"description": "Personal Anthropic account",
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "sk-ant-personal-..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each profile appears as a separate provider in the Paseo app. You can select which one to use when launching an agent.
|
||||
|
||||
You can also combine profiles with model overrides to pin specific models per profile:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"claude-fast": {
|
||||
"extends": "claude",
|
||||
"label": "Claude (Fast)",
|
||||
"models": [{ "id": "claude-sonnet-4-6", "label": "Sonnet 4.6", "isDefault": true }]
|
||||
},
|
||||
"claude-smart": {
|
||||
"extends": "claude",
|
||||
"label": "Claude (Smart)",
|
||||
"models": [{ "id": "claude-opus-4-6", "label": "Opus 4.6", "isDefault": true }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom binary for a provider
|
||||
|
||||
Override the command used to launch any provider with the `command` field. This is an array where the first element is the binary and the rest are arguments.
|
||||
|
||||
### Override a built-in provider's binary
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"claude": {
|
||||
"command": ["/opt/claude-nightly/claude"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Use a custom wrapper script
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"claude": {
|
||||
"command": ["/usr/local/bin/my-claude-wrapper", "--verbose"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Custom binary on a derived provider
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-codex": {
|
||||
"extends": "codex",
|
||||
"label": "Codex (Custom Build)",
|
||||
"command": ["/home/user/codex-dev/target/release/codex"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `command` array completely replaces the default command for that provider. The binary must exist on the system — Paseo checks for its availability and will mark the provider as unavailable if not found.
|
||||
|
||||
---
|
||||
|
||||
## Disabling a provider
|
||||
|
||||
Set `enabled: false` to hide a provider from the provider list. The provider will not appear in the app or CLI.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"copilot": { "enabled": false },
|
||||
"codex": { "enabled": false }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This works for both built-in and custom providers. To re-enable, set `enabled: true` or remove the `enabled` field entirely (providers are enabled by default).
|
||||
|
||||
---
|
||||
|
||||
## ACP providers
|
||||
|
||||
The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open standard for communication between editors and AI coding agents — think LSP but for AI agents. Any agent that supports ACP can be added to Paseo as a custom provider.
|
||||
|
||||
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
|
||||
|
||||
### Adding a generic ACP provider
|
||||
|
||||
Set `extends: "acp"` and provide a `command`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-agent": {
|
||||
"extends": "acp",
|
||||
"label": "My Agent",
|
||||
"command": ["my-agent-binary", "--acp"],
|
||||
"env": {
|
||||
"MY_API_KEY": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Required fields for ACP providers:
|
||||
|
||||
- `extends: "acp"`
|
||||
- `label`
|
||||
- `command` — the command to spawn the agent process (must support ACP over stdio)
|
||||
|
||||
### 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)
|
||||
2. Authenticate with Google (Gemini CLI handles its own auth)
|
||||
3. Add to config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"gemini": {
|
||||
"extends": "acp",
|
||||
"label": "Google Gemini",
|
||||
"command": ["gemini", "--acp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ref: [Gemini CLI ACP mode docs](https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/acp-mode.md)
|
||||
|
||||
### Example: Hermes (Nous Research)
|
||||
|
||||
[Hermes](https://github.com/NousResearch/hermes-agent) is an open-source coding agent by Nous Research with persistent memory and multi-provider LLM support. It supports ACP via the `acp` subcommand.
|
||||
|
||||
1. Install: `curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash`
|
||||
2. Install ACP support: `pip install -e '.[acp]'`
|
||||
3. Configure Hermes credentials in `~/.hermes/`
|
||||
4. Add to config.json:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"hermes": {
|
||||
"extends": "acp",
|
||||
"label": "Hermes",
|
||||
"description": "Nous Research self-improving AI agent",
|
||||
"command": ["hermes", "acp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ref: [Hermes ACP docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/acp)
|
||||
|
||||
### How ACP providers work in Paseo
|
||||
|
||||
When you launch an agent with an ACP provider:
|
||||
|
||||
1. Paseo spawns the process using the configured `command`
|
||||
2. Sends an `initialize` JSON-RPC request over stdin
|
||||
3. The agent responds with its capabilities, available modes, and models
|
||||
4. Paseo creates a session and sends prompts through the ACP protocol
|
||||
5. The agent streams responses, tool calls, and permission requests back over stdout
|
||||
|
||||
Models and modes are discovered dynamically at runtime from the agent process. If you want to override the model list (e.g., to curate which models appear in the UI), use the `models` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-agent": {
|
||||
"extends": "acp",
|
||||
"label": "My Agent",
|
||||
"command": ["my-agent", "--acp"],
|
||||
"models": [
|
||||
{ "id": "fast-model", "label": "Fast", "isDefault": true },
|
||||
{ "id": "smart-model", "label": "Smart" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Profile models (defined in config.json) completely replace runtime-discovered models when present.
|
||||
|
||||
If you want to keep runtime-discovered models and add or relabel a few entries, use `additionalModels` instead.
|
||||
|
||||
Example: add an experimental model while keeping every model the provider discovers at runtime:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-agent": {
|
||||
"extends": "acp",
|
||||
"label": "My Agent",
|
||||
"command": ["my-agent", "--acp"],
|
||||
"additionalModels": [
|
||||
{ "id": "experimental-model", "label": "Experimental", "isDefault": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example: relabel a discovered model without replacing the full list:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-agent": {
|
||||
"extends": "acp",
|
||||
"label": "My Agent",
|
||||
"command": ["my-agent", "--acp"],
|
||||
"additionalModels": [{ "id": "provider/model-id", "label": "My Preferred Label" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When an `additionalModels` entry has the same `id` as a discovered model, it updates that model in place.
|
||||
|
||||
---
|
||||
|
||||
## Provider override reference
|
||||
|
||||
Every entry under `agents.providers` accepts these fields:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------------ | ------------------------ | ----------------- | ------------------------------------------------------------------ |
|
||||
| `extends` | `string` | Yes (custom only) | Built-in provider ID to inherit from, or `"acp"` |
|
||||
| `label` | `string` | Yes (custom only) | Display name in the UI |
|
||||
| `description` | `string` | No | Short description shown in the UI |
|
||||
| `command` | `string[]` | Yes (ACP only) | Command to spawn the agent process |
|
||||
| `env` | `Record<string, string>` | No | Environment variables to set for the agent process |
|
||||
| `models` | `ProviderProfileModel[]` | No | Static model list (overrides runtime discovery) |
|
||||
| `additionalModels` | `ProviderProfileModel[]` | No | Static model additions (merged with runtime discovery or `models`) |
|
||||
| `disallowedTools` | `string[]` | No | Tool names to disable for this provider (e.g. `["WebSearch"]`) |
|
||||
| `enabled` | `boolean` | No | Set to `false` to hide the provider (default: `true`) |
|
||||
| `order` | `number` | No | Sort order in the provider list |
|
||||
|
||||
### Model definition
|
||||
|
||||
Each entry in the `models` array:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ----------------- | ------------------ | -------- | ------------------------------------- |
|
||||
| `id` | `string` | Yes | Model identifier sent to the provider |
|
||||
| `label` | `string` | Yes | Display name in the UI |
|
||||
| `description` | `string` | No | Short description |
|
||||
| `isDefault` | `boolean` | No | Mark as the default model selection |
|
||||
| `thinkingOptions` | `ThinkingOption[]` | No | Available thinking/reasoning levels |
|
||||
|
||||
### Thinking option
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------- | --------- | -------- | ----------------------------------- |
|
||||
| `id` | `string` | Yes | Thinking option identifier |
|
||||
| `label` | `string` | Yes | Display name |
|
||||
| `description` | `string` | No | Short description |
|
||||
| `isDefault` | `boolean` | No | Mark as the default thinking option |
|
||||
|
||||
### 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.
|
||||
|
||||
Use `disallowedTools` to disable unsupported tools:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-proxy": {
|
||||
"extends": "claude",
|
||||
"label": "My Proxy",
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "https://my-proxy.example.com/v1"
|
||||
},
|
||||
"disallowedTools": ["WebSearch"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Valid `extends` values
|
||||
|
||||
Built-in providers: `claude`, `codex`, `copilot`, `opencode`, `pi`
|
||||
|
||||
Special value: `acp` — creates a generic ACP provider (requires `command`)
|
||||
|
||||
### Full example
|
||||
|
||||
A config.json with multiple custom providers:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"agents": {
|
||||
"providers": {
|
||||
"copilot": { "enabled": false },
|
||||
|
||||
"zai": {
|
||||
"extends": "claude",
|
||||
"label": "ZAI",
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "<zai-api-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
|
||||
"API_TIMEOUT_MS": "3000000"
|
||||
},
|
||||
"models": [
|
||||
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
|
||||
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
|
||||
{ "id": "glm-5.1", "label": "GLM 5.1" }
|
||||
]
|
||||
},
|
||||
|
||||
"qwen": {
|
||||
"extends": "claude",
|
||||
"label": "Qwen (Alibaba)",
|
||||
"env": {
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<coding-plan-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
|
||||
},
|
||||
"models": [
|
||||
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
|
||||
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" }
|
||||
]
|
||||
},
|
||||
|
||||
"gemini": {
|
||||
"extends": "acp",
|
||||
"label": "Google Gemini",
|
||||
"command": ["gemini", "--acp"]
|
||||
},
|
||||
|
||||
"hermes": {
|
||||
"extends": "acp",
|
||||
"label": "Hermes",
|
||||
"command": ["hermes", "acp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
428
docs/DATA_MODEL.md
Normal file
428
docs/DATA_MODEL.md
Normal file
@@ -0,0 +1,428 @@
|
||||
# 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.
|
||||
|
||||
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||
|
||||
---
|
||||
|
||||
## Directory layout
|
||||
|
||||
```
|
||||
$PASEO_HOME/
|
||||
├── config.json # Daemon configuration
|
||||
├── agents/
|
||||
│ └── {project-dir}/
|
||||
│ └── {agentId}.json # One file per agent
|
||||
├── schedules/
|
||||
│ └── {scheduleId}.json # One file per schedule
|
||||
├── chat/
|
||||
│ └── rooms.json # All rooms + messages
|
||||
├── loops/
|
||||
│ └── loops.json # All loop records
|
||||
├── projects/
|
||||
│ ├── projects.json # Project registry
|
||||
│ └── workspaces.json # Workspace registry
|
||||
└── push-tokens.json # Expo push notification tokens
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Agent Record
|
||||
|
||||
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
|
||||
|
||||
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 |
|
||||
|
||||
### Nested: SerializableConfig
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------ | -------------------------- | ---------------------------- |
|
||||
| `title` | `string?` | Configured title |
|
||||
| `modeId` | `string?` | Configured mode |
|
||||
| `model` | `string?` | Configured model |
|
||||
| `thinkingOptionId` | `string?` | Thinking/reasoning level |
|
||||
| `featureValues` | `Record<string, unknown>?` | Feature preference overrides |
|
||||
| `extra` | `Record<string, any>?` | Provider-specific config |
|
||||
| `systemPrompt` | `string?` | Custom system prompt |
|
||||
| `mcpServers` | `Record<string, any>?` | MCP server configurations |
|
||||
|
||||
### Nested: RuntimeInfo
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------ | -------------------------- | ------------------------------ |
|
||||
| `provider` | `string` | Active provider |
|
||||
| `sessionId` | `string?` | Active session ID |
|
||||
| `model` | `string?` | Active model |
|
||||
| `thinkingOptionId` | `string?` | Active thinking option |
|
||||
| `modeId` | `string?` | Active mode |
|
||||
| `extra` | `Record<string, unknown>?` | Provider-specific runtime data |
|
||||
|
||||
### Nested: PersistenceHandle
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------- | ---------------------- | --------------------------------------------------------------------- |
|
||||
| `provider` | `string` | Provider that owns the session |
|
||||
| `sessionId` | `string` | Session ID for resumption |
|
||||
| `nativeHandle` | `any?` | Provider-specific handle (Codex thread ID, Claude resume token, etc.) |
|
||||
| `metadata` | `Record<string, any>?` | Extra metadata |
|
||||
|
||||
### Nested: AgentFeature (discriminated union on `type`)
|
||||
|
||||
**Toggle:**
|
||||
|
||||
| Field | Type |
|
||||
| ------------- | ---------- |
|
||||
| `type` | `"toggle"` |
|
||||
| `id` | `string` |
|
||||
| `label` | `string` |
|
||||
| `description` | `string?` |
|
||||
| `tooltip` | `string?` |
|
||||
| `icon` | `string?` |
|
||||
| `value` | `boolean` |
|
||||
|
||||
**Select:**
|
||||
|
||||
| Field | Type |
|
||||
| ------------- | --------------------- |
|
||||
| `type` | `"select"` |
|
||||
| `id` | `string` |
|
||||
| `label` | `string` |
|
||||
| `description` | `string?` |
|
||||
| `tooltip` | `string?` |
|
||||
| `icon` | `string?` |
|
||||
| `value` | `string?` |
|
||||
| `options` | `AgentSelectOption[]` |
|
||||
|
||||
---
|
||||
|
||||
## 2. Daemon Configuration
|
||||
|
||||
**Path:** `$PASEO_HOME/config.json`
|
||||
|
||||
Single file, validated with `PersistedConfigSchema`.
|
||||
|
||||
```
|
||||
{
|
||||
version: 1,
|
||||
daemon: {
|
||||
listen: "127.0.0.1:6767",
|
||||
hostnames: true | string[],
|
||||
mcp: { enabled: boolean },
|
||||
cors: { allowedOrigins: string[] },
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
|
||||
},
|
||||
app: {
|
||||
baseUrl: string
|
||||
},
|
||||
providers: {
|
||||
openai: { apiKey: string },
|
||||
local: { modelsDir: string }
|
||||
},
|
||||
agents: {
|
||||
providers: {
|
||||
[provider: string]: {
|
||||
command: { mode: "default" } | { mode: "append", args: string[] } | { mode: "replace", argv: string[] },
|
||||
env: Record<string, string>
|
||||
}
|
||||
}
|
||||
},
|
||||
features: {
|
||||
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
|
||||
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||
},
|
||||
log: {
|
||||
level, format,
|
||||
console: { level, format },
|
||||
file: { level, path, rotate: { maxSize, maxFiles } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All fields are optional with sensible defaults.
|
||||
|
||||
---
|
||||
|
||||
## 3. Schedule
|
||||
|
||||
**Path:** `$PASEO_HOME/schedules/{id}.json`
|
||||
|
||||
One file per schedule. ID is 8 hex characters.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------- | ------------------------------------- | -------------------------------- |
|
||||
| `id` | `string` | 8-char hex ID |
|
||||
| `name` | `string?` | Human-readable name |
|
||||
| `prompt` | `string` | The prompt to send |
|
||||
| `cadence` | `ScheduleCadence` | Timing (see below) |
|
||||
| `target` | `ScheduleTarget` | What to run (see below) |
|
||||
| `status` | `"active" \| "paused" \| "completed"` | Current state |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `nextRunAt` | `string?` (ISO 8601) | Next scheduled execution |
|
||||
| `lastRunAt` | `string?` (ISO 8601) | Last execution time |
|
||||
| `pausedAt` | `string?` (ISO 8601) | When paused |
|
||||
| `expiresAt` | `string?` (ISO 8601) | Auto-expire time |
|
||||
| `maxRuns` | `number?` | Max executions before completing |
|
||||
| `runs` | `ScheduleRun[]` | Execution history |
|
||||
|
||||
### Nested: ScheduleCadence (discriminated union on `type`)
|
||||
|
||||
- `{ type: "every", everyMs: number }` — interval in milliseconds
|
||||
- `{ type: "cron", expression: string }` — cron expression
|
||||
|
||||
### Nested: ScheduleTarget (discriminated union on `type`)
|
||||
|
||||
- `{ type: "agent", agentId: string }` — send to existing agent
|
||||
- `{ type: "new-agent", config: { provider, cwd, modeId?, model?, thinkingOptionId?, title?, approvalPolicy?, sandboxMode?, networkAccess?, webSearch?, extra?, systemPrompt?, mcpServers? } }` — create a new agent
|
||||
|
||||
### Nested: ScheduleRun
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------- | -------------------------------------- | ----------------------- |
|
||||
| `id` | `string` | Run ID |
|
||||
| `scheduledFor` | `string` (ISO 8601) | Intended execution time |
|
||||
| `startedAt` | `string` (ISO 8601) | |
|
||||
| `endedAt` | `string?` (ISO 8601) | |
|
||||
| `status` | `"running" \| "succeeded" \| "failed"` | |
|
||||
| `agentId` | `string?` (UUID) | Agent used for this run |
|
||||
| `output` | `string?` | Agent output text |
|
||||
| `error` | `string?` | Error message if failed |
|
||||
|
||||
---
|
||||
|
||||
## 4. Chat
|
||||
|
||||
**Path:** `$PASEO_HOME/chat/rooms.json`
|
||||
|
||||
Single file containing all rooms and messages.
|
||||
|
||||
```json
|
||||
{
|
||||
"rooms": [ ... ],
|
||||
"messages": [ ... ]
|
||||
}
|
||||
```
|
||||
|
||||
### ChatRoom
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------- | ------------------- | ----------------------------------- |
|
||||
| `id` | `string` (UUID) | |
|
||||
| `name` | `string` | Unique room name (case-insensitive) |
|
||||
| `purpose` | `string?` | Room description |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | Updated on each new message |
|
||||
|
||||
### ChatMessage
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------ | ------------------- | ----------------------------------- |
|
||||
| `id` | `string` (UUID) | |
|
||||
| `roomId` | `string` | FK to ChatRoom.id |
|
||||
| `authorAgentId` | `string` | Agent ID of the author |
|
||||
| `body` | `string` | Message text (supports `@mentions`) |
|
||||
| `replyToMessageId` | `string?` | FK to another ChatMessage.id |
|
||||
| `mentionAgentIds` | `string[]` | Extracted `@mention` agent IDs |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
|
||||
---
|
||||
|
||||
## 5. Loop
|
||||
|
||||
**Path:** `$PASEO_HOME/loops/loops.json`
|
||||
|
||||
Single file containing an array of all loop records.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------------------- | --------------------------------------------------- | ------------------------------------------ |
|
||||
| `id` | `string` | 8-char UUID prefix |
|
||||
| `name` | `string?` | Human-readable name |
|
||||
| `prompt` | `string` | Worker prompt |
|
||||
| `cwd` | `string` | Working directory |
|
||||
| `provider` | `string` | Default provider |
|
||||
| `model` | `string?` | Default model |
|
||||
| `workerProvider` | `string?` | Override provider for workers |
|
||||
| `workerModel` | `string?` | Override model for workers |
|
||||
| `verifierProvider` | `string?` | Override provider for verifiers |
|
||||
| `verifierModel` | `string?` | Override model for verifiers |
|
||||
| `verifyPrompt` | `string?` | LLM verification prompt |
|
||||
| `verifyChecks` | `string[]` | Shell commands to run as checks |
|
||||
| `archive` | `boolean` | Whether to archive worker agents after use |
|
||||
| `sleepMs` | `number` | Delay between iterations (ms) |
|
||||
| `maxIterations` | `number?` | Cap on iterations |
|
||||
| `maxTimeMs` | `number?` | Total time budget (ms) |
|
||||
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `startedAt` | `string` (ISO 8601) | |
|
||||
| `completedAt` | `string?` (ISO 8601) | |
|
||||
| `stopRequestedAt` | `string?` (ISO 8601) | |
|
||||
| `iterations` | `LoopIteration[]` | |
|
||||
| `logs` | `LoopLogEntry[]` | |
|
||||
| `nextLogSeq` | `number` | Monotonic log sequence counter |
|
||||
| `activeIteration` | `number?` | Currently executing iteration index |
|
||||
| `activeWorkerAgentId` | `string?` | Currently running worker agent |
|
||||
| `activeVerifierAgentId` | `string?` | Currently running verifier agent |
|
||||
|
||||
### Nested: LoopIteration
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------------- | --------------------------------------------------- | ------------------------ |
|
||||
| `index` | `number` | 1-based iteration index |
|
||||
| `workerAgentId` | `string?` | Agent ID of the worker |
|
||||
| `workerStartedAt` | `string` (ISO 8601) | |
|
||||
| `workerCompletedAt` | `string?` (ISO 8601) | |
|
||||
| `verifierAgentId` | `string?` | Agent ID of the verifier |
|
||||
| `status` | `"running" \| "succeeded" \| "failed" \| "stopped"` | |
|
||||
| `workerOutcome` | `"completed" \| "failed" \| "canceled"?` | |
|
||||
| `failureReason` | `string?` | |
|
||||
| `verifyChecks` | `LoopVerifyCheckResult[]` | Shell check results |
|
||||
| `verifyPrompt` | `LoopVerifyPromptResult?` | LLM verification result |
|
||||
|
||||
### Nested: LoopLogEntry
|
||||
|
||||
| Field | Type |
|
||||
| ----------- | ---------------------------------------------------- |
|
||||
| `seq` | `number` (monotonic) |
|
||||
| `timestamp` | `string` (ISO 8601) |
|
||||
| `iteration` | `number?` |
|
||||
| `source` | `"loop" \| "worker" \| "verifier" \| "verify-check"` |
|
||||
| `level` | `"info" \| "error"` |
|
||||
| `text` | `string` |
|
||||
|
||||
### Nested: LoopVerifyCheckResult
|
||||
|
||||
| Field | Type |
|
||||
| ------------- | ------------------- |
|
||||
| `command` | `string` |
|
||||
| `exitCode` | `number` |
|
||||
| `passed` | `boolean` |
|
||||
| `stdout` | `string` |
|
||||
| `stderr` | `string` |
|
||||
| `startedAt` | `string` (ISO 8601) |
|
||||
| `completedAt` | `string` (ISO 8601) |
|
||||
|
||||
### Nested: LoopVerifyPromptResult
|
||||
|
||||
| Field | Type |
|
||||
| ----------------- | ------------------- |
|
||||
| `passed` | `boolean` |
|
||||
| `reason` | `string` |
|
||||
| `verifierAgentId` | `string?` |
|
||||
| `startedAt` | `string` (ISO 8601) |
|
||||
| `completedAt` | `string` (ISO 8601) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Project Registry
|
||||
|
||||
**Path:** `$PASEO_HOME/projects/projects.json`
|
||||
|
||||
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 |
|
||||
|
||||
---
|
||||
|
||||
## 7. Workspace Registry
|
||||
|
||||
**Path:** `$PASEO_HOME/projects/workspaces.json`
|
||||
|
||||
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 |
|
||||
|
||||
---
|
||||
|
||||
## 8. Push Token Store
|
||||
|
||||
**Path:** `$PASEO_HOME/push-tokens.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"tokens": ["ExponentPushToken[...]", ...]
|
||||
}
|
||||
```
|
||||
|
||||
Simple set of Expo push notification tokens. No schema validation — just an array of strings.
|
||||
|
||||
---
|
||||
|
||||
## Client-side stores (App)
|
||||
|
||||
These live in React Native `AsyncStorage` or browser `IndexedDB`, not on the daemon filesystem.
|
||||
|
||||
### Draft Store
|
||||
|
||||
**AsyncStorage key:** `paseo-drafts` (version 2)
|
||||
|
||||
```typescript
|
||||
{
|
||||
drafts: Record<draftKey, {
|
||||
input: { text: string, images: AttachmentMetadata[] },
|
||||
lifecycle: "active" | "abandoned" | "sent",
|
||||
updatedAt: number, // epoch ms
|
||||
version: number // optimistic concurrency
|
||||
}>,
|
||||
createModalDraft: DraftRecord | null
|
||||
}
|
||||
```
|
||||
|
||||
### Attachment Store (Web)
|
||||
|
||||
**IndexedDB database:** `paseo-attachment-bytes`, object store: `attachments`
|
||||
|
||||
Stores binary attachment blobs keyed by attachment ID.
|
||||
|
||||
### AttachmentMetadata
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | --------- | ------------------------------ |
|
||||
| `id` | `string` | Unique attachment ID |
|
||||
| `mimeType` | `string` | MIME type |
|
||||
| `storageType` | `string` | Storage backend identifier |
|
||||
| `storageKey` | `string` | Key within the storage backend |
|
||||
| `createdAt` | `number` | Epoch ms |
|
||||
| `fileName` | `string?` | Original filename |
|
||||
| `byteSize` | `number?` | Size in bytes |
|
||||
247
docs/DESIGN-SYSTEM.md
Normal file
247
docs/DESIGN-SYSTEM.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# DESIGN-SYSTEM.md
|
||||
|
||||
Tokens — every color, font size, weight, spacing step, radius, icon size — live in `packages/app/src/styles/theme.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Character
|
||||
|
||||
Paseo is minimal, spacious, quiet, confident. Whitespace is deliberate. Nothing crowds, nothing decorates, nothing apologizes. A row, a label, a control. That is the bar.
|
||||
|
||||
The app is calm so the user's work is not. Every visual decision serves either _act on this_ or _understand this_ — never _look at this_.
|
||||
|
||||
Consistency comes from component reuse, not from hand-matching styles across surfaces. A row in the projects list, a row in settings, and a row in a modal are the same component, not three implementations that happen to look alike. When two surfaces do the same semantic thing in two different ways, one of them is wrong.
|
||||
|
||||
---
|
||||
|
||||
## 2. Component reuse
|
||||
|
||||
A semantic element used in three or more places is a primitive. One of a kind is a screen.
|
||||
|
||||
Primitives live in `packages/app/src/components/ui/` and `packages/app/src/components/headers/`. Card and row layout live in `packages/app/src/styles/settings.ts`. Section structure lives in `packages/app/src/screens/settings/settings-section.tsx`.
|
||||
|
||||
A pressable styled to look like a button is wrong; the button is `<Button>` (`packages/app/src/components/ui/button.tsx`). A bare `<Text>` styled to look like a section header is wrong; the section header is `<SettingsSection>` (`packages/app/src/screens/settings/settings-section.tsx`). A custom `Modal` for a confirmation is wrong; the confirmation is `confirmDialog` (`packages/app/src/utils/confirm-dialog.ts`). A hand-rolled overflow menu is wrong; the menu is `<DropdownMenu>` (`packages/app/src/components/ui/dropdown-menu.tsx`). A hand-rolled status pill is wrong; the pill is `<StatusBadge>` (`packages/app/src/components/ui/status-badge.tsx`).
|
||||
|
||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||
|
||||
---
|
||||
|
||||
## 3. Hierarchy
|
||||
|
||||
Hierarchy is conveyed through weight and color, not size. Most labels, titles, and hints across the app are `fontSize.base` or `fontSize.xs`. The distinction between a row's primary line and its secondary line is `foreground` versus `foregroundMuted`.
|
||||
|
||||
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`).
|
||||
- **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`).
|
||||
|
||||
The rule, condensed: text that _names_ a surface or a group is `medium`. Text that lives _inside_ a surface or a group is `normal`. Top-of-screen titles are `<ScreenTitle>`, which is lighter still.
|
||||
|
||||
Foreground is for the thing being acted on: row titles, section headings, the selected sidebar item. `foregroundMuted` is for context: hints, descriptions, secondary metadata, idle sidebar items, placeholders, status text.
|
||||
|
||||
Accent is the one CTA per surface. A `<Button variant="default">` filled with `accent` appears at most once on a page. Most pages have zero — settings is mostly toggles and text, the workspace pane is mostly content, the chat composer is the input itself.
|
||||
|
||||
Destructive is a color, not a click. Restart-daemon and remove-host are `<Button variant="outline">` in the row trailing slot; the destructive surface only appears inside the `confirmDialog` (`packages/app/src/screens/settings/host-page.tsx:541-547`). Workspace archive opens a confirm dialog before any red appears (`packages/app/src/components/sidebar-workspace-list.tsx`). Red appears after the user has indicated intent.
|
||||
|
||||
---
|
||||
|
||||
## 4. Buttons
|
||||
|
||||
The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has five variants. Each has one job.
|
||||
|
||||
`default` is the one primary action on a surface — filled with `accent`. At most one per page. The primary slot inside an `<AdaptiveModalSheet>` and the highlighted action on the welcome screen are the canonical uses.
|
||||
|
||||
`secondary` is the paired action when two actions carry equal weight — filled with `surface3`. The component default is `secondary`, which matches its frequency in the codebase.
|
||||
|
||||
`outline` is the low-frequency action that lives on a row — transparent with `borderAccent`. Restart, Remove, Update on host detail (`packages/app/src/screens/settings/host-page.tsx:585-594`).
|
||||
|
||||
`ghost` is structural and non-committal — no border, no fill. Back arrows, header toggles, "Load more" footers (`packages/app/src/screens/sessions-screen.tsx:54-63`), more-affordances. Ghost is used when the affordance is part of the chrome, not a decision.
|
||||
|
||||
`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.
|
||||
|
||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Borders
|
||||
|
||||
Borders group, separate, or rarely emphasize.
|
||||
|
||||
A logical block of related rows lives inside a card — one border around the whole group. The card primitive is `settingsStyles.card`; the keyboard-shortcuts dialog uses the same shape inline (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:68-73`). The border defines what belongs together.
|
||||
|
||||
Rows after the first inside a card carry `settingsStyles.rowBorder` — a single top border. The first row never has one. The same divider pattern appears in the keyboard-shortcuts dialog rows (`packages/app/src/components/keyboard-shortcuts-dialog.tsx:74-83`). Rows do not need their own background to feel separated.
|
||||
|
||||
A list that is itself the page content — sidebar items in `sidebar-workspace-list.tsx`, the workspace list, the agent list (`packages/app/src/components/agent-list.tsx`) — uses spacing and surface, not borders, to separate items. Rows-in-a-card is an interior pattern; lists-as-pages are not.
|
||||
|
||||
Pane chrome — the workspace pane header, the file-explorer header, the diff pane header — uses a single bottom border to separate the header from the content (`packages/app/src/components/git-diff-pane.tsx:2328-2331`). One border, no shadow.
|
||||
|
||||
`borderAccent` is reserved for the outline button. Inputs use `border`. Single-thing borders are wrong; a single bordered element is either a card with one row (use the card) or it does not need a border.
|
||||
|
||||
---
|
||||
|
||||
## 6. Pickers
|
||||
|
||||
Five primitives. The pick is determined by option count, the need to search, and how the picker is anchored.
|
||||
|
||||
`<DropdownMenu>` is for a small fixed set anchored to a trigger. Theme picker, kebab menus on workspace and project rows (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`), row "more" menus. Items can be async (`status: "pending"`) and can include destructive entries. Under ~10 options where the user knows what they're looking for.
|
||||
|
||||
`<Combobox>` is for a large or searchable list. Host switcher in the sidebar footer, model selector in the composer, branch switcher in the workspace header (`packages/app/src/components/branch-switcher.tsx`). The user types to find the option, or the list is long enough to scroll.
|
||||
|
||||
`<ContextMenu>` is for right-click and long-press on a target. The row is the trigger; there is no visible affordance. Used for incidental actions on workspace rows in the sidebar (`packages/app/src/components/sidebar-workspace-list.tsx`).
|
||||
|
||||
`<AdaptiveModalSheet>` is for a focused task. Multi-field forms (`packages/app/src/components/add-host-modal.tsx`, `packages/app/src/components/pair-link-modal.tsx`, `packages/app/src/components/project-picker-modal.tsx`), confirmations with detail, anything that earns a backdrop. Bottom sheet on compact, centered card on desktop. Raw `Modal` is wrong for any of these.
|
||||
|
||||
`confirmDialog` is for destructive yes/no and imperative confirmation. Promise-based: `await confirmDialog({ destructive: true, ... })`. Anything where a wrong click loses work.
|
||||
|
||||
Three themes is `DropdownMenu`. Thirty hosts is `Combobox`. A label and a value is `AdaptiveModalSheet`. "Are you sure?" is `confirmDialog`.
|
||||
|
||||
---
|
||||
|
||||
## 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`).
|
||||
|
||||
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.
|
||||
|
||||
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
|
||||
|
||||
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
|
||||
|
||||
Rows have generous vertical padding: roughly 16px of content plus 16px of vertical padding for settings rows, 8–12px for sidebar list items where many rows must fit. Compressing rows below the established density to fit more on the screen is wrong. Too many rows means more cards or more sections, not smaller rows.
|
||||
|
||||
The whitespace is the design.
|
||||
|
||||
---
|
||||
|
||||
## 8. Responsiveness
|
||||
|
||||
Compact-first. The small case is designed; the large case adds chrome around it.
|
||||
|
||||
The list+detail pattern is canonical and reused across surfaces. The settings shell (`packages/app/src/screens/settings-screen.tsx`) and the projects screen (`packages/app/src/screens/projects-screen.tsx`) implement it identically:
|
||||
|
||||
- On compact: full-screen list with `<BackHeader>` at the top. Tapping a row pushes a full-screen detail with its own `<BackHeader>` that returns to the list.
|
||||
- On desktop: a 320px sidebar on the left holds the list with `surfaceSidebar` background. The content pane on the right holds the selected detail with `<ScreenHeader>`, `<HeaderIconBadge>`, and `<ScreenTitle>`.
|
||||
|
||||
The branching is one `useIsCompactFormFactor()` check at the top of the screen component. The list and the detail are the same components in both layouts; only the framing changes.
|
||||
|
||||
The workspace screen (`packages/app/src/screens/workspace/workspace-screen.tsx`) follows a different but parallel rule: tabs collapse on compact, panes split on desktop. The sidebar (`packages/app/src/components/left-sidebar.tsx`) is overlaid on compact and pinned on desktop.
|
||||
|
||||
A new list+detail feature copies the settings shell. A new workspace-shaped feature copies the workspace shell. Inventing a third shape happens in design review, not in a PR.
|
||||
|
||||
---
|
||||
|
||||
## 9. Copy and voice
|
||||
|
||||
Sentence case. "Pair a device", "Danger zone", "Restart daemon", "Inject Paseo tools", "No sessions yet", "Load more". Proper nouns retain casing — Paseo, Beta, Stable, Local. Title case is wrong.
|
||||
|
||||
No trailing periods on row titles, labels, or buttons. No trailing period on a single-clause hint: "What happens when you press Enter while the agent is running" (`packages/app/src/screens/settings-screen.tsx:271-272`). Periods exist inside multi-sentence prose: "Restarts the daemon process. The app will reconnect automatically."
|
||||
|
||||
Empty-state strings are short noun phrases or short sentences: "No projects yet", "Select a project", "No sessions yet" (`packages/app/src/screens/sessions-screen.tsx:74-76`), "Host not found".
|
||||
|
||||
Buttons are imperative: Save, Cancel, Restart, Remove, Update, Install update, Add host, Load more. In-flight labels are present-participle with a literal three-dot ellipsis: "Saving...", "Restarting...", "Removing...", "Loading...".
|
||||
|
||||
Error copy is direct. "Unable to remove host" (`packages/app/src/screens/settings/host-page.tsx:697`), not "Sorry, we couldn't remove the host." Recovery instructions are concrete: "Wait for it to come online before restarting." Errors describe state; they do not editorialize.
|
||||
|
||||
Terminology:
|
||||
|
||||
- Workspace, never "checkout".
|
||||
- Host, except where the user-facing concept is the daemon process itself ("Restart daemon").
|
||||
- Project, not "repo" or "repository".
|
||||
- Provider, not "model provider".
|
||||
- Session and agent are distinct: a session is a historical entry in `sessions-screen.tsx`; an agent is a live entity in the workspace.
|
||||
|
||||
---
|
||||
|
||||
## 10. States
|
||||
|
||||
Loading is inline by default. `<LoadingSpinner size={14} color={foregroundMuted} />` sits next to the thing it relates to (`packages/app/src/screens/settings/providers-section.tsx:227-231`). Page-level loading is a centered `<LoadingSpinner size="large">` (`packages/app/src/screens/sessions-screen.tsx:69-72`). Card-level loading is a single short line, not a spinner. In-row dropdown items use `<DropdownMenuItem status="pending" pendingLabel="Removing...">`; the menu item handles its own pending state.
|
||||
|
||||
Empty states are short noun phrases. Centered, muted, one or two lines. Sessions screen pairs the empty noun with a single ghost button to navigate back (`packages/app/src/screens/sessions-screen.tsx:74-81`); that pairing is the maximum elaboration. Illustrations and CTAs disguised as empty states are wrong.
|
||||
|
||||
Inline errors are a single sentence in `palette.red[300]` `xs`, sitting under the field or inside the card it relates to (`packages/app/src/screens/settings/providers-section.tsx:115-119`).
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Disabled state is `opacity: theme.opacity[50]` on the outer pressable. Color changes for disabled state are wrong; a disabled button is the same button, dimmer.
|
||||
|
||||
Partial failure (a list mostly fine but one source errored) is a bordered banner above the list, listing each failure in red-300 `xs` (`packages/app/src/screens/projects-screen.tsx:151-159`). The list still renders.
|
||||
|
||||
State surfaces at the smallest scope it affects. Field error stays under the field; page error is a banner; flow-stopping error is an `Alert`.
|
||||
|
||||
---
|
||||
|
||||
## 11. List rows
|
||||
|
||||
The row anatomy is a content column with an optional trailing slot. Inside a card the row is `settingsStyles.row`. Inside a sidebar list the row carries its own padding and `borderRadius.lg` per item (`packages/app/src/components/sidebar-workspace-list.tsx:2614-2625`).
|
||||
|
||||
Rows that drill into a detail lead with a chevron in the trailing slot (`ChevronRight`, `iconSize.sm`, `foregroundMuted`). The whole row is the `<Pressable>`. Pair-device row (`packages/app/src/screens/settings/host-page.tsx:644-668`), provider row (`packages/app/src/screens/settings/providers-section.tsx:92-132`), project row in the projects list. Chevron means navigation.
|
||||
|
||||
Kebab menus (`<DropdownMenu>` with `<MoreVertical size={14} />` trigger) are for actions on the row, not navigation. Trigger style: `padding: 2`, `borderRadius: 4`, hover background `surface2`. Menu position: `align="end"`. Items use `<DropdownMenuItem leading={<Icon size={14} color={foregroundMuted} />} ...>`. Visibility is `isHovered || isTouchPlatform` — hover-revealed on web, always visible on native (`packages/app/src/components/sidebar-workspace-list.tsx:684-770`).
|
||||
|
||||
A row may carry both a chevron and a kebab when both navigation and row-level actions apply. Chevron sits at the end; kebab sits before it.
|
||||
|
||||
Switches and segmented controls also sit in the trailing slot. A row that both navigates and toggles is a `<Pressable>` with a `<Switch>` in the trailing slot — the switch calls `event.stopPropagation()` so the row press does not fire (`packages/app/src/screens/settings/providers-section.tsx:92-132`). Sidebar items that hold a status dot, a count, and a kebab follow the same rule (`packages/app/src/components/sidebar-workspace-list.tsx`).
|
||||
|
||||
Selected state on rows in a desktop list+detail uses `surfaceSidebarHover` as the background (`packages/app/src/screens/projects-screen.tsx`). Selected state on rows in the sidebar list uses `surface2` (`packages/app/src/components/agent-list.tsx:563-571`).
|
||||
|
||||
---
|
||||
|
||||
## 12. Status pills and badges
|
||||
|
||||
Status pills are `palette.<color>[300]` foreground on a 10%-alpha background of the same color. Success uses green, warning uses amber, danger uses red, muted uses zinc. The `<StatusBadge>` primitive (`packages/app/src/components/ui/status-badge.tsx`) is canonical.
|
||||
|
||||
Status dots — the small filled circles next to a host or agent name — are `borderRadius.full` filled with the status color (`statusSuccess`, `statusWarning`, `statusDanger`, or `foregroundMuted`). They sit in the trailing slot of a sidebar row or as a leading marker on a status pill.
|
||||
|
||||
The bespoke pills in `packages/app/src/screens/settings/host-page.tsx:97-116`, `packages/app/src/components/agent-list.tsx:607-632`, and `packages/app/src/components/sidebar-workspace-list.tsx:2889-2894` are drift to be removed. New code uses `<StatusBadge>`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Forbidden
|
||||
|
||||
- `fontWeight.medium` on row titles, body text, button labels, badge text, or `<SidebarCallout>` titles. Medium is reserved for the structural-label tier described in §3 — section labels, modal/sheet titles, dense metadata emphasis, and tight action labels. Anything else is `normal`. `<ScreenTitle>` is responsive `400/300` and is never overridden.
|
||||
- `<Pressable>` wrapping `<Text>` to make a button. `<Button>` exists.
|
||||
- Bare `<Text>` for a section header inside settings. `<SettingsSection>` exists.
|
||||
- A "Settings" CTA on a detail page. Detail pages are settings; settings is reached from the sidebar, the host entry, or a row's kebab menu.
|
||||
- The word "checkout" in UI strings or identifiers. The term is "workspace".
|
||||
- New color tokens or hardcoded hex outside the palette. Status pill rgba backgrounds are the documented pattern (§12), not a license.
|
||||
- Placeholder text dimmed beyond `foregroundMuted`. No extra opacity, no italics, no ghost-text.
|
||||
- `onPointerEnter` and `onPointerLeave`. They do not fire on native iOS. Hover uses Pressable's `onHoverIn`/`onHoverOut` gated with `isHovered || isCompact || isNative`.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## 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` |
|
||||
73
docs/DESIGN.md
Normal file
73
docs/DESIGN.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# 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
|
||||
198
docs/DEVELOPMENT.md
Normal file
198
docs/DEVELOPMENT.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# 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
|
||||
```
|
||||
109
docs/FILE_ICONS.md
Normal file
109
docs/FILE_ICONS.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# File Icons
|
||||
|
||||
The file explorer uses colored SVG icons from [`material-icon-theme`](https://github.com/material-extensions/vscode-material-icon-theme) (installed as a dev dependency in `packages/app`).
|
||||
|
||||
Icons are inlined as SVG strings in:
|
||||
|
||||
```
|
||||
packages/app/src/components/material-file-icons.ts
|
||||
```
|
||||
|
||||
This file is auto-generated. Do not edit it by hand.
|
||||
|
||||
## How it works
|
||||
|
||||
- `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
|
||||
|
||||
## Adding a new icon
|
||||
|
||||
1. Find the icon name in the material-icon-theme manifest:
|
||||
|
||||
```bash
|
||||
node -e "
|
||||
const m = require('./node_modules/material-icon-theme/dist/material-icons.json');
|
||||
console.log('fileExtensions:', m.fileExtensions['YOUR_EXT']);
|
||||
console.log('languageIds:', m.languageIds['YOUR_LANG']);
|
||||
"
|
||||
```
|
||||
|
||||
2. Verify the SVG exists:
|
||||
|
||||
```bash
|
||||
cat node_modules/material-icon-theme/icons/ICON_NAME.svg
|
||||
```
|
||||
|
||||
3. Add two things to `material-file-icons.ts`:
|
||||
- The SVG string in `SVG_ICONS`:
|
||||
|
||||
```ts
|
||||
"icon_name": `<svg ...>...</svg>`,
|
||||
```
|
||||
|
||||
- The extension mapping in `EXTENSION_TO_ICON`:
|
||||
```ts
|
||||
"ext": "icon_name",
|
||||
```
|
||||
|
||||
4. Run `npm run typecheck` to verify.
|
||||
|
||||
## Currently included icons
|
||||
|
||||
53 unique icons covering these extensions:
|
||||
|
||||
| Extension(s) | Icon |
|
||||
| ------------------------------------------ | ----------- |
|
||||
| `ts` | typescript |
|
||||
| `tsx` | react_ts |
|
||||
| `js` | javascript |
|
||||
| `jsx` | react |
|
||||
| `py` | python |
|
||||
| `go` | go |
|
||||
| `rs` | rust |
|
||||
| `rb` | ruby |
|
||||
| `java` | java |
|
||||
| `kt` | kotlin |
|
||||
| `c` | c |
|
||||
| `cpp` | cpp |
|
||||
| `h` | h |
|
||||
| `hpp` | hpp |
|
||||
| `cs` | csharp |
|
||||
| `swift` | swift |
|
||||
| `dart` | dart |
|
||||
| `ex`, `exs` | elixir |
|
||||
| `erl` | erlang |
|
||||
| `hs` | haskell |
|
||||
| `clj` | clojure |
|
||||
| `scala` | scala |
|
||||
| `ml` | ocaml |
|
||||
| `r` | r |
|
||||
| `lua` | lua |
|
||||
| `zig` | zig |
|
||||
| `nix` | nix |
|
||||
| `php` | php |
|
||||
| `html` | html |
|
||||
| `css` | css |
|
||||
| `scss` | sass |
|
||||
| `less` | less |
|
||||
| `json` | json |
|
||||
| `yml`, `yaml` | yaml |
|
||||
| `xml` | xml |
|
||||
| `toml` | toml |
|
||||
| `md`, `markdown` | markdown |
|
||||
| `sql` | database |
|
||||
| `graphql`, `gql` | graphql |
|
||||
| `sh`, `bash` | console |
|
||||
| `tf` | terraform |
|
||||
| `hcl` | hcl |
|
||||
| `vue` | vue |
|
||||
| `svelte` | svelte |
|
||||
| `astro` | astro |
|
||||
| `wasm` | webassembly |
|
||||
| `svg` | svg |
|
||||
| `png`, `jpg`, `jpeg`, `gif`, `webp`, `ico` | image |
|
||||
| `txt` | document |
|
||||
| `conf`, `cfg`, `ini` | settings |
|
||||
| `lock` | lock |
|
||||
| `groovy` | groovy |
|
||||
| `gradle` | gradle |
|
||||
45
docs/GLOSSARY.md
Normal file
45
docs/GLOSSARY.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 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.
|
||||
264
docs/MOBILE_TESTING.md
Normal file
264
docs/MOBILE_TESTING.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# Mobile Testing
|
||||
|
||||
## Maestro
|
||||
|
||||
Maestro flows live in `packages/app/maestro/`. Reusable sub-flows live in `packages/app/maestro/flows/`.
|
||||
|
||||
Run a flow:
|
||||
|
||||
```bash
|
||||
maestro test packages/app/maestro/my-flow.yaml
|
||||
```
|
||||
|
||||
### Screenshots
|
||||
|
||||
`takeScreenshot` writes to the **current working directory** — there's no way to configure the output path in the YAML. To keep screenshots out of the checkout, `cd` into a temp directory and use an absolute path for the flow:
|
||||
|
||||
```bash
|
||||
FLOW="$(pwd)/packages/app/maestro/my-flow.yaml"
|
||||
mkdir -p /tmp/maestro-out
|
||||
cd /tmp/maestro-out && maestro test "$FLOW"
|
||||
```
|
||||
|
||||
`packages/app/maestro/.gitignore` excludes `*.png` as a safety net.
|
||||
|
||||
### Element targeting
|
||||
|
||||
Use `testID` or `nativeID` on components, then target with `id:` in flows. Prefer this over text matching — text breaks on copy changes.
|
||||
|
||||
```tsx
|
||||
// Component
|
||||
<Pressable testID="sidebar-sessions" onPress={onPress}>
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Flow
|
||||
- tapOn:
|
||||
id: "sidebar-sessions"
|
||||
- assertVisible:
|
||||
id: "sidebar-sessions"
|
||||
```
|
||||
|
||||
### Conditional steps
|
||||
|
||||
Use `runFlow:when:visible` for steps that should only execute when a specific element is on screen:
|
||||
|
||||
```yaml
|
||||
- runFlow:
|
||||
when:
|
||||
visible:
|
||||
id: "sidebar-sessions"
|
||||
commands:
|
||||
- swipe:
|
||||
direction: LEFT
|
||||
duration: 300
|
||||
```
|
||||
|
||||
This is how `flows/dev-client.yaml` handles Expo dev client screens that only appear in dev builds.
|
||||
|
||||
### Don't use launchApp against a running dev app
|
||||
|
||||
`launchApp` kills and restarts the app, disrupting Expo dev client state and host connections. For flows that test against an already-running dev app, **omit launchApp entirely** — just interact with whatever is on screen.
|
||||
|
||||
Use `launchApp` only in flows that need a clean start (e.g., onboarding tests).
|
||||
|
||||
### Swipe gestures
|
||||
|
||||
Use `start`/`end` with percentage coordinates for precise control:
|
||||
|
||||
```yaml
|
||||
# Edge swipe from left to open sidebar
|
||||
- swipe:
|
||||
start: "5%,50%"
|
||||
end: "80%,50%"
|
||||
duration: 300
|
||||
```
|
||||
|
||||
`direction: RIGHT` is simpler but less precise — use it for generic swipes, use coordinates when the start position matters (edge gestures, avoiding specific UI regions).
|
||||
|
||||
### Assertions
|
||||
|
||||
`assertVisible` checks **actual screen visibility**, not just view tree presence. An element that exists in the tree but is off-screen (e.g., `translateX: -400`) will correctly fail `assertVisible`. This makes it reliable for catching animation bugs where state says "open" but the view is visually hidden.
|
||||
|
||||
For async elements, use `extendedWaitUntil`:
|
||||
|
||||
```yaml
|
||||
- extendedWaitUntil:
|
||||
visible: ".*Online.*"
|
||||
timeout: 90000
|
||||
```
|
||||
|
||||
### Dev client handling
|
||||
|
||||
Two reusable flows handle Expo dev client screens after launch:
|
||||
|
||||
- `flows/launch.yaml` — handles dev launcher, dismisses dev menu, asserts "Welcome to Paseo"
|
||||
- `flows/dev-client.yaml` — same but without asserting a particular app route
|
||||
|
||||
### Reach the composer
|
||||
|
||||
`flows/land-in-chat.yaml` is the canonical "get into a chat" primitive. It `clearState`s, runs `launch.yaml`, taps the welcome screen's direct-connection option, types `127.0.0.1:6767`, submits, and waits for `message-input-root`. Compose any composer-level fixture on top of it:
|
||||
|
||||
```yaml
|
||||
appId: sh.paseo
|
||||
---
|
||||
- runFlow: flows/land-in-chat.yaml
|
||||
# ...your scenario here, starting from a ready composer
|
||||
```
|
||||
|
||||
See `image-picker-repro.yaml` for an example.
|
||||
|
||||
**Prefer direct connection over relay pairing for local E2E.** Relay needs a 400+ character pairing URL typed into an input; direct needs `127.0.0.1:6767`. The daemon listens on 6767 and the simulator can reach it directly.
|
||||
|
||||
### New Workspace Creation
|
||||
|
||||
The Android workspace-creation regression has a dedicated harness:
|
||||
|
||||
```bash
|
||||
bash packages/app/maestro/test-workspace-create-android-crash.sh
|
||||
```
|
||||
|
||||
For a short recording that starts after launch/connection/sidebar setup:
|
||||
|
||||
```bash
|
||||
bash packages/app/maestro/record-workspace-create-android-focus.sh
|
||||
```
|
||||
|
||||
The flow details are documented in `packages/app/maestro/README.md`. The important rule is that a valid new-workspace assertion must prove the redirect completed: select a real model, tap `Create`, wait for `workspace-header-title`, wait for `message-input-root`, assert `New workspace` is gone, and assert the Android redbox strings are absent. Waiting for the composer alone is too weak because it can still be the `/new` route after a validation error.
|
||||
|
||||
New workspace scenarios should compose the reusable subflows in `packages/app/maestro/flows/`:
|
||||
|
||||
- `android-dev-client.yaml`
|
||||
- `connect-direct-if-welcome.yaml`
|
||||
- `open-prepared-project-sidebar.yaml`
|
||||
- `new-workspace-open-from-sidebar.yaml`
|
||||
- `new-workspace-select-codex-gpt54.yaml`
|
||||
- `new-workspace-submit-and-assert-created.yaml`
|
||||
|
||||
The workspace-create shell scripts render those subflows into a temp directory before running Maestro, which keeps nested `runFlow` paths and `${PASEO_MAESTRO_*}` placeholders working together.
|
||||
|
||||
### Inputs that Maestro types into
|
||||
|
||||
Maestro `inputText` fires one character at a time. React Native's **controlled** `TextInput` re-renders per keystroke; if a controlled input's state update lags or re-mounts mid-type, characters are dropped silently — the final value on screen is a truncated/scrambled version of what was "typed."
|
||||
|
||||
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.
|
||||
|
||||
### Dropdowns that launch native presenters (iOS)
|
||||
|
||||
On iOS, when a dropdown menu (`DropdownMenu` / RN `Modal`) item needs to launch a native presenter like `PHPickerViewController` (image picker) or a `UIDocumentPicker`, the callback **must not fire while the `Modal` is still dismissing**. UIKit dismissal completion spans multiple frames beyond React unmount; launching a native presenter mid-dismissal leaves an invisible backdrop mounted that traps every subsequent touch.
|
||||
|
||||
`DropdownMenu` handles this by deferring the selected item's `onSelect` until `Modal.onDismiss` fires (UIKit-level dismissal complete), then adds a small extra buffer before invoking it. See `components/ui/dropdown-menu.tsx`'s `selectItem` / `flushPendingSelect`.
|
||||
|
||||
When building a new component that composes a dropdown with a native presenter, reuse this dropdown — do not invent a new timing shim.
|
||||
|
||||
## Self-verification loops
|
||||
|
||||
Maestro can only interact with the app UI — it can't toggle iOS appearance, change locale, or simulate network conditions. For bugs that depend on system-level state, wrap Maestro in a bash script that handles the system changes between Maestro runs.
|
||||
|
||||
This pattern also lets agents self-verify fixes without manual user testing.
|
||||
|
||||
### Pattern
|
||||
|
||||
1. Run baseline Maestro flow (confirm feature works)
|
||||
2. Make system-level change via `xcrun simctl` (toggle appearance, etc.)
|
||||
3. Re-run Maestro flow (confirm feature still works)
|
||||
4. Repeat N iterations to catch intermittent failures
|
||||
|
||||
Scripts run `maestro test` from inside a temp directory so screenshots don't dirty the checkout.
|
||||
|
||||
See `packages/app/maestro/test-sidebar-theme.sh` for the canonical example:
|
||||
|
||||
```bash
|
||||
bash packages/app/maestro/test-sidebar-theme.sh 6 1
|
||||
# Args: iterations=6, wait_seconds=1 between toggle and test
|
||||
```
|
||||
|
||||
Key elements of the script pattern:
|
||||
|
||||
```bash
|
||||
set -euo pipefail
|
||||
ITERATIONS="${1:-3}"
|
||||
|
||||
for i in $(seq 1 "$ITERATIONS"); do
|
||||
# Toggle system state
|
||||
xcrun simctl ui booted appearance light
|
||||
|
||||
# Wait for change to propagate
|
||||
sleep 1
|
||||
|
||||
# Run Maestro flow and capture result
|
||||
if maestro test "$FLOW" 2>&1 | tee "$ITER_DIR/test.log"; then
|
||||
echo "PASS"
|
||||
else
|
||||
echo "FAIL"
|
||||
xcrun simctl io booted screenshot "$ITER_DIR/failure-state.png"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
## Unistyles + Reanimated
|
||||
|
||||
### The crash
|
||||
|
||||
Applying Unistyles theme-reactive styles (`StyleSheet.create((theme) => ...)`) directly to `Animated.View` causes **"Unable to find node on an unmounted component"** on theme change.
|
||||
|
||||
Unistyles wraps styled components in `<UnistylesComponent>` and patches native view properties via C++. Reanimated also manages the same native node for animated transforms. When the theme changes, both systems try to update the node simultaneously and the view crashes.
|
||||
|
||||
### The fix
|
||||
|
||||
Use plain React Native `StyleSheet.create` for static positioning on `Animated.View`. Pass theme-dependent values as inline styles from `useUnistyles()`:
|
||||
|
||||
```tsx
|
||||
// BAD: Unistyles dynamic style on Animated.View
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
sidebar: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar, // theme-reactive
|
||||
overflow: "hidden",
|
||||
},
|
||||
}));
|
||||
|
||||
<Animated.View style={[styles.sidebar, animatedStyle]} />;
|
||||
```
|
||||
|
||||
```tsx
|
||||
// GOOD: static stylesheet + inline theme values
|
||||
import { StyleSheet as RNStyleSheet } from "react-native";
|
||||
|
||||
const staticStyles = RNStyleSheet.create({
|
||||
sidebar: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
});
|
||||
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
<Animated.View
|
||||
style={[staticStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surfaceSidebar }]}
|
||||
/>;
|
||||
```
|
||||
|
||||
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
|
||||
|
||||
## iOS Simulator
|
||||
|
||||
```bash
|
||||
# Screenshot
|
||||
xcrun simctl io booted screenshot /tmp/screenshot.png
|
||||
|
||||
# Dark/light mode
|
||||
xcrun simctl ui booted appearance # check current
|
||||
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)).
|
||||
76
docs/PRODUCT.md
Normal file
76
docs/PRODUCT.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Product
|
||||
|
||||
What Paseo is, who it's for, and where it's going.
|
||||
|
||||
## What is Paseo
|
||||
|
||||
Paseo is a next-generation development environment built around agents. One interface to run, monitor, and interact with coding agents across desktop, mobile, terminal, and web.
|
||||
|
||||
The development workflow is shifting from manually editing files to orchestrating agents that do the editing. Paseo is built for that workflow.
|
||||
|
||||
## Core philosophy
|
||||
|
||||
Freedom and flexibility. Every design decision follows from this:
|
||||
|
||||
- **Multi-provider** — Use any coding agent harness. Pick the right model for each job, switch freely as the landscape shifts. No vendor-lock in.
|
||||
- **Cross-device** — Desktop, mobile, web, CLI. Start work at your desk, check progress from your phone, script from the terminal.
|
||||
- **Self-hosted** — The daemon runs on your machine. Your code, your keys, your environment. No inference markup, no cloud dependency.
|
||||
- **Respectful** - No telemetry, no forced cloud, no forced accounts
|
||||
- **Open source** — AGPL-3.0. Users can inspect, fork, and contribute.
|
||||
- **BYOK** — Bring your own keys. Use your subsidized plans and first-party provider pricing. Paseo adds zero cost on top.
|
||||
|
||||
## How it works
|
||||
|
||||
### Projects and workspaces
|
||||
|
||||
Projects are grouped in the sidebar, detected automatically from your filesystem and tagged by git remote when available.
|
||||
|
||||
Each project opens as a workspace. For git projects, the default workspace is the main checkout. Users can create additional workspaces, which are isolated copies (git worktrees) where agents work without affecting main.
|
||||
|
||||
### Inside a workspace
|
||||
|
||||
A workspace is a flexible canvas:
|
||||
|
||||
- Launch multiple agents side by side in split panes
|
||||
- Open terminals alongside agents
|
||||
- Mix and match providers within the same workspace
|
||||
|
||||
### The daemon
|
||||
|
||||
Paseo is a client-server system. The daemon (Node.js) runs on your machine, manages agent processes, and streams output in real time over WebSocket. Clients connect to the daemon — locally or remotely.
|
||||
|
||||
This architecture means:
|
||||
|
||||
- The daemon can run on any machine: laptop, VM, remote server
|
||||
- Multiple clients can connect simultaneously
|
||||
- Agents keep running when you close the app
|
||||
|
||||
## Target user
|
||||
|
||||
Anyone who builds software:
|
||||
|
||||
- Care about owning their tools and their data
|
||||
- Use multiple AI providers and want to switch freely
|
||||
- Run agents on real tasks across real projects
|
||||
- Want to work from multiple devices
|
||||
|
||||
## What compounds over time
|
||||
|
||||
- **Trust** — Showing up daily, shipping in public, being open source. Earned slowly, lost quickly.
|
||||
- **Community contributions** — Code, packaging, skills, agent configs. Contributors become advocates.
|
||||
- **Ecosystem** — Skills, integrations, shared configs. Community-built content that makes the platform more valuable.
|
||||
|
||||
## Strategic bets
|
||||
|
||||
1. **Models commoditize.** Value moves to the orchestration layer. The best model changes monthly — the workflow layer stays.
|
||||
2. **Multi-provider wins.** No single provider stays on top. Developers want the best model for each task.
|
||||
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)
|
||||
|
||||
- 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
|
||||
366
docs/PROVIDERS.md
Normal file
366
docs/PROVIDERS.md
Normal file
@@ -0,0 +1,366 @@
|
||||
# Adding a New Provider to Paseo
|
||||
|
||||
This guide walks through adding a new agent provider end-to-end. There are two integration patterns, and this doc covers both.
|
||||
|
||||
## Two Integration Patterns
|
||||
|
||||
### 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.
|
||||
|
||||
Existing ACP providers: `claude-acp`, `copilot`.
|
||||
|
||||
### 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.
|
||||
|
||||
Existing direct providers: `claude`, `codex`, `opencode`.
|
||||
|
||||
---
|
||||
|
||||
## ACP Provider Checklist
|
||||
|
||||
### 1. Create the provider class
|
||||
|
||||
Create `packages/server/src/server/agent/providers/{name}-agent.ts`.
|
||||
|
||||
Define capabilities, modes, and a thin subclass of `ACPAgentClient`:
|
||||
|
||||
```ts
|
||||
import type { Logger } from "pino";
|
||||
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
|
||||
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||
import { ACPAgentClient } from "./acp-agent.js";
|
||||
|
||||
const MY_PROVIDER_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
|
||||
const MY_PROVIDER_MODES: AgentMode[] = [
|
||||
{
|
||||
id: "default",
|
||||
label: "Default",
|
||||
description: "Standard agent mode",
|
||||
},
|
||||
// Add more modes as needed
|
||||
];
|
||||
|
||||
type MyProviderClientOptions = {
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
};
|
||||
|
||||
export class MyProviderACPAgentClient extends ACPAgentClient {
|
||||
constructor(options: MyProviderClientOptions) {
|
||||
super({
|
||||
provider: "my-provider", // Must match the ID used everywhere else
|
||||
logger: options.logger,
|
||||
runtimeSettings: options.runtimeSettings,
|
||||
defaultCommand: ["my-agent-binary", "--acp"], // CLI command to spawn
|
||||
defaultModes: MY_PROVIDER_MODES,
|
||||
capabilities: MY_PROVIDER_CAPABILITIES,
|
||||
});
|
||||
}
|
||||
|
||||
// Override isAvailable() if the provider needs specific auth/env vars
|
||||
override async isAvailable(): Promise<boolean> {
|
||||
if (!(await super.isAvailable())) {
|
||||
return false; // Binary not found
|
||||
}
|
||||
return Boolean(process.env["MY_PROVIDER_API_KEY"]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `super.isAvailable()` call checks that the binary from `defaultCommand` is on `$PATH`. Override only to add credential checks on top.
|
||||
|
||||
For reference, here is how Copilot does it -- no auth override needed because the CLI handles auth itself:
|
||||
|
||||
```ts
|
||||
export class CopilotACPAgentClient extends ACPAgentClient {
|
||||
constructor(options: CopilotACPAgentClientOptions) {
|
||||
super({
|
||||
provider: "copilot",
|
||||
logger: options.logger,
|
||||
runtimeSettings: options.runtimeSettings,
|
||||
defaultCommand: ["copilot", "--acp"],
|
||||
defaultModes: COPILOT_MODES,
|
||||
capabilities: COPILOT_CAPABILITIES,
|
||||
});
|
||||
}
|
||||
|
||||
override async isAvailable(): Promise<boolean> {
|
||||
return super.isAvailable();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Add to the provider manifest
|
||||
|
||||
In `packages/server/src/server/agent/provider-manifest.ts`, add mode definitions with UI metadata (icons, color tiers) and a provider definition entry.
|
||||
|
||||
First, define the modes with visual metadata:
|
||||
|
||||
```ts
|
||||
const MY_PROVIDER_MODES: AgentProviderModeDefinition[] = [
|
||||
{
|
||||
id: "default",
|
||||
label: "Default",
|
||||
description: "Standard agent mode",
|
||||
icon: "ShieldCheck",
|
||||
colorTier: "safe",
|
||||
},
|
||||
{
|
||||
id: "autonomous",
|
||||
label: "Autonomous",
|
||||
description: "Runs without prompting",
|
||||
icon: "ShieldOff",
|
||||
colorTier: "dangerous",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
Available `colorTier` values: `"safe"`, `"moderate"`, `"dangerous"`, `"planning"`.
|
||||
Available `icon` values: `"ShieldCheck"`, `"ShieldAlert"`, `"ShieldOff"`.
|
||||
|
||||
Then add to the `AGENT_PROVIDER_DEFINITIONS` array:
|
||||
|
||||
```ts
|
||||
export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
// ... existing providers ...
|
||||
{
|
||||
id: "my-provider",
|
||||
label: "My Provider",
|
||||
description: "Short description of the provider",
|
||||
defaultModeId: "default",
|
||||
modes: MY_PROVIDER_MODES,
|
||||
// Optional: enable voice
|
||||
voice: {
|
||||
enabled: true,
|
||||
defaultModeId: "default",
|
||||
defaultModel: "some-model",
|
||||
},
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### 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:
|
||||
|
||||
```ts
|
||||
import { MyProviderACPAgentClient } from "./providers/my-provider-agent.js";
|
||||
|
||||
const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
// ... existing factories ...
|
||||
"my-provider": (logger, runtimeSettings) =>
|
||||
new MyProviderACPAgentClient({
|
||||
logger,
|
||||
runtimeSettings: runtimeSettings?.["my-provider"],
|
||||
}),
|
||||
};
|
||||
```
|
||||
|
||||
### 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`):
|
||||
|
||||
```tsx
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface MyProviderIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function MyProviderIcon({ size = 16, color = "currentColor" }: MyProviderIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path d="..." />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Then register it in `packages/app/src/components/provider-icons.ts`:
|
||||
|
||||
```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,
|
||||
"my-provider": MyProviderIcon as unknown as typeof Bot,
|
||||
};
|
||||
```
|
||||
|
||||
If no icon is registered, the app falls back to a generic `Bot` icon from lucide.
|
||||
|
||||
### 5. Add E2E test config
|
||||
|
||||
In `packages/server/src/server/daemon-e2e/agent-configs.ts`, add your provider:
|
||||
|
||||
```ts
|
||||
export const agentConfigs = {
|
||||
// ... existing configs ...
|
||||
"my-provider": {
|
||||
provider: "my-provider",
|
||||
model: "default-model-id",
|
||||
modes: {
|
||||
full: "autonomous", // Mode with no permission prompts
|
||||
ask: "default", // Mode that requires permission approval
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<string, AgentTestConfig>;
|
||||
```
|
||||
|
||||
Add an availability check in `isProviderAvailable()`:
|
||||
|
||||
```ts
|
||||
case "my-provider":
|
||||
return (
|
||||
isCommandAvailable("my-agent-binary") &&
|
||||
Boolean(process.env.MY_PROVIDER_API_KEY)
|
||||
);
|
||||
```
|
||||
|
||||
Add to the `allProviders` array:
|
||||
|
||||
```ts
|
||||
export const allProviders: AgentProvider[] = [
|
||||
"claude",
|
||||
"claude-acp",
|
||||
"codex",
|
||||
"copilot",
|
||||
"opencode",
|
||||
"my-provider",
|
||||
];
|
||||
```
|
||||
|
||||
### 6. Run typecheck
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
This is required after every change per project rules.
|
||||
|
||||
---
|
||||
|
||||
## Direct Provider Checklist
|
||||
|
||||
If your agent does not speak ACP, implement the interfaces from `agent-sdk-types.ts` directly.
|
||||
|
||||
### Interfaces to implement
|
||||
|
||||
**`AgentClient`** -- factory for sessions and model listing:
|
||||
|
||||
```ts
|
||||
interface AgentClient {
|
||||
readonly provider: AgentProvider;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession>;
|
||||
resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession>;
|
||||
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||
isAvailable(): Promise<boolean>;
|
||||
// Optional:
|
||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||
}
|
||||
```
|
||||
|
||||
**`AgentSession`** -- a running agent conversation:
|
||||
|
||||
```ts
|
||||
interface AgentSession {
|
||||
readonly provider: AgentProvider;
|
||||
readonly id: string | null;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
||||
streamHistory(): AsyncGenerator<AgentStreamEvent>;
|
||||
getRuntimeInfo(): Promise<AgentRuntimeInfo>;
|
||||
getAvailableModes(): Promise<AgentMode[]>;
|
||||
getCurrentMode(): Promise<string | null>;
|
||||
setMode(modeId: string): Promise<void>;
|
||||
getPendingPermissions(): AgentPermissionRequest[];
|
||||
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
|
||||
describePersistence(): AgentPersistenceHandle | null;
|
||||
interrupt(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
// Optional:
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### Steps
|
||||
|
||||
1. Create `packages/server/src/server/agent/providers/{name}-agent.ts` implementing both interfaces
|
||||
2. Add to the provider manifest (same as ACP step 2 above)
|
||||
3. Add factory to the registry (same as ACP step 3 above)
|
||||
4. Add icon (same as ACP step 4 above)
|
||||
5. Add E2E config (same as ACP step 5 above)
|
||||
6. Run typecheck
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual testing with the CLI
|
||||
|
||||
Start the daemon if not already running, then:
|
||||
|
||||
```bash
|
||||
# Launch an agent with your provider
|
||||
paseo run --provider my-provider
|
||||
|
||||
# Launch with a specific model and mode
|
||||
paseo run --provider my-provider --model some-model --mode default
|
||||
|
||||
# List running agents
|
||||
paseo ls -a -g
|
||||
|
||||
# Check if the provider reports models
|
||||
paseo models --provider my-provider
|
||||
```
|
||||
|
||||
### E2E test patterns
|
||||
|
||||
The E2E configs in `agent-configs.ts` expose two helpers:
|
||||
|
||||
- `getFullAccessConfig(provider)` -- returns config for a session with no permission prompts
|
||||
- `getAskModeConfig(provider)` -- returns config for a session that triggers permission requests
|
||||
|
||||
Tests use `isProviderAvailable(provider)` to skip when the binary or credentials are missing, so CI will not fail for providers that are not installed.
|
||||
|
||||
---
|
||||
|
||||
## Gotchas
|
||||
|
||||
**Mode IDs can be URIs.** ACP providers like Copilot use full URIs as mode IDs (e.g., `"https://agentclientprotocol.com/protocol/session-modes#agent"`). Never assume mode IDs are simple strings. The manifest `defaultModeId` must match exactly.
|
||||
|
||||
**Models and modes are discovered dynamically.** ACP providers report available models and modes at runtime via the protocol. The static definitions in `provider-manifest.ts` are used for UI scaffolding (icons, color tiers) but the runtime values from the agent process are the source of truth.
|
||||
|
||||
**`AgentProvider` is always `string`.** The type alias is `type AgentProvider = string`. Provider IDs are validated against the manifest at runtime, not at the type level.
|
||||
|
||||
**Auth patterns vary.** Some providers need API keys in env vars (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`), some use OAuth tokens (`CLAUDE_CODE_OAUTH_TOKEN`), some use auth files (`~/.codex/auth.json`), and some handle auth entirely in their CLI binary (Copilot). Your `isAvailable()` method should check whatever is needed.
|
||||
|
||||
**The manifest mode list and the agent class mode list are separate.** The manifest in `provider-manifest.ts` includes UI metadata (`icon`, `colorTier`). The agent class defines modes without UI metadata (just `id`, `label`, `description`). Keep them in sync.
|
||||
|
||||
**`defaultCommand` is a tuple.** The first element is the binary name, the rest are default arguments. The base class uses this to find the executable and spawn the process.
|
||||
|
||||
**Runtime settings can override the command.** Users can configure custom binary paths or environment variables per provider via `ProviderRuntimeSettings`. Your factory in the registry should pass `runtimeSettings?.["your-provider"]` through to the constructor.
|
||||
298
docs/RELEASE.md
Normal file
298
docs/RELEASE.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# Release
|
||||
|
||||
All workspaces share one version and release together.
|
||||
|
||||
## Two paths
|
||||
|
||||
There are two supported ways to ship from `main`:
|
||||
|
||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||
2. **Beta flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
|
||||
|
||||
## Standard release (patch)
|
||||
|
||||
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.
|
||||
- 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).
|
||||
|
||||
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
|
||||
|
||||
Use the direct stable path when the current `main` changes are ready to become the public release immediately.
|
||||
|
||||
## Manual step-by-step
|
||||
|
||||
```bash
|
||||
npm run typecheck # Verify the exact commit you intend to release
|
||||
npm run release:check # Typecheck, build, dry-run pack
|
||||
npm run version:all:patch # Bump version, create commit + tag
|
||||
npm run release:publish # Publish to npm
|
||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||
```
|
||||
|
||||
## Beta flow
|
||||
|
||||
```bash
|
||||
npm run release:beta:patch # Bump to X.Y.Z-beta.1, push commit + tag
|
||||
# ... test desktop and APK prerelease assets from GitHub Releases ...
|
||||
npm run release:beta:next # Optional: cut X.Y.Z-beta.2, beta.3, ...
|
||||
npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
```
|
||||
|
||||
- Beta tags are published GitHub prereleases like `v0.1.41-beta.1`
|
||||
- Betas publish desktop assets and APKs for testing, but they do not publish npm packages and do not trigger the production web/mobile release flows
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
|
||||
- **Do create a changelog entry for betas.** The beta entry is temporary and gets updated in place until promotion.
|
||||
|
||||
Use the beta path when you need to:
|
||||
|
||||
- test a build manually in a Linux or Windows VM
|
||||
- send a build to a user who is hitting a specific problem
|
||||
- iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly
|
||||
|
||||
## 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.
|
||||
|
||||
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`.
|
||||
|
||||
### Default behavior
|
||||
|
||||
`npm run release:patch` → tag push → 24h 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 24. 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 24h 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 release manifest goes from `rolloutHours=24` to `rolloutHours=0` within ~30s of publish, and the renderer polls every 30 minutes — so no stable user can admit during the gap.
|
||||
|
||||
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) on an already-shipped release:**
|
||||
|
||||
```bash
|
||||
gh workflow run desktop-rollout.yml \
|
||||
-f tag=v0.1.42 \
|
||||
-f rollout_hours=0
|
||||
```
|
||||
|
||||
`rollout_hours=0` admits 100% of stable users on their next update check (within ~30 min for active clients).
|
||||
|
||||
**Slow a rollout down** (e.g. extend total duration to 72h since the original release):
|
||||
|
||||
```bash
|
||||
gh workflow run desktop-rollout.yml \
|
||||
-f tag=v0.1.42 \
|
||||
-f rollout_hours=72
|
||||
```
|
||||
|
||||
`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 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.
|
||||
|
||||
### Custom ramp on a manually-dispatched build
|
||||
|
||||
`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 \
|
||||
-f tag=v0.1.43 \
|
||||
-f rollout_hours=6
|
||||
```
|
||||
|
||||
This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 24. 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.
|
||||
|
||||
If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+1> -f rollout_hours=0` after N+1 publishes so the users who already got N reach the fix fast.
|
||||
|
||||
### Limitations
|
||||
|
||||
- **No pause / kill switch.** Once a stable user is admitted, they will install the update on next quit (`autoInstallOnAppQuit = true`). To stop new admissions, ship a superseding release. To "recall" already-admitted users, ship a hotfix `+1` patch.
|
||||
- **No rollback.** `allowDowngrade = false`. Bad release = ship a hotfix.
|
||||
- **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.
|
||||
|
||||
## 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`.
|
||||
|
||||
## Fixing a failed release build
|
||||
|
||||
**NEVER bump the version to fix a build problem.** New versions are reserved for meaningful product changes (features, fixes, improvements). Build/CI failures are fixed on the current version.
|
||||
|
||||
**Do not rely on `workflow_dispatch` for tagged code fixes.** The `workflow_dispatch` trigger runs the workflow file from the default branch but checks out the code at the tag ref (`ref: ${{ inputs.tag }}`). That means fixes committed to `main` won't change the tagged source tree being built. `workflow_dispatch` only helps when the fix lives in the workflow file itself.
|
||||
|
||||
To retry a failed workflow, **always push a retry tag** on the commit you want to build. Reusing the same tag name is expected: move it with `git tag -f ...` and push it with `--force` so the workflow rebuilds the commit you actually want.
|
||||
|
||||
Prefer a tag push over `workflow_dispatch` whenever you are rebuilding release code or release assets.
|
||||
|
||||
The retry tag patterns below still work and remain the supported way to rebuild specific release targets:
|
||||
|
||||
```bash
|
||||
# Desktop (all platforms)
|
||||
git tag -f desktop-v0.1.28 HEAD && git push origin desktop-v0.1.28 --force
|
||||
|
||||
# Desktop (single platform)
|
||||
git tag -f desktop-macos-v0.1.28 HEAD && git push origin desktop-macos-v0.1.28 --force
|
||||
git tag -f desktop-linux-v0.1.28 HEAD && git push origin desktop-linux-v0.1.28 --force
|
||||
git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.28 --force
|
||||
|
||||
# Android APK
|
||||
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
||||
|
||||
# Beta
|
||||
git tag -f v0.1.29-beta.2 HEAD && git push origin v0.1.29-beta.2 --force
|
||||
```
|
||||
|
||||
This ensures the checkout ref matches the actual code on `main` with the fix included.
|
||||
|
||||
- `vX.Y.Z` or `vX.Y.Z-beta.N` rebuilds the full tagged release
|
||||
- `desktop-vX.Y.Z` rebuilds desktop for all desktop platforms only
|
||||
- `desktop-macos-vX.Y.Z`, `desktop-linux-vX.Y.Z`, and `desktop-windows-vX.Y.Z` rebuild only that desktop platform
|
||||
- `android-vX.Y.Z` rebuilds the Android APK release only
|
||||
|
||||
## Notes
|
||||
|
||||
- `version:all:*` bumps root + syncs workspace versions and `@getpaseo/*` dependency versions
|
||||
- `release:prepare` refreshes workspace `node_modules` links to prevent stale types
|
||||
- `npm run dev:desktop` and `npm run build:desktop` target the Electron desktop package in `packages/desktop`
|
||||
- If `release:publish` partially fails, re-run it — npm skips already-published versions
|
||||
- The website uses GitHub's latest published release API for download links, so published beta prereleases do not replace the stable download target.
|
||||
|
||||
## Changelog format
|
||||
|
||||
Release notes depend on the changelog heading format. The heading **must** be strictly followed:
|
||||
|
||||
```
|
||||
## X.Y.Z - YYYY-MM-DD
|
||||
## X.Y.Z-beta.N - YYYY-MM-DD
|
||||
```
|
||||
|
||||
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
|
||||
|
||||
## Changelog policy
|
||||
|
||||
- `CHANGELOG.md` includes stable releases and the current beta line.
|
||||
- The first beta inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
|
||||
- The next beta updates that same top entry in place, for example from `0.1.60-beta.1` to `0.1.60-beta.2`.
|
||||
- Stable promotion updates that same entry in place, for example from `0.1.60-beta.2` to `0.1.60`.
|
||||
- Do not create duplicate entries for each beta on the same version line.
|
||||
|
||||
## Changelog ownership
|
||||
|
||||
- **Only Claude should write changelog entries.**
|
||||
- If you are Codex and a stable release needs a changelog entry, launch a Claude agent with Paseo to draft it, then review and commit the result.
|
||||
|
||||
## Changelog voice
|
||||
|
||||
The changelog is shown on the Paseo homepage. Write it for **end users**, not developers.
|
||||
|
||||
- **Frame everything from the user's perspective.** Describe what changed in the app, not what changed in the code. Users care that "workspaces load instantly" — not that a component no longer remounts.
|
||||
- **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.
|
||||
- **Cut low-signal entries.** "Toolbar buttons have consistent sizing" is too granular. Combine small polish items or drop them.
|
||||
|
||||
## Changelog conciseness
|
||||
|
||||
Every bullet must be scannable at a glance. The changelog is not release documentation — it's a list.
|
||||
|
||||
- **One line per bullet.** If a bullet wraps to three lines in a narrow column, it's too long.
|
||||
- **Split bullets that pack multiple distinct changes.** If a bullet uses "and", "plus", a comma list, or an em-dash to chain several independent improvements, break them into separate bullets — even when they share a theme or author. One bullet = one user-facing change.
|
||||
- **Trim qualifying clauses.** Drop "with a hint shown when…", "matching the CLI's behaviour", "across common install shapes". If the detail doesn't change whether a user cares, cut it.
|
||||
- **Lead with the outcome.** "Windows: agents launch reliably from npm `.cmd` shims…" is better than "Windows: agents launch reliably across common install shapes. Claude, Codex, and OpenCode now start correctly…".
|
||||
- **Attribution follows the split.** When you split a dense bullet, move each PR/author to the bullet it belongs to. Never duplicate the same PR across multiple bullets.
|
||||
|
||||
## Changelog attribution
|
||||
|
||||
Every changelog bullet must credit contributors and link to the PR(s) that delivered the change. This is not one-PR-per-line — a single bullet describes a user-facing change and may reference multiple PRs.
|
||||
|
||||
Format: append `([#123](https://github.com/getpaseo/paseo/pull/123) by [@user](https://github.com/user))` at the end of each bullet. For changes spanning multiple PRs or contributors:
|
||||
|
||||
```markdown
|
||||
- Voice mode now works on tablets with proper microphone permissions. ([#210](https://github.com/getpaseo/paseo/pull/210), [#215](https://github.com/getpaseo/paseo/pull/215) by [@alice](https://github.com/alice), [@bob](https://github.com/bob))
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **Always link the PR number** as `[#N](https://github.com/getpaseo/paseo/pull/N)`.
|
||||
- **Always link the contributor's GitHub profile** as `[@user](https://github.com/user)`.
|
||||
- **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.
|
||||
|
||||
## Changelog ordering
|
||||
|
||||
Entries within each section (Added, Improved, Fixed) are ordered by user impact:
|
||||
|
||||
1. **User-facing features and changes first** — things users will notice, want to try, or that change their workflow.
|
||||
2. **Quality-of-life improvements** — polish, performance, smoother interactions.
|
||||
3. **Internal/infra changes last** — only include if they have a tangible user benefit (e.g. "faster startup" is user-facing even if the fix was internal).
|
||||
|
||||
## Pre-release sanity check
|
||||
|
||||
Before cutting any release (beta or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
||||
|
||||
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
|
||||
|
||||
> Review the diff between the latest release tag and HEAD. Focus on:
|
||||
>
|
||||
> 1. **Breaking changes** — especially in the WebSocket protocol, agent lifecycle, and any server↔client contract.
|
||||
> 2. **Backward compatibility** — the important direction is old app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Flag anything that breaks old clients against new daemons or requires both sides to update in lockstep.
|
||||
> 3. **Regressions** — anything that looks like it could break existing functionality.
|
||||
>
|
||||
> Diff: `git diff <latest-release-tag>..HEAD`
|
||||
|
||||
The agent's job is a deep sanity check, not a full code review. If it flags anything, investigate before proceeding.
|
||||
|
||||
## Changelog scope
|
||||
|
||||
The changelog always covers **stable-to-HEAD**:
|
||||
|
||||
- **Beta release**: the diff and release notes cover `latest stable tag -> HEAD`. The current beta changelog entry is updated in place.
|
||||
- **Stable release**: the same changelog entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
|
||||
|
||||
In other words, betas are checkpoints along the way; the changelog entry remains the single record for the final jump from one stable version to the next.
|
||||
|
||||
## Completion checklist
|
||||
|
||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
|
||||
- [ ] Update `CHANGELOG.md` with user-facing release notes (features, fixes — not refactors)
|
||||
- [ ] Verify the changelog heading follows strict `## X.Y.Z - YYYY-MM-DD` format
|
||||
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
|
||||
126
docs/TESTING.md
Normal file
126
docs/TESTING.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Testing
|
||||
|
||||
## Philosophy
|
||||
|
||||
Tests prove behavior, not structure. Every test should answer: "what user-visible or API-visible behavior does this verify?"
|
||||
|
||||
## Test-driven development
|
||||
|
||||
Work in vertical slices: one test, one implementation, repeat. Each test responds to what you learned from the previous cycle.
|
||||
|
||||
```
|
||||
RIGHT (vertical):
|
||||
RED→GREEN: test1→impl1
|
||||
RED→GREEN: test2→impl2
|
||||
RED→GREEN: test3→impl3
|
||||
|
||||
WRONG (horizontal):
|
||||
RED: test1, test2, test3, test4, test5
|
||||
GREEN: impl1, impl2, impl3, impl4, impl5
|
||||
```
|
||||
|
||||
Writing all tests first then all implementation produces bad tests — you end up testing imagined behavior instead of actual behavior.
|
||||
|
||||
## Determinism first
|
||||
|
||||
Tests must produce the same result every run:
|
||||
|
||||
- No conditional assertions or branching paths
|
||||
- No reliance on timing, randomness, or network jitter
|
||||
- No weak assertions (`toBeTruthy`, `toBeDefined`)
|
||||
- Assert the full intended behavior, not fragments
|
||||
|
||||
```typescript
|
||||
// Bad: conditional and weak
|
||||
it("creates a tool call", async () => {
|
||||
const result = await createToolCall(input);
|
||||
if (result.ok) {
|
||||
expect(result.id).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
// Good: deterministic and explicit
|
||||
it("returns timeout error when provider times out", async () => {
|
||||
const result = await createToolCall(input);
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: { code: "PROVIDER_TIMEOUT", waitedMs: 30000 },
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Flaky tests are a bug
|
||||
|
||||
Never remove a test because it's flaky. Find the variance source (time, randomness, race condition, shared state, non-deterministic output, environment drift) and fix it.
|
||||
|
||||
## Real dependencies over mocks
|
||||
|
||||
Mocks are not the default. They require an explicit decision.
|
||||
|
||||
- **Database**: real test database, not a mock
|
||||
- **APIs**: real APIs with test/sandbox credentials, not request mocks
|
||||
- **File system**: temporary directory that gets cleaned up, not fs mocks
|
||||
|
||||
Ask: "will this still hold with real dependencies at runtime?" If no, don't mock.
|
||||
|
||||
### Use swappable adapters instead
|
||||
|
||||
When you need test isolation, design code so dependencies are injectable:
|
||||
|
||||
```typescript
|
||||
interface EmailSender {
|
||||
send(to: string, body: string): Promise<void>;
|
||||
}
|
||||
|
||||
// Production
|
||||
const realSender: EmailSender = { send: sendgrid.send };
|
||||
|
||||
// Test: in-memory adapter
|
||||
function createTestEmailSender() {
|
||||
const sent: Array<{ to: string; body: string }> = [];
|
||||
return {
|
||||
send: async (to: string, body: string) => {
|
||||
sent.push({ to, body });
|
||||
},
|
||||
sent,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## End-to-end means end-to-end
|
||||
|
||||
When a test is labeled end-to-end, it calls the real service. No environment variable gates, no conditional skipping, no mocking the external dependency.
|
||||
|
||||
## Test organization
|
||||
|
||||
- Collocate tests with implementation: `thing.ts` + `thing.test.ts`
|
||||
- Extract complex setup into reusable helpers
|
||||
- Test bodies should read like plain English
|
||||
- Build a vocabulary of test helpers that make complex flows simple
|
||||
|
||||
## 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.
|
||||
|
||||
## Debugging with tests
|
||||
|
||||
Use the test as your debugging ground:
|
||||
|
||||
1. Add temporary logging to the code under test
|
||||
2. Run the test, observe actual values
|
||||
3. Trace the flow end-to-end through test output
|
||||
4. Confirm each assumption with actual output
|
||||
5. Remove logging when done
|
||||
|
||||
The test output is the source of truth, not your reading of the code.
|
||||
|
||||
## Design for testability
|
||||
|
||||
If code isn't testable, refactor it. Signs:
|
||||
|
||||
- You want to reach for a mock
|
||||
- You can't inject a dependency
|
||||
- You need to test private internals
|
||||
- Setup requires too much global state
|
||||
|
||||
Aim for deep modules: small interface, deep implementation. Fewer methods = fewer tests needed, simpler params = simpler setup.
|
||||
302
docs/UNISTYLES.md
Normal file
302
docs/UNISTYLES.md
Normal file
@@ -0,0 +1,302 @@
|
||||
# Unistyles Gotchas
|
||||
|
||||
This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-aware styles. Unistyles is fast because most style updates do not go through React renders: the [Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites React Native component imports, attaches style metadata, and lets the native ShadowRegistry update tracked views when theme or runtime dependencies change.
|
||||
|
||||
That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.
|
||||
|
||||
## STOP — `useUnistyles()` Is Forbidden
|
||||
|
||||
**Do not call `useUnistyles()` unless every alternative below has been ruled out and you can explain in a code comment why.** The library authors themselves [strongly advise against it](https://www.unistyl.es/v3/references/use-unistyles):
|
||||
|
||||
> We strongly recommend **not using** this hook, as it will re-render your component on every change. This hook was created to simplify the migration process and should only be used when other methods fail.
|
||||
|
||||
We have hit this gotcha repeatedly in Paseo. It manifests as periodic, lockstep re-renders of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling: `AgentStreamView` re-rendering constantly with `theme` showing as the only changed input on every render. The hook subscribes the component to **all** Unistyles runtime changes (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call, which also breaks every downstream `useMemo`/`memo` boundary that includes a derived theme value.
|
||||
|
||||
Before reaching for `useUnistyles()`, work down this list of alternatives in order:
|
||||
|
||||
### 1. `StyleSheet.create((theme) => ...)` — default
|
||||
|
||||
Most theme-aware styling needs nothing else. The Babel plugin tracks theme dependencies inside the factory and updates the native ShadowTree without any React re-render.
|
||||
|
||||
```tsx
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
}));
|
||||
|
||||
<View style={styles.container} />;
|
||||
```
|
||||
|
||||
If you are reading a theme value just to feed it back into a `style` prop, you almost certainly want this and not the hook.
|
||||
|
||||
### 2. Hard-coded constants for genuinely static values
|
||||
|
||||
If you only need a number that happens to live on the theme (e.g. a fixed spacing value used to compute a gap or animation distance), use a literal constant or import a static module. Static reads do not need a subscription. See the "Static Theme Imports" section below — importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static.
|
||||
|
||||
### 3. `withUnistyles(Component)` for third-party props
|
||||
|
||||
When a third-party component takes a non-`style` prop that must be theme-reactive (e.g. `BlurView.tint`, `Image.tintColor`, navigator option props, bottom-sheet `backgroundStyle`), wrap that single component with `withUnistyles`. Only the wrapper re-renders, not the surrounding tree.
|
||||
|
||||
```tsx
|
||||
const ThemedBlur = withUnistyles(BlurView);
|
||||
<ThemedBlur tint={theme.colors.surface0} />;
|
||||
```
|
||||
|
||||
(Mind the `> *` child-selector leak documented further down.)
|
||||
|
||||
### 4. Lift the read into a tiny leaf component
|
||||
|
||||
If only one prop in a large component needs a theme value at runtime, extract a small leaf component that calls `useUnistyles()` and accept its re-renders in isolation. Never let a whole stream / panel / sidebar / virtualized list subscribe.
|
||||
|
||||
### 5. (Last resort) `useUnistyles()`
|
||||
|
||||
Only acceptable when both of:
|
||||
|
||||
- (a) The value is consumed by a 3rd-party library that cannot be wrapped with `withUnistyles` (per the upstream "When to use it?" list), AND
|
||||
- (b) The component is small, leaf-level, and not on a hot render path.
|
||||
|
||||
If you add a new `useUnistyles()` call, leave a comment on the line explaining which of (a)/(b) applies and why each higher-priority alternative was ruled out.
|
||||
|
||||
### Hot-path forbidden list
|
||||
|
||||
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`
|
||||
- `Composer` and `MessageInput`
|
||||
- `WorkspaceScreen` shell, tabs row, deck wrapper
|
||||
- `LeftSidebar` row items, `SidebarWorkspaceList`, `CommandCenter`
|
||||
- Anything inside a virtualized list (`@tanstack/react-virtual`, `FlashList`)
|
||||
|
||||
Reviewers must reject PRs that add `useUnistyles()` calls in these areas without a written justification matching the last-resort criteria above.
|
||||
|
||||
## How Updates Propagate
|
||||
|
||||
For standard React Native components, the [Unistyles Babel plugin](https://www.unistyl.es/v3/other/babel-plugin) rewrites imports such as `View`, `Text`, `Pressable`, and `ScrollView` to Unistyles-aware component factories. On native, those factories borrow the component ref and register the `style` prop with the ShadowRegistry. The upstream ["Why my view doesn't update?"](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update) guide describes this as the ShadowTree update path that avoids unnecessary React re-renders.
|
||||
|
||||
The important detail: the automatic native path tracks `props.style`. It does not generally track every prop that happens to carry style-like values.
|
||||
|
||||
[`useUnistyles()`](https://www.unistyl.es/v3/references/use-unistyles) is different. It gives React access to the current theme/runtime and can make a component re-render when those values change. Use it for values that must be rendered through React props, such as icon colors or small escape hatches. Do not expect direct reads from `UnistylesRuntime` to re-render a component; [issue #817](https://github.com/jpudysz/react-native-unistyles/issues/817) is a useful reminder of that invariant.
|
||||
|
||||
## Main Gotcha: `contentContainerStyle`
|
||||
|
||||
`ScrollView.contentContainerStyle` is the canonical trap. It looks like a style prop, but it is not the same prop that Unistyles' remapped native component registers by default. The upstream tutorial calls this out directly in its [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue) section.
|
||||
|
||||
Avoid this pattern when the style depends on the theme:
|
||||
|
||||
```tsx
|
||||
<ScrollView contentContainerStyle={styles.container} />;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flexGrow: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
On first mount this can paint with the current adaptive or initial theme. If app settings later load a persisted theme and call [`UnistylesRuntime.setTheme`](https://www.unistyl.es/v3/guides/theming#change-theme), the JS-side style proxy may report the new theme while the native content container keeps the old background. That is how the welcome screen ended up with a light background and dark foreground/buttons.
|
||||
|
||||
This applies broadly to non-`style` props that carry theme-dependent values, such as component props named `color`, `trackColor`, `tintColor`, `backgroundStyle`, `handleIndicatorStyle`, and other library-specific style props. The [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views) recommends explicit handling for these cases, and [issue #1030](https://github.com/jpudysz/react-native-unistyles/issues/1030) shows a related native-prop update edge case around `Image.tintColor`. Treat these values as React props unless wrapped with `withUnistyles`.
|
||||
|
||||
## Fix Patterns
|
||||
|
||||
Preferred pattern: put themed backgrounds on a normal wrapper view, and keep `contentContainerStyle` theme-free.
|
||||
|
||||
```tsx
|
||||
<View style={styles.container}>
|
||||
<ScrollView contentContainerStyle={styles.contentContainer}>{children}</ScrollView>
|
||||
</View>;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
contentContainer: {
|
||||
flexGrow: 1,
|
||||
padding: theme.spacing[4],
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
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):
|
||||
|
||||
```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.
|
||||
|
||||
The smallest escape hatch is to use `useUnistyles()` and pass an inline value through React:
|
||||
|
||||
```tsx
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.contentContainer, { backgroundColor: theme.colors.surface0 }]}
|
||||
/>;
|
||||
```
|
||||
|
||||
Use this sparingly. It works because React re-renders the prop, but it gives up the main Unistyles native-update path for that value.
|
||||
|
||||
## `withUnistyles` And The `> *` Child-Selector Leak
|
||||
|
||||
`withUnistyles` on a component with a theme-dependent `style` prop works by wrapping the component in a `<div style={{display: 'contents'}} className={hash}>` and emitting the style under a `.hash > *` child selector so the styles cascade onto the wrapped component. This is how auto-mapping for `style` and `contentContainerStyle` works on web.
|
||||
|
||||
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:
|
||||
|
||||
```css
|
||||
.unistyles_j2k2iilhfz {
|
||||
flex: 1 1 0%;
|
||||
background-color: var(--colors-surface0);
|
||||
}
|
||||
.unistyles_j2k2iilhfz > * {
|
||||
flex: 1 1 0%;
|
||||
background-color: var(--colors-surface0);
|
||||
}
|
||||
```
|
||||
|
||||
The child-selector rule forced `flex:1` and `background-color: surface0` onto the Composer's outer `Animated.View` (a direct child of `container`), stretching it to fill remaining space and leaving a large empty gap between the composer UI and the bottom of the screen. It also painted a `surface0` band behind the scroll-to-bottom button. The bug only appeared in the browser — Electron skips `WelcomeScreen` after pairing, so the `> *` rule was never injected there.
|
||||
|
||||
Symptoms to watch for:
|
||||
|
||||
- A sibling of a themed panel-background `View` stretches unexpectedly on web only.
|
||||
- Random direct children of a `{ flex: 1, backgroundColor: surface0 }` `View` pick up an unexpected background.
|
||||
- DevTools shows a `.unistyles_xxx > *` rule you did not write.
|
||||
|
||||
Quick confirmation in DevTools console:
|
||||
|
||||
```js
|
||||
[...document.styleSheets]
|
||||
.flatMap((s) => [...(s.cssRules || [])])
|
||||
.map((r) => r.cssText)
|
||||
.filter((t) => t.includes("unistyles") && t.includes("> *"));
|
||||
```
|
||||
|
||||
Any match beyond benign `r-pointerEvents-* > *` rules from react-native-web is a leak.
|
||||
|
||||
Avoid the bug by preferring the wrapper-`View` pattern from the previous section whenever possible: put `{ flex: 1, backgroundColor: surface0 }` on a plain `View` and give the `ScrollView` a theme-free `style`/`contentContainerStyle`. That keeps `withUnistyles` off the hot path and avoids the hash collision. Only reach for `withUnistyles(ScrollView)` when a wrapper view is genuinely awkward, and when you do, give the wrapped style a distinctive shape (extra key, different layout) so it does not hash-collide with a common panel background used elsewhere.
|
||||
|
||||
## Hidden Sheet Content
|
||||
|
||||
`@gorhom/bottom-sheet` can keep `BottomSheetModal` content mounted while the sheet is hidden. That matters during Paseo's startup theme transition: a header node can be created under the initial adaptive theme, stay hidden, then appear later with stale native style values even though surrounding content has re-rendered correctly.
|
||||
|
||||
We saw this in `AdaptiveModalSheet`: the body text and buttons were dark-theme-correct, but the shared sheet title opened with the initial light-theme text color on a dark sheet background. For tiny values in a reusable sheet header, prefer the inline escape hatch:
|
||||
|
||||
```tsx
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
<Text style={[styles.title, { color: theme.colors.foreground }]}>{title}</Text>;
|
||||
```
|
||||
|
||||
Keep layout and typography in `StyleSheet.create`; move only the stale theme-dependent value through React. If a larger subtree shows the same behavior, consider remounting the sheet on theme changes or moving the themed paint onto a wrapper that is mounted with the visible content.
|
||||
|
||||
The same rule applies to bottom-sheet component props such as `backgroundStyle` and `handleIndicatorStyle`: they are library props, not the direct React Native `style` prop Unistyles registers. Prefer a custom `backgroundComponent` that calls `useUnistyles()`, or pass a small inline object from the hook theme.
|
||||
|
||||
## Memoized Style Objects
|
||||
|
||||
When a third-party library receives a plain style object, it is outside Unistyles' native tracking path. Make sure any memo that builds that style object depends on the actual theme values it reads.
|
||||
|
||||
Avoid indirect keys like this:
|
||||
|
||||
```tsx
|
||||
const { theme, rt } = useUnistyles();
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [rt.themeName]);
|
||||
```
|
||||
|
||||
On adaptive system-theme changes, the hook can provide a light/dark theme update while an indirect runtime key is not the value that invalidates the memo. That leaves the library rendering stale colors. Assistant markdown hit this exact failure: the workspace shell switched to light, but assistant text and code spans kept the old dark-theme markdown style object.
|
||||
|
||||
Prefer the hook theme itself, or explicit theme tokens, as the dependency:
|
||||
|
||||
```tsx
|
||||
const { theme } = useUnistyles();
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
```
|
||||
|
||||
If a style factory is cheap, skipping `useMemo` entirely is also fine.
|
||||
|
||||
## Static Theme Imports
|
||||
|
||||
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:
|
||||
|
||||
```tsx
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />;
|
||||
```
|
||||
|
||||
Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
|
||||
|
||||
## Adaptive Themes And Persisted Settings
|
||||
|
||||
Unistyles [`initialTheme`](https://www.unistyl.es/v3/guides/theming#select-theme) and [`adaptiveThemes`](https://www.unistyl.es/v3/guides/theming#adaptive-themes) are mutually exclusive. `initialTheme` can be a string or a synchronous function, but it cannot wait on async storage.
|
||||
|
||||
Paseo currently stores app settings in AsyncStorage and loads them through react-query. That means the app can mount under adaptive/system theme first, then switch after settings load:
|
||||
|
||||
1. Unistyles config starts with `adaptiveThemes: true`.
|
||||
2. The device may report system light.
|
||||
3. Settings load a persisted non-auto preference, such as dark.
|
||||
4. The app calls `setAdaptiveThemes(false)` and `setTheme("dark")`.
|
||||
|
||||
That brief transition is expected with the current storage model. It makes tracking-compatible styles important: anything mounted during the initial adaptive theme must update correctly after the persisted preference applies. [Issue #550](https://github.com/jpudysz/react-native-unistyles/issues/550) was a separate ScrollView sticky-header bug, but it is still useful context for why ScrollView theme updates deserve extra suspicion.
|
||||
|
||||
If we ever need to avoid the transition entirely, store at least the theme preference in synchronous storage and configure Unistyles with `initialTheme`.
|
||||
|
||||
## Debugging
|
||||
|
||||
To inspect what the Babel plugin sees, temporarily enable [`debug: true`](https://www.unistyl.es/v3/other/babel-plugin#debug) in `packages/app/babel.config.js`:
|
||||
|
||||
```js
|
||||
[
|
||||
"react-native-unistyles/plugin",
|
||||
{
|
||||
root: "src",
|
||||
debug: true,
|
||||
},
|
||||
],
|
||||
```
|
||||
|
||||
Then rebuild the bundle and look for lines such as:
|
||||
|
||||
```text
|
||||
src/components/welcome-screen.tsx: styles.container: [Theme]
|
||||
```
|
||||
|
||||
This only confirms that the stylesheet dependency was detected. The upstream debugging guide makes the same distinction: dependency detection is only one failure mode. It does not prove the style prop is registered on the native view you care about.
|
||||
|
||||
For paint-layer bugs, use high-contrast probes:
|
||||
|
||||
1. Paint each candidate layer a distinct color, such as root wrapper cyan, `ScrollView.style` yellow, and `contentContainerStyle` magenta.
|
||||
2. Cold-restart the app, not just Fast Refresh.
|
||||
3. Screenshot the simulator and sample pixels to see which color fills the area.
|
||||
4. Remove the probes before committing.
|
||||
|
||||
The welcome-screen investigation used this approach to prove the white layer was the `ScrollView` content container. Deep-dive evidence is in [welcome-theme-split-research.md](/Users/moboudra/.paseo/notes/welcome-theme-split-research.md).
|
||||
|
||||
## References
|
||||
|
||||
- [Unistyles v3 documentation](https://www.unistyl.es/)
|
||||
- [Theming: initial theme, adaptive themes, and runtime theme changes](https://www.unistyl.es/v3/guides/theming)
|
||||
- [ScrollView Background Issue](https://www.unistyl.es/v3/tutorial/settings-screen#scrollview-background-issue)
|
||||
- [withUnistyles reference](https://www.unistyl.es/v3/references/with-unistyles)
|
||||
- [3rd-party view decision algorithm](https://www.unistyl.es/v3/references/3rd-party-views)
|
||||
- [Babel plugin debug option](https://www.unistyl.es/v3/other/babel-plugin#debug)
|
||||
- [Why my view doesn't update?](https://www.unistyl.es/v3/guides/why-my-view-doesnt-update)
|
||||
- [GitHub issue #550: ScrollView sticky-header theme updates](https://github.com/jpudysz/react-native-unistyles/issues/550)
|
||||
- [GitHub issue #817: `UnistylesRuntime.themeName` does not re-render](https://github.com/jpudysz/react-native-unistyles/issues/817)
|
||||
- [GitHub issue #1030: `Image.tintColor` and native style update edge case](https://github.com/jpudysz/react-native-unistyles/issues/1030)
|
||||
- [Local research note: welcome theme split](/Users/moboudra/.paseo/notes/welcome-theme-split-research.md)
|
||||
68
docs/plan-approval-normalization.md
Normal file
68
docs/plan-approval-normalization.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# 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`
|
||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1772963539,
|
||||
"narHash": "sha256-9jVDGZnvCckTGdYT53d/EfznygLskyLQXYwJLKMPsZs=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "9dcb002ca1690658be4a04645215baea8b95f31d",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
59
flake.nix
Normal file
59
flake.nix
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
description = "Paseo - self-hosted daemon for AI coding agents";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
}:
|
||||
let
|
||||
supportedSystems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
"x86_64-darwin"
|
||||
"aarch64-darwin"
|
||||
];
|
||||
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
||||
pkgsFor = system: import nixpkgs { inherit system; };
|
||||
in
|
||||
{
|
||||
packages = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
paseo = pkgs.callPackage ./nix/package.nix { };
|
||||
in
|
||||
{
|
||||
default = paseo;
|
||||
paseo = paseo;
|
||||
}
|
||||
);
|
||||
|
||||
nixosModules.default = self.nixosModules.paseo;
|
||||
nixosModules.paseo =
|
||||
{ pkgs, lib, ... }:
|
||||
{
|
||||
imports = [ ./nix/module.nix ];
|
||||
services.paseo.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||
};
|
||||
|
||||
devShells = forAllSystems (
|
||||
system:
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
in
|
||||
{
|
||||
default = pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.nodejs_22
|
||||
pkgs.python3
|
||||
];
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
96
knip.json
Normal file
96
knip.json
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"$schema": "./node_modules/knip/schema.json",
|
||||
"workspaces": {
|
||||
".": {
|
||||
"entry": ["scripts/**/*.{js,mjs,cjs,ts}"],
|
||||
"project": ["scripts/**/*.{js,mjs,cjs,ts}"]
|
||||
},
|
||||
"packages/server": {
|
||||
"entry": [
|
||||
"src/server/index.ts",
|
||||
"src/server/exports.ts",
|
||||
"src/utils/tool-call-parsers.ts",
|
||||
"src/shared/**/*.ts",
|
||||
"src/client/**/*.ts",
|
||||
"src/server/agent/agent-sdk-types.ts",
|
||||
"src/server/agent/provider-manifest.ts",
|
||||
"scripts/**/*.{ts,mts,mjs,cjs,js}",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.e2e.ts",
|
||||
"src/**/*.e2e.tsx"
|
||||
],
|
||||
"project": ["src/**/*.{ts,tsx}", "scripts/**/*.{ts,mts,mjs,cjs,js}"]
|
||||
},
|
||||
"packages/app": {
|
||||
"entry": [
|
||||
"index.ts",
|
||||
"app.config.js",
|
||||
"babel.config.js",
|
||||
"app/**/*.{ts,tsx}",
|
||||
"src/**/*.test.{ts,tsx}",
|
||||
"src/**/*.e2e.{ts,tsx}",
|
||||
"src/**/*.native.{ts,tsx}",
|
||||
"e2e/**/*.{ts,tsx}",
|
||||
"playwright.config.{ts,js}",
|
||||
"vitest.config.{ts,js}",
|
||||
"test-stubs/**/*.ts"
|
||||
],
|
||||
"project": ["**/*.{ts,tsx,js,jsx}"],
|
||||
"paths": {
|
||||
"@server/*": ["../server/src/*"]
|
||||
},
|
||||
"ignore": ["android/**", "ios/**", ".expo/**", "dist/**", "scripts/reset-project.js"]
|
||||
},
|
||||
"packages/cli": {
|
||||
"entry": ["src/index.ts", "bin/paseo", "src/**/*.test.{ts,tsx}", "tests/**/*.{ts,tsx}"],
|
||||
"project": ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"]
|
||||
},
|
||||
"packages/relay": {
|
||||
"entry": ["src/index.ts", "src/e2ee.ts", "src/cloudflare-adapter.ts", "src/**/*.test.ts"],
|
||||
"project": ["src/**/*.ts"]
|
||||
},
|
||||
"packages/website": {
|
||||
"entry": ["src/router.tsx", "vite.config.ts", "src/routes/**/*.tsx"],
|
||||
"project": ["src/**/*.{ts,tsx}"],
|
||||
"ignore": ["src/routeTree.gen.ts"]
|
||||
},
|
||||
"packages/desktop": {
|
||||
"entry": ["src/main.ts", "src/preload.ts", "src/**/*.test.ts"],
|
||||
"project": ["src/**/*.ts"]
|
||||
},
|
||||
"packages/highlight": {
|
||||
"entry": ["src/index.ts", "src/**/*.test.ts"],
|
||||
"project": ["src/**/*.ts"]
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"entry": ["src/index.ts"],
|
||||
"project": ["src/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"ignoreDependencies": [
|
||||
"sherpa-onnx-node",
|
||||
"@playwright/test",
|
||||
"material-icon-theme",
|
||||
"eas-cli",
|
||||
"wait-on",
|
||||
"concurrently",
|
||||
"get-port-cli",
|
||||
"patch-package",
|
||||
"cross-env",
|
||||
"expo-module-scripts",
|
||||
"buffer",
|
||||
"metro-config"
|
||||
],
|
||||
"ignoreBinaries": [
|
||||
"expo-module",
|
||||
"xed",
|
||||
"eas",
|
||||
"playwright",
|
||||
"wrangler",
|
||||
"powershell",
|
||||
"tsx",
|
||||
"vitest",
|
||||
"open"
|
||||
]
|
||||
}
|
||||
11
lefthook.yml
Normal file
11
lefthook.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
pre-commit:
|
||||
parallel: true
|
||||
jobs:
|
||||
- name: format
|
||||
glob: "*.{css,js,json,jsonc,jsx,md,ts,tsx,yaml,yml}"
|
||||
run: npm run format:check:files -- {staged_files}
|
||||
- name: lint
|
||||
glob: "*.{js,jsx,ts,tsx}"
|
||||
run: npm run lint -- {staged_files}
|
||||
- name: typecheck
|
||||
run: npm run typecheck
|
||||
176
nix/module.nix
Normal file
176
nix/module.nix
Normal file
@@ -0,0 +1,176 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
cfg = config.services.paseo;
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
(lib.mkRenamedOptionModule [ "services" "paseo" "allowedHosts" ] [ "services" "paseo" "hostnames" ])
|
||||
];
|
||||
|
||||
options.services.paseo = {
|
||||
enable = lib.mkEnableOption "Paseo, a self-hosted daemon for AI coding agents";
|
||||
|
||||
package = lib.mkPackageOption pkgs "paseo" { };
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "paseo";
|
||||
description = "User account under which Paseo runs.";
|
||||
};
|
||||
|
||||
group = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "paseo";
|
||||
description = "Group under which Paseo runs.";
|
||||
};
|
||||
|
||||
dataDir = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default =
|
||||
if cfg.user == "paseo"
|
||||
then "/var/lib/paseo"
|
||||
else "/home/${cfg.user}/.paseo";
|
||||
defaultText = lib.literalExpression ''
|
||||
if cfg.user == "paseo"
|
||||
then "/var/lib/paseo"
|
||||
else "/home/''${cfg.user}/.paseo"
|
||||
'';
|
||||
description = "Directory for Paseo state (PASEO_HOME). Stores agent data, config, and logs.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 6767;
|
||||
description = "Port for the Paseo daemon to listen on.";
|
||||
};
|
||||
|
||||
listenAddress = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "127.0.0.1";
|
||||
description = "Address for the Paseo daemon to bind to.";
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Whether to open the firewall for the Paseo daemon port.";
|
||||
};
|
||||
|
||||
hostnames = lib.mkOption {
|
||||
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
|
||||
default = [ ];
|
||||
example = [ ".example.com" "myhost.local" ];
|
||||
description = ''
|
||||
Hostnames the Paseo daemon accepts in the Host header (DNS rebinding protection).
|
||||
Localhost and IP addresses are always allowed by default.
|
||||
|
||||
Use a leading dot to match a domain and all its subdomains
|
||||
(e.g. `".example.com"` matches `example.com` and `foo.example.com`).
|
||||
|
||||
Set to `true` to allow any host (not recommended).
|
||||
'';
|
||||
};
|
||||
|
||||
relay = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
|
||||
};
|
||||
};
|
||||
|
||||
inheritUserEnvironment = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = cfg.user != "paseo";
|
||||
defaultText = lib.literalExpression ''cfg.user != "paseo"'';
|
||||
description = ''
|
||||
Whether to include the user's profile PATH in the service environment.
|
||||
|
||||
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.
|
||||
|
||||
Enabled by default when `user` is set to a non-default value.
|
||||
'';
|
||||
};
|
||||
|
||||
environment = lib.mkOption {
|
||||
type = lib.types.attrsOf lib.types.str;
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
PASEO_RELAY_ENDPOINT = "relay.paseo.sh:443";
|
||||
}
|
||||
'';
|
||||
description = "Extra environment variables for the Paseo daemon.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
home = cfg.dataDir;
|
||||
};
|
||||
|
||||
users.groups.${cfg.group} = lib.mkIf (cfg.group == "paseo") { };
|
||||
|
||||
systemd.tmpfiles.rules = [
|
||||
"d ${cfg.dataDir} 0700 ${cfg.user} ${cfg.group} - -"
|
||||
];
|
||||
|
||||
systemd.services.paseo = {
|
||||
description = "Paseo - self-hosted daemon for AI coding agents";
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
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) {
|
||||
PASEO_HOSTNAMES = "true";
|
||||
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
|
||||
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
|
||||
} // cfg.environment;
|
||||
|
||||
serviceConfig = {
|
||||
Type = "simple";
|
||||
User = cfg.user;
|
||||
Group = cfg.group;
|
||||
|
||||
ExecStart =
|
||||
"${cfg.package}/bin/paseo-server"
|
||||
+ lib.optionalString (!cfg.relay.enable) " --no-relay";
|
||||
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
|
||||
# Graceful shutdown (server handles SIGTERM with a 10s timeout)
|
||||
KillSignal = "SIGTERM";
|
||||
TimeoutStopSec = 15;
|
||||
};
|
||||
};
|
||||
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||
};
|
||||
}
|
||||
144
nix/package.nix
Normal file
144
nix/package.nix
Normal file
@@ -0,0 +1,144 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
buildNpmPackage,
|
||||
nodejs_22,
|
||||
python3,
|
||||
makeWrapper,
|
||||
# node-pty needs libuv headers on Linux
|
||||
libuv,
|
||||
}:
|
||||
|
||||
buildNpmPackage rec {
|
||||
pname = "paseo";
|
||||
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 non-daemon workspace contents (keep package.json for workspace resolution)
|
||||
!(lib.hasPrefix "/packages/app/src" relPath)
|
||||
&& !(lib.hasPrefix "/packages/app/assets" relPath)
|
||||
&& !(lib.hasPrefix "/packages/app/android" relPath)
|
||||
&& !(lib.hasPrefix "/packages/app/ios" relPath)
|
||||
&& !(lib.hasPrefix "/packages/website/src" relPath)
|
||||
&& !(lib.hasPrefix "/packages/website/public" relPath)
|
||||
&& !(lib.hasPrefix "/packages/desktop/src" relPath)
|
||||
&& !(lib.hasPrefix "/packages/desktop/src-tauri" relPath)
|
||||
# Exclude test fixtures and debug files
|
||||
&& !(lib.hasSuffix ".test.ts" baseName)
|
||||
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
|
||||
&& baseName != "node_modules"
|
||||
&& baseName != ".git"
|
||||
&& baseName != ".paseo"
|
||||
&& baseName != ".DS_Store";
|
||||
};
|
||||
|
||||
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-BEZxYUs3aqHTToxljhmDdDkw7udMDOLBwyPK08JA9w8=";
|
||||
|
||||
# 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).
|
||||
# We manually rebuild only node-pty in buildPhase.
|
||||
npmRebuildFlags = [ "--ignore-scripts" ];
|
||||
|
||||
nativeBuildInputs = [
|
||||
python3 # for node-gyp (node-pty compilation)
|
||||
makeWrapper
|
||||
];
|
||||
|
||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
|
||||
libuv
|
||||
];
|
||||
|
||||
# Don't use the default npm build hook — we need a custom build sequence
|
||||
dontNpmBuild = true;
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
# Rebuild only node-pty (native addon for terminal emulation).
|
||||
# Speech-related native modules (sherpa-onnx, onnxruntime-node) are
|
||||
# intentionally left unbuilt — they're lazily loaded and gracefully
|
||||
# degrade when unavailable.
|
||||
npm rebuild node-pty
|
||||
|
||||
# Build all daemon packages in dependency order (defined in package.json)
|
||||
npm run build:daemon
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/lib/paseo
|
||||
|
||||
# Copy root package metadata
|
||||
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" \
|
||||
--set NODE_ENV production
|
||||
|
||||
# Create wrapper for the CLI
|
||||
makeWrapper ${nodejs}/bin/node $out/bin/paseo \
|
||||
--add-flags "$out/lib/paseo/packages/cli/dist/index.js" \
|
||||
--set NODE_PATH "$out/lib/paseo/node_modules"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "Self-hosted daemon for Claude Code, Codex, and OpenCode";
|
||||
homepage = "https://github.com/getpaseo/paseo";
|
||||
license = lib.licenses.agpl3Plus;
|
||||
mainProgram = "paseo";
|
||||
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||
};
|
||||
}
|
||||
24751
package-lock.json
generated
24751
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
103
package.json
103
package.json
@@ -1,8 +1,29 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.64",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
"development",
|
||||
"mcp",
|
||||
"openai",
|
||||
"realtime",
|
||||
"voice",
|
||||
"voice-assistant"
|
||||
],
|
||||
"homepage": "https://paseo.sh",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": {
|
||||
"name": "Mohamed Boudra",
|
||||
"email": "hello@moboudra.com"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/getpaseo/paseo.git"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
"packages/server",
|
||||
"packages/app",
|
||||
"packages/relay",
|
||||
@@ -12,63 +33,81 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev:server": "NODE_ENV=development tsx packages/server/scripts/daemon-runner.ts --dev",
|
||||
"dev:win": "powershell ./scripts/dev.ps1",
|
||||
"dev:server": "npm run dev --workspace=@getpaseo/server",
|
||||
"dev:app": "npm run start --workspace=@getpaseo/app",
|
||||
"dev:website": "npm run dev --workspace=@getpaseo/website",
|
||||
"postinstall": "node scripts/postinstall-patches.mjs",
|
||||
"prepare": "lefthook install --force",
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"build:highlight": "npm run build --workspace=@getpaseo/highlight",
|
||||
"build:daemon": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"typecheck:daemon": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"format": "oxfmt .",
|
||||
"format:files": "oxfmt",
|
||||
"format:check": "oxfmt --check .",
|
||||
"format:check:files": "oxfmt --check",
|
||||
"lint": "oxlint",
|
||||
"lint:fix": "oxlint --fix",
|
||||
"knip": "knip",
|
||||
"start": "npm run start --workspace=@getpaseo/server",
|
||||
"android": "npm run android --workspace=@getpaseo/app",
|
||||
"android:development": "npm run android:development --workspace=@getpaseo/app",
|
||||
"android:prod": "npm run android:prod --workspace=@getpaseo/app",
|
||||
"android:production": "npm run android:production --workspace=@getpaseo/app",
|
||||
"android:release": "npm run android:prod --workspace=@getpaseo/app",
|
||||
"android:release": "npm run android:production --workspace=@getpaseo/app",
|
||||
"android:clear": "npm run android:clear --workspace=@getpaseo/app",
|
||||
"android:clean": "npm run android:clean --workspace=@getpaseo/app",
|
||||
"ios": "npm run ios --workspace=@getpaseo/app",
|
||||
"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",
|
||||
"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",
|
||||
"version:sync-internal": "node scripts/sync-workspace-versions.mjs",
|
||||
"release:prepare": "npm install --workspaces --include-workspace-root",
|
||||
"version:all:patch": "npm version patch --include-workspace-root --message \"chore(release): cut %s\"",
|
||||
"version:all:minor": "npm version minor --include-workspace-root --message \"chore(release): cut %s\"",
|
||||
"version:all:major": "npm version major --include-workspace-root --message \"chore(release): cut %s\"",
|
||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||
"release:publish": "npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||
"version:all:patch": "node scripts/set-release-version.mjs --mode patch",
|
||||
"version:all:minor": "node scripts/set-release-version.mjs --mode minor",
|
||||
"version:all:major": "node scripts/set-release-version.mjs --mode major",
|
||||
"version:all:beta:patch": "node scripts/set-release-version.mjs --mode beta-patch",
|
||||
"version:all:beta:minor": "node scripts/set-release-version.mjs --mode beta-minor",
|
||||
"version:all:beta:major": "node scripts/set-release-version.mjs --mode beta-major",
|
||||
"version:all:beta:next": "node scripts/set-release-version.mjs --mode beta-next",
|
||||
"version:all:promote": "node scripts/set-release-version.mjs --mode promote",
|
||||
"release:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run build --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm run build --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/server && npm pack --dry-run --workspace=@getpaseo/cli",
|
||||
"release:publish:dry-run": "npm publish --dry-run --workspace=@getpaseo/highlight --access public && npm publish --dry-run --workspace=@getpaseo/relay --access public && npm publish --dry-run --workspace=@getpaseo/server --access public && npm publish --dry-run --workspace=@getpaseo/cli --access public",
|
||||
"release:publish": "npm publish --workspace=@getpaseo/highlight --access public && npm publish --workspace=@getpaseo/relay --access public && npm publish --workspace=@getpaseo/server --access public && npm publish --workspace=@getpaseo/cli --access public",
|
||||
"release:push": "node scripts/push-current-release-tag.mjs",
|
||||
"release:patch": "npm run version:all:patch && npm run release:check && npm run release:publish && npm run release:push",
|
||||
"release:minor": "npm run version:all:minor && npm run release:check && npm run release:publish && npm run release:push",
|
||||
"release:major": "npm run version:all:major && npm run release:check && npm run release:publish && npm run release:push"
|
||||
"release:beta:patch": "npm run release:check && npm run version:all:beta:patch && npm run release:push",
|
||||
"release:beta:minor": "npm run release:check && npm run version:all:beta:minor && npm run release:push",
|
||||
"release:beta:major": "npm run release:check && npm run version:all:beta:major && npm run release:push",
|
||||
"release:beta:next": "npm run release:check && npm run version:all:beta:next && npm run release:push",
|
||||
"release:promote": "npm run release:check && npm run version:all:promote && npm run release:publish && npm run release:push",
|
||||
"release:patch": "npm run release:check && npm run version:all:patch && npm run release:publish && npm run release:push",
|
||||
"release:minor": "npm run release:check && npm run version:all:minor && npm run release:publish && npm run release:push",
|
||||
"release:major": "npm run release:check && npm run version:all:major && npm run release:publish && npm run release:push"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.14",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260423.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"prettier": "^3.5.3",
|
||||
"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",
|
||||
"patch-package": "^8.0.1",
|
||||
"typescript": "^5.9.3"
|
||||
"playwright": "^1.56.1",
|
||||
"typescript": "^5.9.3",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
"openai",
|
||||
"realtime",
|
||||
"voice",
|
||||
"voice-assistant",
|
||||
"development",
|
||||
"mcp"
|
||||
],
|
||||
"author": "moboudra",
|
||||
"license": "MIT",
|
||||
"overrides": {
|
||||
"lightningcss": "1.30.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11"
|
||||
"lightningcss": "1.30.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "!v*-rc.*"
|
||||
workflow_dispatch: {}
|
||||
|
||||
jobs:
|
||||
@@ -36,3 +37,16 @@ jobs:
|
||||
params:
|
||||
build_id: ${{ needs.build_android.outputs.build_id }}
|
||||
profile: production
|
||||
|
||||
submit_ios_for_review:
|
||||
name: Submit iOS for App Store review
|
||||
needs: [submit_ios]
|
||||
environment: production
|
||||
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
|
||||
|
||||
3
packages/app/.gitignore
vendored
3
packages/app/.gitignore
vendored
@@ -37,6 +37,9 @@ yarn-error.*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# vitest browser failure screenshots
|
||||
.vitest-screenshots/
|
||||
|
||||
app-example
|
||||
|
||||
# generated native folders
|
||||
|
||||
3
packages/app/Gemfile
Normal file
3
packages/app/Gemfile
Normal file
@@ -0,0 +1,3 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane"
|
||||
@@ -65,8 +65,7 @@ export default {
|
||||
ios: {
|
||||
supportsTablet: true,
|
||||
infoPlist: {
|
||||
NSMicrophoneUsageDescription:
|
||||
"This app needs access to the microphone for voice commands.",
|
||||
NSMicrophoneUsageDescription: "This app needs access to the microphone for voice commands.",
|
||||
ITSAppUsesNonExemptEncryption: false,
|
||||
},
|
||||
bundleIdentifier: variant.packageId,
|
||||
@@ -92,14 +91,15 @@ export default {
|
||||
"android.permission.CAMERA",
|
||||
],
|
||||
package: variant.packageId,
|
||||
...(variant.googleServicesFile
|
||||
? { googleServicesFile: variant.googleServicesFile }
|
||||
: {}),
|
||||
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
|
||||
},
|
||||
web: {
|
||||
output: "single",
|
||||
favicon: "./assets/images/favicon.png",
|
||||
},
|
||||
autolinking: {
|
||||
searchPaths: ["../../node_modules", "./node_modules"],
|
||||
},
|
||||
plugins: [
|
||||
"expo-router",
|
||||
[
|
||||
@@ -143,6 +143,7 @@ export default {
|
||||
experiments: {
|
||||
typedRoutes: true,
|
||||
reactCompiler: true,
|
||||
autolinkingModuleResolution: true,
|
||||
},
|
||||
extra: {
|
||||
router: {},
|
||||
|
||||
BIN
packages/app/assets/audio/thinking-tone.wav
Normal file
BIN
packages/app/assets/audio/thinking-tone.wav
Normal file
Binary file not shown.
BIN
packages/app/assets/images/editor-apps/cursor.png
Normal file
BIN
packages/app/assets/images/editor-apps/cursor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
packages/app/assets/images/editor-apps/file-explorer.png
Normal file
BIN
packages/app/assets/images/editor-apps/file-explorer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
BIN
packages/app/assets/images/editor-apps/finder.png
Normal file
BIN
packages/app/assets/images/editor-apps/finder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
packages/app/assets/images/editor-apps/vscode.png
Normal file
BIN
packages/app/assets/images/editor-apps/vscode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
BIN
packages/app/assets/images/editor-apps/webstorm.png
Normal file
BIN
packages/app/assets/images/editor-apps/webstorm.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
BIN
packages/app/assets/images/editor-apps/zed.png
Normal file
BIN
packages/app/assets/images/editor-apps/zed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@@ -1,37 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
test("agent details sheet shows IDs and copy toast", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const repo = await createTempGitRepo();
|
||||
const prompt = "Respond with exactly: Hello";
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, prompt);
|
||||
|
||||
await page.getByTestId("agent-overflow-menu").click();
|
||||
await page.getByTestId("agent-menu-details").click();
|
||||
|
||||
await expect(page.getByTestId("agent-details-sheet")).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("agent-details-agent-id")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-details-agent-id-value")).not.toHaveText(
|
||||
"Not available"
|
||||
);
|
||||
|
||||
await expect(page.getByTestId("agent-details-persistence-session-id")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("agent-details-persistence-session-id-value")
|
||||
).not.toHaveText("Not available", { timeout: 90_000 });
|
||||
|
||||
await page.getByTestId("agent-details-agent-id").click();
|
||||
await expect(page.getByTestId("app-toast")).toBeVisible();
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText(/copied/i);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('agent timeline hydrates after reload via fetch_agent_timeline_request', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const marker = 'TIMELINE_HYDRATION_OK';
|
||||
const prompt = `Respond with exactly: ${marker}`;
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, prompt);
|
||||
|
||||
const assistantMessage = page
|
||||
.getByTestId('assistant-message')
|
||||
.filter({ hasText: marker })
|
||||
.first();
|
||||
await expect(assistantMessage).toBeVisible({ timeout: 120000 });
|
||||
|
||||
await page.reload({ waitUntil: 'commit' });
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
await expect(page.getByTestId('agent-loading')).toHaveCount(0, { timeout: 30000 });
|
||||
await expect(page.getByText(prompt, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(
|
||||
page.getByTestId('assistant-message').filter({ hasText: marker }).first()
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
129
packages/app/e2e/archive-tab.spec.ts
Normal file
129
packages/app/e2e/archive-tab.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
archiveAgentFromSessions,
|
||||
clickSessionRow,
|
||||
closeWorkspaceAgentTab,
|
||||
connectArchiveTabDaemonClient,
|
||||
createIdleAgent,
|
||||
expectArchivedAgentFocused,
|
||||
expectSessionRowArchived,
|
||||
expectSessionRowVisible,
|
||||
expectWorkspaceArchiveOutcome,
|
||||
expectWorkspaceTabHidden,
|
||||
openSessions,
|
||||
openWorkspaceWithAgents,
|
||||
primeAdditionalPage,
|
||||
resetSeededPageState,
|
||||
reloadWorkspace,
|
||||
} from "./helpers/archive-tab";
|
||||
|
||||
test.describe("Archive tab reconciliation", () => {
|
||||
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
|
||||
test.describe.configure({ timeout: 300_000 });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("archive-tab-");
|
||||
client = await connectArchiveTabDaemonClient();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await client?.close().catch(() => undefined);
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
test("non-UI archive prunes the archived tab across open pages and reload", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
title: `cli-archive-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const surviving = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
title: `cli-control-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const passivePage = await page.context().newPage();
|
||||
|
||||
try {
|
||||
await primeAdditionalPage(passivePage);
|
||||
await resetSeededPageState(page);
|
||||
await resetSeededPageState(passivePage);
|
||||
await openSessions(page);
|
||||
await expectSessionRowVisible(page, archived.title);
|
||||
await expectSessionRowVisible(page, surviving.title);
|
||||
await openSessions(passivePage);
|
||||
await expectSessionRowVisible(passivePage, archived.title);
|
||||
await expectSessionRowVisible(passivePage, surviving.title);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
|
||||
await archiveAgentFromDaemon(client, archived.id);
|
||||
await expectWorkspaceArchiveOutcome(page, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await reloadWorkspace(passivePage, tempRepo.path);
|
||||
await expectWorkspaceTabHidden(passivePage, archived.id);
|
||||
} finally {
|
||||
await passivePage.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("Sessions archive prunes the archived tab across open pages", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
title: `ui-archive-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const surviving = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
title: `ui-control-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const passivePage = await page.context().newPage();
|
||||
|
||||
try {
|
||||
await primeAdditionalPage(passivePage);
|
||||
await resetSeededPageState(page);
|
||||
await resetSeededPageState(passivePage);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await openWorkspaceWithAgents(passivePage, [archived, surviving]);
|
||||
await openSessions(page);
|
||||
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
|
||||
await reloadWorkspace(page, tempRepo.path);
|
||||
await expectWorkspaceTabHidden(page, archived.id);
|
||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
} finally {
|
||||
await passivePage.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking an archived session reopens its closed tab focused", async ({ page }) => {
|
||||
const archived = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
title: `reopen-archived-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
const surviving = await createIdleAgent(client, {
|
||||
cwd: tempRepo.path,
|
||||
title: `reopen-control-${randomUUID().slice(0, 8)}`,
|
||||
});
|
||||
|
||||
await resetSeededPageState(page);
|
||||
await openWorkspaceWithAgents(page, [archived, surviving]);
|
||||
await closeWorkspaceAgentTab(page, archived.id);
|
||||
await archiveAgentFromDaemon(client, archived.id);
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, archived.title);
|
||||
|
||||
await clickSessionRow(page, archived.title);
|
||||
|
||||
await expectArchivedAgentFocused(page, archived.id);
|
||||
});
|
||||
});
|
||||
@@ -1,428 +0,0 @@
|
||||
import path from 'node:path';
|
||||
import { appendFile, mkdtemp, rm, writeFile, realpath } from 'node:fs/promises';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
import {
|
||||
ensureHostSelected,
|
||||
gotoHome,
|
||||
setWorkingDirectory,
|
||||
} from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test.describe.configure({ mode: 'serial', timeout: 120000 });
|
||||
|
||||
function getChangesScope(page: Page) {
|
||||
return page.locator('[data-testid="explorer-content-area"]:visible').first();
|
||||
}
|
||||
|
||||
function getChangesHeader(page: Page) {
|
||||
return getChangesScope(page).getByTestId('changes-header');
|
||||
}
|
||||
|
||||
async function selectChangesView(page: Page, view: 'working' | 'base') {
|
||||
// Defensive: close any open dropdown menus (their backdrops intercept clicks).
|
||||
const primaryBackdrop = page.getByTestId('changes-primary-cta-menu-backdrop');
|
||||
if (await primaryBackdrop.isVisible().catch(() => false)) {
|
||||
await primaryBackdrop.click({ force: true });
|
||||
await expect(primaryBackdrop).toHaveCount(0);
|
||||
}
|
||||
const overflowBackdrop = page.getByTestId('changes-overflow-content-backdrop');
|
||||
if (await overflowBackdrop.isVisible().catch(() => false)) {
|
||||
await overflowBackdrop.click({ force: true });
|
||||
await expect(overflowBackdrop).toHaveCount(0);
|
||||
}
|
||||
|
||||
const scope = getChangesScope(page);
|
||||
const modeToggle = scope.getByTestId('changes-diff-status').first();
|
||||
const expected = view === 'working' ? 'Uncommitted' : 'Committed';
|
||||
if (!(await modeToggle.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
const current = ((await modeToggle.innerText().catch(() => '')) ?? '').trim();
|
||||
if (current !== expected) {
|
||||
await modeToggle.click();
|
||||
}
|
||||
await expect(modeToggle).toContainText(expected, { timeout: 10000 });
|
||||
}
|
||||
|
||||
async function openChangesOverflowMenu(page: Page) {
|
||||
const menuButton = getChangesScope(page).locator('[data-testid="changes-overflow-menu"]:visible').first();
|
||||
await expect(menuButton).toBeVisible();
|
||||
await menuButton.click();
|
||||
}
|
||||
|
||||
async function openChangesPrimaryMenu(page: Page) {
|
||||
const scope = getChangesScope(page);
|
||||
const caret = scope.getByTestId('changes-primary-cta-caret').first();
|
||||
await expect(caret).toBeVisible();
|
||||
await caret.click();
|
||||
// Menu content is rendered via a portal, so don't scope it to the explorer content area.
|
||||
await expect(page.getByTestId('changes-primary-cta-menu')).toBeVisible();
|
||||
}
|
||||
|
||||
async function openChangesPanel(page: Page, options?: { expectGit?: boolean }) {
|
||||
const changesHeader = getChangesHeader(page);
|
||||
if (!(await changesHeader.isVisible())) {
|
||||
const explorerHeader = page.getByTestId('explorer-header');
|
||||
if (await explorerHeader.isVisible()) {
|
||||
const changesTab = explorerHeader.getByText('Changes', { exact: true });
|
||||
if (await changesTab.isVisible().catch(() => false)) {
|
||||
await changesTab.click();
|
||||
} else {
|
||||
const overflowMenu = page.getByTestId('agent-overflow-menu').first();
|
||||
await expect(overflowMenu).toBeVisible({ timeout: 10000 });
|
||||
await overflowMenu.click();
|
||||
await page.getByText(/view changes/i).first().click();
|
||||
}
|
||||
} else {
|
||||
const overflowMenu = page.getByTestId('agent-overflow-menu').first();
|
||||
await expect(overflowMenu).toBeVisible({ timeout: 10000 });
|
||||
await overflowMenu.click();
|
||||
await page.getByText(/view changes/i).first().click();
|
||||
}
|
||||
}
|
||||
await expect(changesHeader).toBeVisible({ timeout: 30000 });
|
||||
if (options?.expectGit === false) {
|
||||
return;
|
||||
}
|
||||
const changesScope = getChangesScope(page);
|
||||
await expect(changesScope.getByTestId('changes-not-git')).toHaveCount(0, {
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(changesScope.getByTestId('changes-branch')).not.toHaveText('Not a git repository', {
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async function sendPrompt(page: Page, prompt: string) {
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(prompt);
|
||||
await input.press('Enter');
|
||||
}
|
||||
|
||||
async function waitForAssistantText(page: Page, text: string) {
|
||||
const assistantMessage = page.getByTestId('assistant-message').filter({ hasText: text }).last();
|
||||
await expect(assistantMessage).toBeVisible({ timeout: 60000 });
|
||||
return assistantMessage;
|
||||
}
|
||||
|
||||
async function createAgentAndWait(page: Page, message: string) {
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(message);
|
||||
await input.press('Enter');
|
||||
await expect(page).toHaveURL(/\/agent\//, { timeout: 120000 });
|
||||
await expect(page.getByText(message, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function requestCwd(page: Page) {
|
||||
await sendPrompt(page, 'Run `pwd` and respond with exactly: CWD: <path>');
|
||||
const message = await waitForAssistantText(page, 'CWD:');
|
||||
// The assistant streams tokens; make sure we capture the full path (not a partial prefix).
|
||||
await expect.poll(async () => (await message.textContent()) ?? '', { timeout: 60000 }).toContain('/worktrees/');
|
||||
const content = (await message.textContent()) ?? '';
|
||||
const match = content.match(/CWD:\s*(\S+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected agent to respond with "CWD: <path>", got: ${content}`);
|
||||
}
|
||||
return match[1].trim();
|
||||
}
|
||||
|
||||
async function selectAttachWorktree(page: Page, branchName: string) {
|
||||
await page.getByTestId('worktree-attach-toggle').click();
|
||||
const picker = page.getByTestId('worktree-attach-picker');
|
||||
await expect(picker).toBeVisible();
|
||||
|
||||
// Wait a bit for the worktree list to load
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
await picker.click();
|
||||
|
||||
// Wait a bit for animation
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const sheet = page.getByLabel('Bottom Sheet', { exact: true });
|
||||
const backdrop = page.getByRole('button', { name: 'Bottom sheet backdrop' }).first();
|
||||
|
||||
await expect.poll(async () => {
|
||||
const sheetVisible = await sheet.isVisible().catch(() => false);
|
||||
const backdropVisible = await backdrop.isVisible().catch(() => false);
|
||||
// Also check if branch name is visible directly
|
||||
const branchVisible = await page.getByText(branchName, { exact: true }).first().isVisible().catch(() => false);
|
||||
return sheetVisible || backdropVisible || branchVisible;
|
||||
}, { timeout: 10000 }).toBeTruthy();
|
||||
const sheetVisible = await sheet.isVisible().catch(() => false);
|
||||
const scope = sheetVisible ? sheet : page;
|
||||
const preferredOption = scope.getByText(branchName, { exact: true }).first();
|
||||
if (await preferredOption.isVisible().catch(() => false)) {
|
||||
await preferredOption.click();
|
||||
await expect(picker).toContainText(branchName);
|
||||
return;
|
||||
}
|
||||
|
||||
const options = scope.locator('[data-testid^="worktree-attach-option-"]');
|
||||
const optionCount = await options.count();
|
||||
if (optionCount === 0) {
|
||||
throw new Error(`No worktree options were available in the attach picker`);
|
||||
}
|
||||
const fallbackOption = options.first();
|
||||
const fallbackLabel = ((await fallbackOption.innerText()) ?? "").trim();
|
||||
await fallbackOption.click();
|
||||
if (fallbackLabel.length > 0) {
|
||||
await expect(picker).toContainText(fallbackLabel);
|
||||
}
|
||||
}
|
||||
|
||||
async function enableCreateWorktree(page: Page) {
|
||||
const createToggle = page.getByTestId('worktree-create-toggle');
|
||||
const willCreateLabel = page.getByText(/Will create:/);
|
||||
if (await willCreateLabel.isVisible()) {
|
||||
return;
|
||||
}
|
||||
const readyLabel = page.getByText(
|
||||
/Run isolated from|Run in an isolated directory/
|
||||
);
|
||||
await expect(readyLabel).toBeVisible({ timeout: 30000 });
|
||||
await createToggle.click({ force: true });
|
||||
await expect(willCreateLabel).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
async function refreshUncommittedMode(page: Page) {
|
||||
await selectChangesView(page, 'base');
|
||||
await selectChangesView(page, 'working');
|
||||
}
|
||||
|
||||
async function refreshChangesTab(page: Page) {
|
||||
const header = page.locator('[data-testid="explorer-header"]:visible').first();
|
||||
await header.getByText('Files', { exact: true }).first().click();
|
||||
await header.getByText('Changes', { exact: true }).first().click();
|
||||
}
|
||||
|
||||
function normalizeTmpPath(value: string) {
|
||||
if (value.startsWith('/var/')) {
|
||||
return `/private${value}`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
const repo = await createTempGitRepo('paseo-e2e-', { withRemote: true });
|
||||
const nonGitDir = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-non-git-'));
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
await enableCreateWorktree(page);
|
||||
await createAgentAndWait(page, 'Respond with exactly: READY');
|
||||
await waitForAssistantText(page, 'READY');
|
||||
|
||||
await openChangesPanel(page);
|
||||
const branchLabelLocator = getChangesScope(page).getByTestId('changes-branch');
|
||||
await expect
|
||||
.poll(async () => (await branchLabelLocator.innerText()).trim(), { timeout: 30000 })
|
||||
.not.toBe('Unknown');
|
||||
const branchNameFromUi = (await branchLabelLocator.innerText()).trim();
|
||||
expect(branchNameFromUi.length).toBeGreaterThan(0);
|
||||
|
||||
const firstCwd = await requestCwd(page);
|
||||
const worktreeBranch = execSync('git rev-parse --abbrev-ref HEAD', {
|
||||
cwd: firstCwd,
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
expect(worktreeBranch.length).toBeGreaterThan(0);
|
||||
const [resolvedCwd, resolvedRepo] = await Promise.all([
|
||||
realpath(firstCwd).catch(() => firstCwd),
|
||||
realpath(repo.path).catch(() => repo.path),
|
||||
]);
|
||||
const normalizedRepo = normalizeTmpPath(resolvedRepo);
|
||||
const normalizedCwd = normalizeTmpPath(resolvedCwd);
|
||||
const expectedMarker = `${path.sep}worktrees${path.sep}`;
|
||||
expect(normalizedCwd.includes(expectedMarker)).toBeTruthy();
|
||||
|
||||
await page.getByTestId('sidebar-new-agent').click();
|
||||
await expect(page).toHaveURL(/\/agent\/?$/);
|
||||
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await selectAttachWorktree(page, worktreeBranch);
|
||||
await createAgentAndWait(page, 'Respond with exactly: READY2');
|
||||
await waitForAssistantText(page, 'READY2');
|
||||
|
||||
const secondCwd = await requestCwd(page);
|
||||
expect(secondCwd).toBe(firstCwd);
|
||||
|
||||
await sendPrompt(page, "Respond with exactly: OK");
|
||||
await waitForAssistantText(page, "OK");
|
||||
|
||||
const readmePath = path.join(firstCwd, 'README.md');
|
||||
await appendFile(readmePath, '\nFirst change\n');
|
||||
|
||||
await refreshUncommittedMode(page);
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await getChangesScope(page).getByTestId('diff-file-0-toggle').first().click();
|
||||
await expect(page.getByText('First change')).toBeVisible();
|
||||
const primaryCta = getChangesScope(page).getByTestId('changes-primary-cta').first();
|
||||
await expect(primaryCta).toBeVisible();
|
||||
await expect(primaryCta).toContainText('Commit');
|
||||
|
||||
await primaryCta.click();
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
return execSync('git status --porcelain', {
|
||||
cwd: firstCwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe('');
|
||||
await openChangesPanel(page);
|
||||
await selectChangesView(page, 'working');
|
||||
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Push once from the menu so the branch has an origin/<branch> ref.
|
||||
await openChangesPrimaryMenu(page);
|
||||
await page.getByTestId('changes-menu-push').click();
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
execSync(`git show-ref --verify --quiet refs/remotes/origin/${worktreeBranch}`, { cwd: firstCwd });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe(true);
|
||||
|
||||
const notesPath = path.join(firstCwd, 'notes.txt');
|
||||
await writeFile(notesPath, 'Second change\n');
|
||||
|
||||
await refreshUncommittedMode(page);
|
||||
await refreshChangesTab(page);
|
||||
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toHaveCount(0);
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toContainText('Commit');
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
await getChangesScope(page).getByTestId('changes-primary-cta').click();
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
return execSync('git status --porcelain', {
|
||||
cwd: firstCwd,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe('');
|
||||
await openChangesPanel(page);
|
||||
await selectChangesView(page, 'working');
|
||||
await expect(getChangesScope(page).getByText('No uncommitted changes')).toBeVisible({ timeout: 30000 });
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).not.toContainText('Commit');
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText('README.md', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
await expect(getChangesScope(page).getByText('notes.txt', { exact: true })).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Push is now the primary action (origin/<branch> exists and we're ahead of it).
|
||||
const pushPrimary = getChangesScope(page).getByTestId('changes-primary-cta').first();
|
||||
await expect(pushPrimary).toContainText(/push/i, { timeout: 30000 });
|
||||
await pushPrimary.click();
|
||||
// Regression check: the primary CTA stays in place while pushing.
|
||||
await expect(pushPrimary).toBeVisible();
|
||||
await page.waitForTimeout(50);
|
||||
await expect(pushPrimary).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(() => {
|
||||
try {
|
||||
const count = execSync(
|
||||
`git rev-list --count origin/${worktreeBranch}..${worktreeBranch}`,
|
||||
{ cwd: firstCwd, encoding: 'utf8' }
|
||||
).trim();
|
||||
return Number.parseInt(count, 10);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, { timeout: 30000 })
|
||||
.toBe(0);
|
||||
|
||||
// Merge to base in the main worktree (worktree branches can't always check out base refs in-place).
|
||||
// This avoids UI flakiness around ship actions while still validating the diff panel end-to-end.
|
||||
execSync("git checkout main", { cwd: repo.path });
|
||||
execSync(`git -c commit.gpgsign=false merge --no-edit ${worktreeBranch}`, { cwd: repo.path });
|
||||
execSync("git push", { cwd: repo.path });
|
||||
|
||||
await selectChangesView(page, 'base');
|
||||
await expect(getChangesScope(page).getByText(/No changes vs/i)).toBeVisible({
|
||||
timeout: 60000,
|
||||
});
|
||||
await refreshChangesTab(page);
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toHaveCount(0, { timeout: 30000 });
|
||||
|
||||
await openChangesOverflowMenu(page);
|
||||
await expect(page.getByTestId('changes-menu-archive-worktree')).toBeVisible();
|
||||
await page.getByTestId('changes-menu-archive-worktree').click();
|
||||
// Archiving a worktree deletes agents and redirects to home
|
||||
await expect(page).toHaveURL(/\/agent\/?(?:\?.*)?$/, { timeout: 30000 });
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
// Repo inspection is async; wait until git options are interactive again.
|
||||
await expect(page.getByText('Inspecting repository…')).toHaveCount(0, { timeout: 30000 });
|
||||
await page.getByTestId('worktree-attach-toggle').click();
|
||||
await expect(page.getByTestId('worktree-attach-picker')).toBeVisible({ timeout: 30000 });
|
||||
await page.getByTestId('worktree-attach-picker').click();
|
||||
await expect(page.getByText(worktreeBranch, { exact: true })).toHaveCount(0);
|
||||
const attachSheet = page.getByLabel('Bottom Sheet', { exact: true });
|
||||
if (await attachSheet.isVisible().catch(() => false)) {
|
||||
await page.getByTestId('dropdown-sheet-close').click({ force: true });
|
||||
await expect(attachSheet).toBeHidden({ timeout: 30000 });
|
||||
}
|
||||
await page.getByTestId('worktree-attach-toggle').click();
|
||||
await expect(page.getByTestId('worktree-attach-picker')).toBeHidden({ timeout: 30000 });
|
||||
|
||||
await setWorkingDirectory(page, nonGitDir);
|
||||
// Wait for git options to disappear (repo inspection is async and the git section can briefly render stale UI).
|
||||
await expect(page.getByTestId('worktree-attach-toggle')).toHaveCount(0, { timeout: 30000 });
|
||||
await expect(page.getByTestId('worktree-attach-picker')).toHaveCount(0);
|
||||
await createAgentAndWait(page, 'Respond with exactly: NON-GIT');
|
||||
await waitForAssistantText(page, 'NON-GIT');
|
||||
await openChangesPanel(page, { expectGit: false });
|
||||
await expect(getChangesScope(page).getByTestId('changes-not-git')).toBeVisible();
|
||||
await expect(getChangesScope(page).getByTestId('changes-primary-cta')).toHaveCount(0);
|
||||
await expect(getChangesScope(page).getByTestId('changes-overflow-menu')).toHaveCount(0);
|
||||
} finally {
|
||||
await rm(nonGitDir, { recursive: true, force: true });
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('create agent in a temp repo', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const prompt = "Respond with exactly: Hello";
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, prompt);
|
||||
|
||||
// Verify user message is shown in the stream
|
||||
await expect(page.getByText(prompt, { exact: true })).toBeVisible();
|
||||
|
||||
// Verify we used a fast model (do not fall back to a default like Sonnet).
|
||||
await page.getByTestId('agent-overflow-menu').click();
|
||||
await expect(page.getByText('Model', { exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId('agent-overflow-content').getByText(/haiku/i)).toBeVisible();
|
||||
|
||||
// Wait for agent response containing "Hello" within an assistant message
|
||||
const assistantMessage = page.getByTestId('assistant-message').filter({ hasText: 'Hello' });
|
||||
await expect(assistantMessage).toBeVisible({ timeout: 30000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { gotoHome, openSettings } from './helpers/app';
|
||||
|
||||
test('daemon is connected in settings', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
await gotoHome(page);
|
||||
await openSettings(page);
|
||||
|
||||
await expect(page.getByText(`127.0.0.1:${daemonPort}`)).toBeVisible();
|
||||
await expect(page.getByTestId(`daemon-card-${serverId}`).getByText('Online', { exact: true })).toBeVisible();
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test('deleting an agent persists after reload', async ({ page }) => {
|
||||
const repo = await createTempGitRepo();
|
||||
const nonce = Math.random().toString(36).slice(2, 10);
|
||||
const prompt = `respond-ready-${nonce}`;
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
// Create agent (via message input) so it shows up in the sidebar list.
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(prompt);
|
||||
await input.press('Enter');
|
||||
await page.waitForURL(/\/agent\//, { waitUntil: 'commit' });
|
||||
// Wait for the initial turn to complete so the agent can be archived (web uses a hover action).
|
||||
const stopOrCancel = page.getByRole('button', { name: /Stop agent|Canceling agent/ });
|
||||
await stopOrCancel.first().waitFor({ state: 'visible', timeout: 30000 }).catch(() => undefined);
|
||||
await expect(stopOrCancel).toHaveCount(0, { timeout: 120000 });
|
||||
|
||||
const match = page.url().match(/\/agent\/([^/]+)\/([^/?#]+)/);
|
||||
if (!match) {
|
||||
throw new Error(`Expected /agent/:serverId/:agentId URL, got ${page.url()}`);
|
||||
}
|
||||
const serverId = decodeURIComponent(match[1]);
|
||||
const agentId = decodeURIComponent(match[2]);
|
||||
|
||||
// Return home and delete via long-press in the agent list.
|
||||
await gotoHome(page);
|
||||
const rowTestId = `agent-row-${serverId}-${agentId}`;
|
||||
const agentRow = page.getByTestId(rowTestId).first();
|
||||
await expect(agentRow).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Web UX: hover shows a quick-archive icon. (Long-press is touch-oriented and unreliable on desktop web.)
|
||||
await agentRow.hover();
|
||||
const quickArchive = page.getByTestId(`agent-archive-${serverId}-${agentId}`).first();
|
||||
await expect(quickArchive).toBeVisible({ timeout: 10000 });
|
||||
await quickArchive.click({ force: true });
|
||||
|
||||
// Ensure deletion finished before reload (avoids races).
|
||||
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
|
||||
|
||||
// A full reload should not bring the agent back.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible();
|
||||
await expect(page.getByTestId(rowTestId)).toHaveCount(0, { timeout: 30000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,227 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
async function addFakeMicrophone(page: Page) {
|
||||
const fixturePath = path.resolve(__dirname, 'fixtures', 'recording.webm');
|
||||
const base64Audio = (await readFile(fixturePath)).toString('base64');
|
||||
const mimeType = 'audio/webm;codecs=opus';
|
||||
|
||||
return page.addInitScript(({ base64Audio, mimeType }) => {
|
||||
const mic = {
|
||||
active: 0,
|
||||
getUserMediaCalls: 0,
|
||||
stopCalls: 0,
|
||||
lastRecorder: null as null | { state: string },
|
||||
};
|
||||
(window as any).__mic = mic;
|
||||
|
||||
(window as any).isSecureContext = true;
|
||||
|
||||
const nav = navigator as any;
|
||||
if (!nav.mediaDevices) {
|
||||
nav.mediaDevices = {};
|
||||
}
|
||||
nav.mediaDevices.getUserMedia = async () => {
|
||||
mic.getUserMediaCalls += 1;
|
||||
mic.active += 1;
|
||||
const track = {
|
||||
stop: () => {
|
||||
mic.stopCalls += 1;
|
||||
mic.active = Math.max(0, mic.active - 1);
|
||||
},
|
||||
};
|
||||
return {
|
||||
getTracks: () => [track],
|
||||
};
|
||||
};
|
||||
|
||||
const blobFromBase64 = (base64: string, mimeType: string): Blob => {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return new Blob([bytes], { type: mimeType });
|
||||
};
|
||||
|
||||
class FakeMediaRecorder extends EventTarget {
|
||||
public static isTypeSupported() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public state: 'inactive' | 'recording' = 'inactive';
|
||||
public mimeType: string;
|
||||
public ondataavailable: ((event: { data: Blob }) => void) | null = null;
|
||||
public onerror: ((event: unknown) => void) | null = null;
|
||||
|
||||
constructor(_stream: unknown, options?: MediaRecorderOptions) {
|
||||
super();
|
||||
this.mimeType = options?.mimeType ?? 'audio/webm';
|
||||
mic.lastRecorder = this;
|
||||
}
|
||||
|
||||
public start() {
|
||||
this.state = 'recording';
|
||||
}
|
||||
|
||||
public stop() {
|
||||
if (this.state !== 'recording') {
|
||||
throw new Error('Not recording');
|
||||
}
|
||||
this.state = 'inactive';
|
||||
try {
|
||||
this.ondataavailable?.({
|
||||
data: blobFromBase64(base64Audio, mimeType),
|
||||
});
|
||||
} catch (err) {
|
||||
this.onerror?.(err);
|
||||
}
|
||||
this.dispatchEvent(new Event('stop'));
|
||||
}
|
||||
}
|
||||
|
||||
(window as any).MediaRecorder = FakeMediaRecorder;
|
||||
}, { base64Audio, mimeType });
|
||||
}
|
||||
|
||||
test('dictation hotkeys do not trigger on background screens', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const calls = await page.evaluate(() => (window as any).__mic.getUserMediaCalls as number);
|
||||
const active = await page.evaluate(() => (window as any).__mic.active as number);
|
||||
|
||||
expect(calls).toBe(1);
|
||||
expect(active).toBe(1);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(0);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dictation transcribes fixture via real STT', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(1);
|
||||
|
||||
const initialCopyMessageCount = await page
|
||||
.getByRole('button', { name: 'Copy message' })
|
||||
.count();
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => page.getByRole('button', { name: 'Copy message' }).count(),
|
||||
{ timeout: 60_000 }
|
||||
)
|
||||
.toBeGreaterThan(initialCopyMessageCount);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('cancel stops mic even if recorder is already inactive', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
|
||||
await expect(page).toHaveURL(/\/agent\//);
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(1);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const mic = (window as any).__mic as { lastRecorder: null | { state: string } };
|
||||
if (mic.lastRecorder) {
|
||||
mic.lastRecorder.state = 'inactive';
|
||||
}
|
||||
});
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(0);
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('dictation confirm+send does not dispatch after navigating away', async ({ page }) => {
|
||||
await addFakeMicrophone(page);
|
||||
|
||||
const repo = await createTempGitRepo();
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgent(page, 'Respond with exactly: Hello');
|
||||
|
||||
await expect(page).toHaveURL(/\/agent($|\/)/);
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeEditable();
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => (window as any).__mic.active as number))
|
||||
.toBe(1);
|
||||
|
||||
await page.keyboard.press('Control+d');
|
||||
|
||||
const newAgentButton = page.getByTestId('sidebar-new-agent');
|
||||
await expect(newAgentButton).toBeVisible();
|
||||
await newAgentButton.click();
|
||||
await expect(page).toHaveURL(/\/agent\/?$/);
|
||||
|
||||
await page.waitForTimeout(10_000);
|
||||
|
||||
const agentEntry = page.getByText(repo.path).first();
|
||||
await expect(agentEntry).toBeVisible();
|
||||
await agentEntry.click();
|
||||
await expect(page).toHaveURL(/\/agent($|\/)/);
|
||||
|
||||
await expect(page.getByText(/voice note/i)).not.toBeVisible();
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
test("draft enables explorer after selecting a working directory", async ({ page }) => {
|
||||
const repo = await createTempGitRepo("paseo-e2e-draft-explorer-");
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
const newAgentButton = page.getByTestId("sidebar-new-agent").first();
|
||||
await expect(newAgentButton).toBeVisible({ timeout: 30000 });
|
||||
await newAgentButton.click();
|
||||
await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 });
|
||||
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
|
||||
const toggle = page
|
||||
.getByRole("button", {
|
||||
name: /open explorer|close explorer|toggle explorer/i,
|
||||
})
|
||||
.first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await toggle.click();
|
||||
await expect(
|
||||
page.locator('[data-testid="explorer-header"]:visible').first()
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const terminalsTab = page
|
||||
.locator('[data-testid="explorer-tab-terminals"]:visible')
|
||||
.first();
|
||||
await expect(terminalsTab).toBeVisible({ timeout: 30000 });
|
||||
await terminalsTab.click();
|
||||
await expect(
|
||||
page.locator('[data-testid="terminal-surface"]:visible').first()
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
61
packages/app/e2e/file-explorer-collapse.spec.ts
Normal file
61
packages/app/e2e/file-explorer-collapse.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
collapseFolder,
|
||||
expandFolder,
|
||||
expectExplorerEntryHidden,
|
||||
expectExplorerEntryVisible,
|
||||
expectFileTabOpen,
|
||||
openFileExplorer,
|
||||
openFileFromExplorer,
|
||||
} from "./helpers/file-explorer";
|
||||
import { gotoWorkspace } from "./helpers/launcher";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
type WorkspaceSetupDaemonClient,
|
||||
} from "./helpers/workspace-setup";
|
||||
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
let workspaceId: string;
|
||||
let seedClient: WorkspaceSetupDaemonClient;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("file-explorer-collapse-", {
|
||||
files: [
|
||||
{ path: "assets/logo.png", content: "image bytes for explorer e2e\n" },
|
||||
{ path: "docs/guide.md", content: "# Guide\n" },
|
||||
],
|
||||
});
|
||||
seedClient = await connectWorkspaceSetupClient();
|
||||
const result = await seedClient.openProject(tempRepo.path);
|
||||
if (!result.workspace) {
|
||||
throw new Error(result.error ?? "Failed to seed workspace");
|
||||
}
|
||||
workspaceId = String(result.workspace.id);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await seedClient?.close();
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
test.describe("File explorer collapse", () => {
|
||||
test("collapses an opened image file parent folder and still expands other folders", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
await expandFolder(page, "assets");
|
||||
await expectExplorerEntryVisible(page, "logo.png");
|
||||
|
||||
await openFileFromExplorer(page, "logo.png");
|
||||
await expectFileTabOpen(page, "assets/logo.png");
|
||||
|
||||
await collapseFolder(page, "assets");
|
||||
await expectExplorerEntryHidden(page, "logo.png");
|
||||
|
||||
await expandFolder(page, "docs");
|
||||
await expectExplorerEntryVisible(page, "guide.md");
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,14 @@
|
||||
import { test as base, expect, type Page } from '@playwright/test';
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||
|
||||
// Extend base test to provide dynamic baseURL from global-setup
|
||||
const test = base.extend({
|
||||
baseURL: async ({}, use) => {
|
||||
baseURL: async ({}, provide) => {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
throw new Error('E2E_METRO_PORT not set - globalSetup must run first');
|
||||
throw new Error("E2E_METRO_PORT not set - globalSetup must run first");
|
||||
}
|
||||
await use(`http://localhost:${metroPort}`);
|
||||
await provide(`http://localhost:${metroPort}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,19 +19,19 @@ test.beforeEach(async ({ page }) => {
|
||||
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.'
|
||||
"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') {
|
||||
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.'
|
||||
"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.'
|
||||
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,17 +39,17 @@ test.beforeEach(async ({ page }) => {
|
||||
// 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.' });
|
||||
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||
});
|
||||
|
||||
const entries: string[] = [];
|
||||
consoleEntries.set(page, entries);
|
||||
|
||||
page.on('console', (message) => {
|
||||
page.on("console", (message) => {
|
||||
entries.push(`[console:${message.type()}] ${message.text()}`);
|
||||
});
|
||||
|
||||
page.on('pageerror', (error) => {
|
||||
page.on("pageerror", (error) => {
|
||||
entries.push(`[pageerror] ${error.message}`);
|
||||
});
|
||||
|
||||
@@ -56,57 +57,38 @@ test.beforeEach(async ({ page }) => {
|
||||
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.');
|
||||
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
|
||||
}
|
||||
const testDaemon = {
|
||||
const testDaemon = buildSeededHost({
|
||||
serverId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{
|
||||
id: `direct:127.0.0.1:${daemonPort}`,
|
||||
type: 'direct',
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
const createAgentPreferences = {
|
||||
// Ensure create flow never uses a remembered host from the developer's real app.
|
||||
serverId: testDaemon.serverId,
|
||||
// Keep e2e fast/cheap by default.
|
||||
provider: 'claude',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini' },
|
||||
},
|
||||
};
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
nowIso,
|
||||
});
|
||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce }) => {
|
||||
({ 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 disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
||||
const disableValue = localStorage.getItem(disableOnceKey);
|
||||
if (disableValue) {
|
||||
localStorage.removeItem(disableOnceKey);
|
||||
if (disableValue === seedNonce) {
|
||||
if (disableValue === nonce) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem('@paseo:e2e', '1');
|
||||
localStorage.setItem('@paseo:e2e-seed-nonce', seedNonce);
|
||||
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));
|
||||
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 }
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -120,9 +102,9 @@ test.afterEach(async ({ page }, testInfo) => {
|
||||
return;
|
||||
}
|
||||
|
||||
await testInfo.attach('browser-console', {
|
||||
body: entries.join('\n'),
|
||||
contentType: 'text/plain',
|
||||
await testInfo.attach("browser-console", {
|
||||
body: entries.join("\n"),
|
||||
contentType: "text/plain",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import path from 'node:path';
|
||||
import { appendFile } from 'node:fs/promises';
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
import { ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app';
|
||||
import { createTempGitRepo } from './helpers/workspace';
|
||||
|
||||
test.describe.configure({ timeout: 90000 });
|
||||
|
||||
function getChangesScope(page: Page) {
|
||||
return page.locator('[data-testid="explorer-content-area"]:visible').first();
|
||||
}
|
||||
|
||||
async function openChangesPanel(page: Page) {
|
||||
const changesHeader = getChangesScope(page).getByTestId('changes-header');
|
||||
if (!(await changesHeader.isVisible())) {
|
||||
const explorerHeader = page.getByTestId('explorer-header');
|
||||
if (await explorerHeader.isVisible()) {
|
||||
await page.getByText('Changes', { exact: true }).click();
|
||||
} else {
|
||||
const overflowMenu = page.getByTestId('agent-overflow-menu').first();
|
||||
await expect(overflowMenu).toBeVisible({ timeout: 10000 });
|
||||
await overflowMenu.click();
|
||||
await page.getByText('View Changes', { exact: true }).click();
|
||||
}
|
||||
}
|
||||
await expect(changesHeader).toBeVisible();
|
||||
}
|
||||
|
||||
async function refreshUncommittedMode(page: Page) {
|
||||
const scope = getChangesScope(page);
|
||||
const toggle = scope.getByTestId('changes-diff-status').first();
|
||||
await expect(toggle).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const currentLabel = (await toggle.innerText()).trim();
|
||||
await toggle.click();
|
||||
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(currentLabel);
|
||||
|
||||
const nextLabel = (await toggle.innerText()).trim();
|
||||
await toggle.click();
|
||||
await expect.poll(async () => (await toggle.innerText()).trim()).not.toBe(nextLabel);
|
||||
}
|
||||
|
||||
async function createAgentAndWait(page: Page, message: string) {
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
await input.fill(message);
|
||||
await input.press('Enter');
|
||||
await expect(page).toHaveURL(/\/agent\//, { timeout: 120000 });
|
||||
await expect(page.getByText(message, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test('keeps file header sticky while scrolling within a long diff', async ({ page }) => {
|
||||
const repo = await createTempGitRepo('paseo-e2e-sticky-');
|
||||
|
||||
try {
|
||||
await gotoHome(page);
|
||||
await setWorkingDirectory(page, repo.path);
|
||||
await ensureHostSelected(page);
|
||||
await createAgentAndWait(page, 'Respond with exactly: READY');
|
||||
|
||||
await openChangesPanel(page);
|
||||
|
||||
const readmePath = path.join(repo.path, 'README.md');
|
||||
const lines = Array.from({ length: 400 }, (_, idx) => `Sticky header line ${idx}\n`).join('');
|
||||
await appendFile(readmePath, `\n${lines}`);
|
||||
|
||||
await refreshUncommittedMode(page);
|
||||
|
||||
const scope = getChangesScope(page);
|
||||
await expect(scope.getByText('README.md', { exact: true })).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const fileToggle = scope.getByTestId('diff-file-0-toggle').first();
|
||||
await fileToggle.click();
|
||||
|
||||
const markerLine = scope.getByText('Sticky header line 250').first();
|
||||
await expect(markerLine).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const scroll = scope.getByTestId('git-diff-scroll').first();
|
||||
await expect(scroll).toBeVisible();
|
||||
|
||||
await expect.poll(async () => {
|
||||
return await scroll.evaluate((el) => (el.scrollHeight ?? 0) > (el.clientHeight ?? 0));
|
||||
}).toBe(true);
|
||||
|
||||
await scroll.hover();
|
||||
for (let i = 0; i < 12; i++) {
|
||||
await page.mouse.wheel(0, 700);
|
||||
}
|
||||
|
||||
await expect.poll(async () => {
|
||||
return await scroll.evaluate((el) => el.scrollTop ?? 0);
|
||||
}).toBeGreaterThan(0);
|
||||
|
||||
await expect(scope.getByText('Sticky header line 390').first()).toBeVisible({ timeout: 30000 });
|
||||
await expect(fileToggle).toBeVisible();
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,20 +1,29 @@
|
||||
import { spawn, type ChildProcess, execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import dotenv from 'dotenv';
|
||||
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import net from "node:net";
|
||||
import { Buffer } from "node:buffer";
|
||||
import dotenv from "dotenv";
|
||||
import { forkPaseoHomeMetadata, resolvePaseoHomePath } from "./helpers/paseo-home-fork";
|
||||
|
||||
interface WaitForServerOptions {
|
||||
host?: string;
|
||||
timeoutMs?: number;
|
||||
label: string;
|
||||
childProcess?: ChildProcess | null;
|
||||
getRecentOutput?: () => string;
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.once("error", reject);
|
||||
server.listen(0, () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close(() => reject(new Error('Failed to acquire port')));
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to acquire port")));
|
||||
return;
|
||||
}
|
||||
server.close(() => resolve(address.port));
|
||||
@@ -22,224 +31,689 @@ async function getAvailablePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer(port: number, timeout = 15000): Promise<void> {
|
||||
function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump: () => string } {
|
||||
const lines: string[] = [];
|
||||
return {
|
||||
add(line: string) {
|
||||
lines.push(line);
|
||||
if (lines.length > maxLines) {
|
||||
lines.shift();
|
||||
}
|
||||
},
|
||||
dump() {
|
||||
return lines.join("\n");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatRecentOutput(getRecentOutput?: () => string): string {
|
||||
if (!getRecentOutput) {
|
||||
return "";
|
||||
}
|
||||
const output = getRecentOutput().trim();
|
||||
if (!output) {
|
||||
return "";
|
||||
}
|
||||
return `\nRecent output:\n${output}`;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function waitForServer(port: number, options: WaitForServerOptions): Promise<void> {
|
||||
const { host = "127.0.0.1", timeoutMs = 15000, label, childProcess, getRecentOutput } = options;
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
let lastConnectionError: unknown = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (childProcess && childProcess.exitCode !== null) {
|
||||
const signal = childProcess.signalCode ? `, signal ${childProcess.signalCode}` : "";
|
||||
throw new Error(
|
||||
`${label} exited before listening on ${host}:${port} (exit code ${childProcess.exitCode}${signal}).${formatRecentOutput(getRecentOutput)}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(port, 'localhost', () => {
|
||||
const socket = net.connect(port, host, () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.on('error', reject);
|
||||
socket.setTimeout(1000, () => {
|
||||
socket.destroy();
|
||||
reject(new Error(`Connection timed out to ${host}:${port}`));
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
lastConnectionError = error;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
throw new Error(`Server did not start on port ${port} within ${timeout}ms`);
|
||||
|
||||
const reason =
|
||||
lastConnectionError instanceof Error
|
||||
? ` Last connection error: ${lastConnectionError.message}`
|
||||
: "";
|
||||
throw new Error(
|
||||
`${label} did not start on ${host}:${port} within ${timeoutMs}ms.${reason}${formatRecentOutput(getRecentOutput)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function parseRelayStartupFailure(line: string): string | null {
|
||||
const clean = stripAnsi(line);
|
||||
if (/Address already in use/i.test(clean)) {
|
||||
return clean;
|
||||
}
|
||||
if (/failed: ::bind\(/i.test(clean)) {
|
||||
return clean;
|
||||
}
|
||||
if (/Fatal uncaught/i.test(clean)) {
|
||||
return clean;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
if (!child) {
|
||||
return;
|
||||
}
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
child.kill("SIGTERM");
|
||||
await new Promise<void>((resolve) => {
|
||||
let pendingResolve: (() => void) | null = resolve;
|
||||
const settle = () => {
|
||||
if (!pendingResolve) return;
|
||||
const fn = pendingResolve;
|
||||
pendingResolve = null;
|
||||
clearTimeout(timeout);
|
||||
fn();
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
settle();
|
||||
}, 5000);
|
||||
child.once("exit", settle);
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeOpenAiErrorBody(body: string): string {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) {
|
||||
return "empty response body";
|
||||
}
|
||||
if (trimmed.length <= 240) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed.slice(0, 240)}…`;
|
||||
}
|
||||
|
||||
async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean> {
|
||||
const key = apiKey?.trim();
|
||||
if (!key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("https://api.openai.com/v1/models?limit=1", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
});
|
||||
if (response.ok) {
|
||||
return true;
|
||||
}
|
||||
const body = await response.text();
|
||||
console.warn(
|
||||
`[e2e] OPENAI_API_KEY probe failed (${response.status}): ${summarizeOpenAiErrorBody(body)}`,
|
||||
);
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[e2e] OPENAI_API_KEY probe request failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let daemonProcess: ChildProcess | null = null;
|
||||
let metroProcess: ChildProcess | null = null;
|
||||
let paseoHome: string | null = null;
|
||||
let fakeGhBinDir: string | null = null;
|
||||
let relayProcess: ChildProcess | null = null;
|
||||
|
||||
type OfferPayload = {
|
||||
function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (trimmed === "current") {
|
||||
return resolvePaseoHomePath("~/.paseo");
|
||||
}
|
||||
return resolvePaseoHomePath(trimmed);
|
||||
}
|
||||
|
||||
interface OfferPayload {
|
||||
v: 2;
|
||||
serverId: string;
|
||||
daemonPublicKeyB64: string;
|
||||
relay: { endpoint: string };
|
||||
};
|
||||
}
|
||||
|
||||
async function createFakeGhBin(): Promise<string> {
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-"));
|
||||
const ghPath = path.join(binDir, "gh");
|
||||
await writeFile(
|
||||
ghPath,
|
||||
`#!/usr/bin/env node
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args[0] === "auth" && args[1] === "status") {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args[0] === "pr" && args[1] === "list") {
|
||||
console.log(JSON.stringify([
|
||||
{
|
||||
number: 515,
|
||||
title: "Review selected start ref",
|
||||
url: "https://github.com/getpaseo/paseo/pull/515",
|
||||
state: "OPEN",
|
||||
body: "Fixture pull request for app e2e.",
|
||||
labels: [],
|
||||
baseRefName: "main",
|
||||
headRefName: "feature/start-from-pr"
|
||||
}
|
||||
]));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (args[0] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) {
|
||||
console.error("no pull requests found for branch");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args[0] === "issue" && args[1] === "list") {
|
||||
console.log("[]");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error("Unsupported fake gh invocation: " + args.join(" "));
|
||||
process.exit(1);
|
||||
`,
|
||||
);
|
||||
await chmod(ghPath, 0o755);
|
||||
return binDir;
|
||||
}
|
||||
|
||||
const ANSI_PATTERN = new RegExp(`${String.fromCharCode(0x1b)}\\[[0-9;]*m`, "g");
|
||||
|
||||
function stripAnsi(input: string): string {
|
||||
return input.replace(/\u001b\[[0-9;]*m/g, '');
|
||||
return input.replace(ANSI_PATTERN, "");
|
||||
}
|
||||
|
||||
function ensureRelayBuildArtifact(repoRoot: string): void {
|
||||
const relayDistEntry = path.join(repoRoot, "packages/relay/dist/e2ee.js");
|
||||
if (existsSync(relayDistEntry)) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[e2e] Building @getpaseo/relay for daemon startup");
|
||||
execSync("npm run build --workspace=@getpaseo/relay", {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
||||
const marker = '#offer=';
|
||||
const marker = "#offer=";
|
||||
const idx = url.indexOf(marker);
|
||||
if (idx === -1) {
|
||||
throw new Error(`missing ${marker} fragment: ${url}`);
|
||||
}
|
||||
const encoded = url.slice(idx + marker.length);
|
||||
const json = Buffer.from(encoded, 'base64url').toString('utf8');
|
||||
const json = Buffer.from(encoded, "base64url").toString("utf8");
|
||||
const offer = JSON.parse(json) as Partial<OfferPayload>;
|
||||
if (offer.v !== 2) throw new Error('offer.v missing/invalid');
|
||||
if (!offer.serverId) throw new Error('offer.serverId missing');
|
||||
if (!offer.daemonPublicKeyB64) throw new Error('offer.daemonPublicKeyB64 missing');
|
||||
if (!offer.relay?.endpoint) throw new Error('offer.relay.endpoint missing');
|
||||
if (offer.v !== 2) throw new Error("offer.v missing/invalid");
|
||||
if (!offer.serverId) throw new Error("offer.serverId missing");
|
||||
if (!offer.daemonPublicKeyB64) throw new Error("offer.daemonPublicKeyB64 missing");
|
||||
if (!offer.relay?.endpoint) throw new Error("offer.relay.endpoint missing");
|
||||
return offer as OfferPayload;
|
||||
}
|
||||
|
||||
export default async function globalSetup() {
|
||||
const repoRoot = path.resolve(__dirname, '../../..');
|
||||
const envTestPath = path.join(repoRoot, '.env.test');
|
||||
function loadPairingOfferFromCli(repoRoot: string, paseoHomePath: string): OfferPayload {
|
||||
const stdout = execFileSync(
|
||||
process.execPath,
|
||||
["--import", "tsx", "packages/cli/src/index.ts", "daemon", "pair", "--json"],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHomePath,
|
||||
},
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
const payload = JSON.parse(stdout) as { relayEnabled?: boolean; url?: string | null };
|
||||
if (payload.relayEnabled !== true || typeof payload.url !== "string") {
|
||||
throw new Error(`Unexpected daemon pair response: ${stdout}`);
|
||||
}
|
||||
return decodeOfferFromFragmentUrl(payload.url);
|
||||
}
|
||||
|
||||
async function waitForPairingOfferFromCli(args: {
|
||||
repoRoot: string;
|
||||
paseoHome: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<OfferPayload> {
|
||||
const timeoutMs = args.timeoutMs ?? 15000;
|
||||
const start = Date.now();
|
||||
let lastError: unknown = null;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
return loadPairingOfferFromCli(args.repoRoot, args.paseoHome);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out waiting for \`paseo daemon pair --json\` to produce a pairing offer: ${
|
||||
lastError instanceof Error ? lastError.message : String(lastError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
interface DictationConfig {
|
||||
openAiUsable: boolean;
|
||||
localModelsDir: string | null;
|
||||
}
|
||||
|
||||
async function loadEnvTestFile(repoRoot: string): Promise<void> {
|
||||
const envTestPath = path.join(repoRoot, ".env.test");
|
||||
if (existsSync(envTestPath)) {
|
||||
dotenv.config({ path: envTestPath });
|
||||
}
|
||||
}
|
||||
|
||||
const port = await getAvailablePort();
|
||||
const relayPort = await getAvailablePort();
|
||||
const metroPort = await getAvailablePort();
|
||||
paseoHome = await mkdtemp(path.join(tmpdir(), 'paseo-e2e-home-'));
|
||||
|
||||
const relayDir = path.resolve(__dirname, '..', '..', 'relay');
|
||||
relayProcess = spawn(
|
||||
'npx',
|
||||
['wrangler', 'dev', '--local', '--ip', '127.0.0.1', '--port', String(relayPort)],
|
||||
{
|
||||
cwd: relayDir,
|
||||
env: { ...process.env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: false,
|
||||
}
|
||||
async function applyPaseoHomeFork(targetHome: string): Promise<void> {
|
||||
const forkSourceHome = resolveOptionalPaseoHomeEnv(process.env.E2E_FORK_PASEO_HOME_FROM);
|
||||
if (!forkSourceHome) {
|
||||
return;
|
||||
}
|
||||
const forkResult = await forkPaseoHomeMetadata({
|
||||
sourceHome: forkSourceHome,
|
||||
targetHome,
|
||||
});
|
||||
process.env.E2E_FORK_SOURCE_PASEO_HOME = forkResult.sourceHome;
|
||||
process.env.E2E_FORK_TARGET_PASEO_HOME = forkResult.targetHome;
|
||||
process.env.E2E_FORK_COPIED_FILES = String(forkResult.copiedFiles);
|
||||
process.env.E2E_FORK_COPIED_BYTES = String(forkResult.copiedBytes);
|
||||
console.log(
|
||||
`[e2e] Forked Paseo metadata from ${forkResult.sourceHome} to ${forkResult.targetHome} ` +
|
||||
`(${forkResult.agentFiles} agent files, ${forkResult.projectFiles} project registry files, ` +
|
||||
`${forkResult.copiedBytes} bytes)`,
|
||||
);
|
||||
if (forkResult.skippedMissing.length > 0) {
|
||||
console.warn(
|
||||
`[e2e] Paseo metadata fork skipped missing paths: ${forkResult.skippedMissing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
relayProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((l) => l.trim());
|
||||
async function resolveDictationConfig(): Promise<DictationConfig> {
|
||||
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
|
||||
const defaultLocalModelsDir = path.join(
|
||||
process.env.HOME ?? "",
|
||||
".paseo",
|
||||
"models",
|
||||
"local-speech",
|
||||
);
|
||||
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.",
|
||||
);
|
||||
}
|
||||
|
||||
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
|
||||
console.log(
|
||||
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
|
||||
);
|
||||
return { openAiUsable, localModelsDir };
|
||||
}
|
||||
|
||||
interface RelayStreamState {
|
||||
failureLine: string | null;
|
||||
readyForSelectedPort: boolean;
|
||||
}
|
||||
|
||||
function attachRelayStreamHandlers(
|
||||
child: ChildProcess,
|
||||
relayPort: number,
|
||||
buffer: ReturnType<typeof createLineBuffer>,
|
||||
state: RelayStreamState,
|
||||
): void {
|
||||
function handleChunk(data: Buffer, streamTag: "stdout" | "stderr") {
|
||||
const lines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
console.log(`[relay] ${line}`);
|
||||
}
|
||||
});
|
||||
relayProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((l) => l.trim());
|
||||
for (const line of lines) {
|
||||
console.error(`[relay] ${line}`);
|
||||
buffer.add(`[${streamTag}] ${line}`);
|
||||
const failure = parseRelayStartupFailure(line);
|
||||
if (failure) {
|
||||
state.failureLine = failure;
|
||||
}
|
||||
const clean = stripAnsi(line);
|
||||
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
|
||||
if (readyMatch && Number(readyMatch[1]) === relayPort) {
|
||||
state.readyForSelectedPort = true;
|
||||
}
|
||||
if (streamTag === "stdout") {
|
||||
console.log(`[relay] ${line}`);
|
||||
} else {
|
||||
console.error(`[relay] ${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout?.on("data", (data: Buffer) => handleChunk(data, "stdout"));
|
||||
child.stderr?.on("data", (data: Buffer) => handleChunk(data, "stderr"));
|
||||
}
|
||||
|
||||
async function awaitRelayReady(
|
||||
child: ChildProcess,
|
||||
relayPort: number,
|
||||
state: RelayStreamState,
|
||||
buffer: ReturnType<typeof createLineBuffer>,
|
||||
): Promise<void> {
|
||||
await waitForServer(relayPort, {
|
||||
label: "Relay dev server",
|
||||
timeoutMs: 30000,
|
||||
childProcess: child,
|
||||
getRecentOutput: buffer.dump,
|
||||
});
|
||||
|
||||
await waitForServer(relayPort, 30000);
|
||||
const readyDeadline = Date.now() + 5000;
|
||||
function isRelayReadyCheckPending(): boolean {
|
||||
if (state.readyForSelectedPort) return false;
|
||||
if (state.failureLine !== null) return false;
|
||||
if (child.exitCode !== null) return false;
|
||||
if (child.signalCode !== null) return false;
|
||||
if (Date.now() >= readyDeadline) return false;
|
||||
return true;
|
||||
}
|
||||
while (isRelayReadyCheckPending()) await sleep(100);
|
||||
|
||||
// Start Metro bundler on dynamic port
|
||||
const appDir = path.resolve(__dirname, '..');
|
||||
metroProcess = spawn('npx', ['expo', 'start', '--web', '--port', String(metroPort)], {
|
||||
if (state.failureLine) {
|
||||
throw new Error(`Relay startup failed: ${state.failureLine}`);
|
||||
}
|
||||
if (!state.readyForSelectedPort) {
|
||||
throw new Error(
|
||||
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
|
||||
buffer.dump,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
throw new Error(
|
||||
`Relay process exited before startup completed (exit code ${child.exitCode}, signal ${child.signalCode}).${formatRecentOutput(
|
||||
buffer.dump,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function startRelay(): Promise<number> {
|
||||
const relayDir = path.resolve(__dirname, "..", "..", "relay");
|
||||
const maxRelayStartupAttempts = 5;
|
||||
let lastRelayStartupError: unknown = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
|
||||
const relayPort = await getAvailablePort();
|
||||
const buffer = createLineBuffer();
|
||||
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
|
||||
|
||||
relayProcess = spawn(
|
||||
"npx",
|
||||
["wrangler", "dev", "--local", "--ip", "127.0.0.1", "--port", String(relayPort)],
|
||||
{
|
||||
cwd: relayDir,
|
||||
env: { ...process.env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
},
|
||||
);
|
||||
attachRelayStreamHandlers(relayProcess, relayPort, buffer, state);
|
||||
|
||||
try {
|
||||
await awaitRelayReady(relayProcess, relayPort, state, buffer);
|
||||
return relayPort;
|
||||
} catch (error) {
|
||||
lastRelayStartupError = error;
|
||||
await stopProcess(relayProcess);
|
||||
relayProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
const message =
|
||||
lastRelayStartupError instanceof Error
|
||||
? lastRelayStartupError.message
|
||||
: String(lastRelayStartupError);
|
||||
throw new Error(
|
||||
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
function startMetro(metroPort: number, buffer: ReturnType<typeof createLineBuffer>): ChildProcess {
|
||||
const appDir = path.resolve(__dirname, "..");
|
||||
const child = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
|
||||
cwd: appDir,
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSER: 'none', // Don't auto-open browser
|
||||
BROWSER: "none",
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
metroProcess.stdout?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter((l) => l.trim());
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
const lines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
buffer.add(`[stdout] ${line}`);
|
||||
console.log(`[metro] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
metroProcess.stderr?.on('data', (data: Buffer) => {
|
||||
console.error(`[metro] ${data.toString().trim()}`);
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
const lines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
buffer.add(`[stderr] ${line}`);
|
||||
console.error(`[metro] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
const serverDir = path.resolve(__dirname, '../../..', 'packages/server');
|
||||
const tsxBin = execSync('which tsx').toString().trim();
|
||||
return child;
|
||||
}
|
||||
|
||||
let offerPayload: OfferPayload | null = null;
|
||||
let offerResolve: (() => void) | null = null;
|
||||
const offerPromise = new Promise<void>((resolve) => {
|
||||
offerResolve = resolve;
|
||||
});
|
||||
interface DaemonSpawnArgs {
|
||||
port: number;
|
||||
relayPort: number;
|
||||
metroPort: number;
|
||||
paseoHome: string;
|
||||
fakeGhBinDir: string;
|
||||
dictation: DictationConfig;
|
||||
buffer: ReturnType<typeof createLineBuffer>;
|
||||
}
|
||||
|
||||
daemonProcess = spawn(tsxBin, ['src/server/index.ts'], {
|
||||
function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const { openAiUsable, localModelsDir } = args.dictation;
|
||||
|
||||
const child = spawn(tsxBin, ["src/server/index.ts"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: paseoHome,
|
||||
PASEO_SERVER_ID: 'srv_e2e_test_daemon',
|
||||
PASEO_LISTEN: `0.0.0.0:${port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
|
||||
// Keep e2e bootstrap fast and deterministic; terminal/sidebar tests do not need speech.
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
NODE_ENV: 'development',
|
||||
PATH: `${args.fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`,
|
||||
PASEO_DICTATION_ENABLED: openAiUsable ? "1" : "0",
|
||||
PASEO_VOICE_MODE_ENABLED: openAiUsable ? "1" : "0",
|
||||
...(openAiUsable
|
||||
? {
|
||||
PASEO_DICTATION_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_STT_PROVIDER: "openai",
|
||||
PASEO_VOICE_TTS_PROVIDER: "openai",
|
||||
}
|
||||
: {}),
|
||||
...(localModelsDir ? { PASEO_LOCAL_MODELS_DIR: localModelsDir } : {}),
|
||||
NODE_ENV: "development",
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
let stdoutBuffer = '';
|
||||
daemonProcess.stdout?.on('data', (data: Buffer) => {
|
||||
stdoutBuffer += data.toString('utf8');
|
||||
const lines = stdoutBuffer.split('\n');
|
||||
stdoutBuffer = lines.pop() ?? '';
|
||||
let stdoutBuffer = "";
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
stdoutBuffer += data.toString("utf8");
|
||||
const lines = stdoutBuffer.split("\n");
|
||||
stdoutBuffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (!offerPayload) {
|
||||
const clean = stripAnsi(trimmed);
|
||||
try {
|
||||
const obj = JSON.parse(clean) as { msg?: string; url?: string };
|
||||
if (obj.msg === 'pairing_offer' && typeof obj.url === 'string') {
|
||||
offerPayload = decodeOfferFromFragmentUrl(obj.url);
|
||||
offerResolve?.();
|
||||
}
|
||||
} catch {
|
||||
const match = clean.match(/https?:\/\/[^\s"]+#offer=[A-Za-z0-9_-]+/);
|
||||
if (match && clean.includes('pairing_offer')) {
|
||||
try {
|
||||
offerPayload = decodeOfferFromFragmentUrl(match[0]);
|
||||
offerResolve?.();
|
||||
} catch {
|
||||
// ignore parsing failures
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
args.buffer.add(`[stdout] ${trimmed}`);
|
||||
console.log(`[daemon] ${trimmed}`);
|
||||
}
|
||||
});
|
||||
|
||||
daemonProcess.stderr?.on('data', (data: Buffer) => {
|
||||
console.error(`[daemon] ${data.toString().trim()}`);
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
const lines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
args.buffer.add(`[stderr] ${line}`);
|
||||
console.error(`[daemon] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for both daemon and Metro to be ready
|
||||
await Promise.all([
|
||||
waitForServer(port),
|
||||
waitForServer(metroPort, 120000), // Metro can take longer to start
|
||||
]);
|
||||
|
||||
// Wait for daemon to emit a pairing offer (includes relay session ID).
|
||||
await Promise.race([
|
||||
offerPromise,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timed out waiting for pairing_offer log')), 15000)
|
||||
),
|
||||
]);
|
||||
if (!offerPayload) {
|
||||
throw new Error('pairing_offer was not parsed from daemon logs');
|
||||
}
|
||||
const offer = offerPayload as OfferPayload;
|
||||
|
||||
process.env.E2E_DAEMON_PORT = String(port);
|
||||
process.env.E2E_RELAY_PORT = String(relayPort);
|
||||
process.env.E2E_SERVER_ID = offer.serverId;
|
||||
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
||||
process.env.E2E_METRO_PORT = String(metroPort);
|
||||
console.log(`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`);
|
||||
|
||||
return async () => {
|
||||
if (daemonProcess) {
|
||||
daemonProcess.kill('SIGTERM');
|
||||
daemonProcess = null;
|
||||
}
|
||||
if (metroProcess) {
|
||||
metroProcess.kill('SIGTERM');
|
||||
metroProcess = null;
|
||||
}
|
||||
if (relayProcess) {
|
||||
relayProcess.kill('SIGTERM');
|
||||
relayProcess = null;
|
||||
}
|
||||
if (paseoHome) {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
paseoHome = null;
|
||||
}
|
||||
console.log('[e2e] Test daemon stopped');
|
||||
};
|
||||
return child;
|
||||
}
|
||||
|
||||
async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
|
||||
await Promise.all([
|
||||
stopProcess(daemonProcess),
|
||||
stopProcess(metroProcess),
|
||||
stopProcess(relayProcess),
|
||||
]);
|
||||
daemonProcess = null;
|
||||
metroProcess = null;
|
||||
relayProcess = null;
|
||||
if (paseoHome && shouldRemovePaseoHome) {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
paseoHome = null;
|
||||
} else if (paseoHome) {
|
||||
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
|
||||
}
|
||||
if (fakeGhBinDir) {
|
||||
await rm(fakeGhBinDir, { recursive: true, force: true });
|
||||
fakeGhBinDir = null;
|
||||
}
|
||||
}
|
||||
|
||||
export default async function globalSetup() {
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
ensureRelayBuildArtifact(repoRoot);
|
||||
await loadEnvTestFile(repoRoot);
|
||||
|
||||
const port = await getAvailablePort();
|
||||
const metroPort = await getAvailablePort();
|
||||
const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME);
|
||||
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
|
||||
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));
|
||||
fakeGhBinDir = await createFakeGhBin();
|
||||
const metroLineBuffer = createLineBuffer();
|
||||
const daemonLineBuffer = createLineBuffer();
|
||||
|
||||
await applyPaseoHomeFork(paseoHome);
|
||||
|
||||
const cleanup = () => performCleanup(shouldRemovePaseoHome);
|
||||
|
||||
const dictation = await resolveDictationConfig();
|
||||
|
||||
try {
|
||||
const relayPort = await startRelay();
|
||||
metroProcess = startMetro(metroPort, metroLineBuffer);
|
||||
daemonProcess = startDaemon({
|
||||
port,
|
||||
relayPort,
|
||||
metroPort,
|
||||
paseoHome,
|
||||
fakeGhBinDir,
|
||||
dictation,
|
||||
buffer: daemonLineBuffer,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
waitForServer(port, {
|
||||
label: "Paseo daemon",
|
||||
childProcess: daemonProcess,
|
||||
getRecentOutput: daemonLineBuffer.dump,
|
||||
}),
|
||||
waitForServer(metroPort, {
|
||||
label: "Metro web server",
|
||||
timeoutMs: 120000,
|
||||
childProcess: metroProcess,
|
||||
getRecentOutput: metroLineBuffer.dump,
|
||||
}),
|
||||
]);
|
||||
|
||||
const offer = await waitForPairingOfferFromCli({
|
||||
repoRoot,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
process.env.E2E_DAEMON_PORT = String(port);
|
||||
process.env.E2E_RELAY_PORT = String(relayPort);
|
||||
process.env.E2E_SERVER_ID = offer.serverId;
|
||||
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
||||
process.env.E2E_METRO_PORT = String(metroPort);
|
||||
console.log(
|
||||
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
|
||||
);
|
||||
|
||||
return async () => {
|
||||
await cleanup();
|
||||
console.log("[e2e] Test daemon stopped");
|
||||
};
|
||||
} catch (error) {
|
||||
await cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
283
packages/app/e2e/helpers/agent-bottom-anchor.ts
Normal file
283
packages/app/e2e/helpers/agent-bottom-anchor.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
const NEAR_BOTTOM_THRESHOLD_PX = 72;
|
||||
|
||||
export interface ScrollMetrics {
|
||||
offsetY: number;
|
||||
contentHeight: number;
|
||||
viewportHeight: number;
|
||||
distanceFromBottom: number;
|
||||
}
|
||||
|
||||
export interface SeededAgent {
|
||||
id: string;
|
||||
title: string;
|
||||
expectedTailText: string;
|
||||
url: string;
|
||||
workspaceUrl: string;
|
||||
}
|
||||
|
||||
export interface DaemonClientInstance {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
createAgent(options: {
|
||||
provider: string;
|
||||
model: string;
|
||||
thinkingOptionId: string;
|
||||
modeId: string;
|
||||
cwd: string;
|
||||
title: string;
|
||||
initialPrompt: string;
|
||||
}): Promise<{ id: string }>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
||||
}
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
return `ws://127.0.0.1:${daemonPort}/ws`;
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function buildReplyBlock(label: string, lineCount = 14): string {
|
||||
return Array.from({ length: lineCount }, (_, index) => {
|
||||
const line = (index + 1).toString().padStart(2, "0");
|
||||
return `${label} line ${line} anchor verification text keeps wrapping stable across resize and composer growth.`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
function buildProtocolMessage(label: string, lineCount = 14): string {
|
||||
return [
|
||||
"For every message in this chat, reply with exactly the text after the final line `REPLY:`.",
|
||||
"Do not add extra words, bullets, markdown fences, or tool calls.",
|
||||
"REPLY:",
|
||||
buildReplyBlock(label, lineCount),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildReplyMessage(label: string, lineCount = 14): string {
|
||||
return ["REPLY:", buildReplyBlock(label, lineCount)].join("\n");
|
||||
}
|
||||
|
||||
export function createReplyTurn(label: string): {
|
||||
message: string;
|
||||
expectedReply: string;
|
||||
} {
|
||||
return {
|
||||
message: buildReplyMessage(label),
|
||||
expectedReply: buildReplyBlock(label),
|
||||
};
|
||||
}
|
||||
|
||||
interface DaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: DaemonClientConfig) => DaemonClientInstance
|
||||
> {
|
||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: DaemonClientConfig) => DaemonClientInstance;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `app-e2e-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
webSocketFactory,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function seedBottomAnchorAgent(input: {
|
||||
client: DaemonClientInstance;
|
||||
cwd: string;
|
||||
title?: string;
|
||||
turnCount?: number;
|
||||
lineCount?: number;
|
||||
}): Promise<SeededAgent> {
|
||||
const title = input.title ?? `bottom-anchor-${Date.now()}`;
|
||||
const turnCount = Math.max(3, input.turnCount ?? 5);
|
||||
const lineCount = Math.max(14, input.lineCount ?? 14);
|
||||
const created = await input.client.createAgent({
|
||||
provider: "codex",
|
||||
model: "gpt-5.4-mini",
|
||||
thinkingOptionId: "low",
|
||||
modeId: "full-access",
|
||||
cwd: input.cwd,
|
||||
title,
|
||||
initialPrompt: buildProtocolMessage(`${title}-turn-00`, lineCount),
|
||||
});
|
||||
const initialFinish = await input.client.waitForFinish(created.id, 120000);
|
||||
if (initialFinish.status !== "idle") {
|
||||
throw new Error(
|
||||
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`,
|
||||
);
|
||||
}
|
||||
|
||||
let expectedTailText = buildReplyBlock(`${title}-turn-00`, lineCount);
|
||||
for (let index = 1; index < turnCount; index += 1) {
|
||||
const label = `${title}-turn-${index.toString().padStart(2, "0")}`;
|
||||
expectedTailText = buildReplyBlock(label, lineCount);
|
||||
await input.client.sendAgentMessage(created.id, buildReplyMessage(label, lineCount));
|
||||
const finish = await input.client.waitForFinish(created.id, 120000);
|
||||
if (finish.status !== "idle") {
|
||||
throw new Error(
|
||||
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
title,
|
||||
expectedTailText,
|
||||
url: `${buildHostWorkspaceRoute(getServerId(), input.cwd)}?open=${encodeURIComponent(`agent:${created.id}`)}`,
|
||||
workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd),
|
||||
};
|
||||
}
|
||||
|
||||
function getVisibleChatScroll(page: Page) {
|
||||
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
}
|
||||
|
||||
export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
return getVisibleChatScroll(page).evaluate((root: Element) => {
|
||||
const candidates = [root, ...Array.from(root.querySelectorAll("*"))]
|
||||
.filter((element): element is HTMLElement => element instanceof HTMLElement)
|
||||
.filter((element) => {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
const isEditable =
|
||||
tagName === "textarea" ||
|
||||
tagName === "input" ||
|
||||
element.getAttribute("contenteditable") === "true";
|
||||
return !isEditable && element.scrollHeight - element.clientHeight > 1;
|
||||
});
|
||||
const scrollElement =
|
||||
candidates.sort(
|
||||
(left, right) =>
|
||||
right.scrollHeight - right.clientHeight - (left.scrollHeight - left.clientHeight),
|
||||
)[0] ?? (root as HTMLElement);
|
||||
|
||||
const offsetY = Math.max(0, scrollElement.scrollTop);
|
||||
const contentHeight = Math.max(0, scrollElement.scrollHeight);
|
||||
const viewportHeight = Math.max(0, scrollElement.clientHeight);
|
||||
const distanceFromBottom = Math.max(0, contentHeight - (offsetY + viewportHeight));
|
||||
|
||||
return {
|
||||
offsetY,
|
||||
contentHeight,
|
||||
viewportHeight,
|
||||
distanceFromBottom,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function scrollUpFromBottom(page: Page, pixels: number): Promise<void> {
|
||||
const scrollViewport = getVisibleChatScroll(page);
|
||||
await expect(scrollViewport).toHaveCount(1, { timeout: 30000 });
|
||||
let remaining = Math.max(0, pixels);
|
||||
while (remaining > 0) {
|
||||
const delta = Math.min(240, remaining);
|
||||
await scrollViewport.evaluate((element: Element, step: number) => {
|
||||
const scrollContainer = element as HTMLElement;
|
||||
scrollContainer.dispatchEvent(
|
||||
new WheelEvent("wheel", {
|
||||
deltaY: -step,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
|
||||
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
}, delta);
|
||||
remaining -= delta;
|
||||
|
||||
if ((await readScrollMetrics(page)).distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise<void> {
|
||||
await expect(getVisibleChatScroll(page)).toBeVisible({ timeout: 60000 });
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 60000,
|
||||
});
|
||||
await expect(page.getByTestId("agent-loading")).toHaveCount(0, { timeout: 60000 });
|
||||
if (expectedTailText) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.contentHeight;
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectNearBottom(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.distanceFromBottom;
|
||||
})
|
||||
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function expectDetachedFromBottom(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.distanceFromBottom;
|
||||
})
|
||||
.toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function waitForContentGrowth(
|
||||
page: Page,
|
||||
previousContentHeight: number,
|
||||
): Promise<ScrollMetrics> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.contentHeight;
|
||||
})
|
||||
.toBeGreaterThan(previousContentHeight);
|
||||
return readScrollMetrics(page);
|
||||
}
|
||||
|
||||
export async function getChatContainerKey(page: Page): Promise<string | null> {
|
||||
return getVisibleChatScroll(page).evaluate((element) => {
|
||||
const nativeId = (element as HTMLElement).id;
|
||||
const prefix = "agent-chat-scroll-";
|
||||
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
|
||||
});
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
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).');
|
||||
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.');
|
||||
if (port === "6767") {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.",
|
||||
);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
@@ -20,151 +23,203 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
||||
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).');
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
|
||||
const needsReset = await page.evaluate(({ expectedEndpoint, expectedServerId }) => {
|
||||
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 any;
|
||||
if (entry?.serverId !== expectedServerId) return true;
|
||||
const connections = entry?.connections;
|
||||
if (!Array.isArray(connections)) return true;
|
||||
if (connections.some((c: any) => c?.type === 'direct' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) return true;
|
||||
return !connections.some((c: any) => c?.type === 'direct' && c?.endpoint === expectedEndpoint);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}, { expectedEndpoint, expectedServerId });
|
||||
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(
|
||||
({ expectedEndpoint, nowIso, expectedServerId }) => {
|
||||
localStorage.setItem('@paseo:e2e', '1');
|
||||
localStorage.setItem(
|
||||
'@paseo:daemon-registry',
|
||||
JSON.stringify([
|
||||
{
|
||||
serverId: expectedServerId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{
|
||||
id: `direct:${expectedEndpoint}`,
|
||||
type: 'direct',
|
||||
endpoint: expectedEndpoint,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:${expectedEndpoint}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
},
|
||||
])
|
||||
);
|
||||
localStorage.setItem(
|
||||
'@paseo:create-agent-preferences',
|
||||
JSON.stringify({
|
||||
serverId: expectedServerId,
|
||||
provider: 'claude',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini' },
|
||||
},
|
||||
})
|
||||
);
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
({ 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");
|
||||
},
|
||||
{ expectedEndpoint, nowIso, expectedServerId }
|
||||
{ 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).');
|
||||
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');
|
||||
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.');
|
||||
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
|
||||
}
|
||||
|
||||
let registry: any;
|
||||
try {
|
||||
registry = JSON.parse(snapshot.registryRaw);
|
||||
} catch {
|
||||
throw new Error('E2E expected @paseo:daemon-registry to be valid JSON.');
|
||||
}
|
||||
|
||||
if (!Array.isArray(registry) || registry.length !== 1) {
|
||||
const { serverId, connections } = parseRegistryEntry(snapshot.registryRaw);
|
||||
if (serverId !== expectedServerId) {
|
||||
throw new Error(
|
||||
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : 'non-array'}).`
|
||||
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${serverId}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const daemon = registry[0];
|
||||
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)}).`);
|
||||
}
|
||||
if (daemon.serverId !== expectedServerId) {
|
||||
throw new Error(`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${daemon.serverId}).`);
|
||||
}
|
||||
|
||||
const connections: unknown = daemon?.connections;
|
||||
if (
|
||||
!Array.isArray(connections) ||
|
||||
!connections.some((c: any) => c?.type === 'direct' && c?.endpoint === expectedEndpoint)
|
||||
) {
|
||||
throw new Error(
|
||||
`E2E expected seeded daemon connections to include direct ${expectedEndpoint} (got ${JSON.stringify(connections)}).`
|
||||
);
|
||||
}
|
||||
if (Array.isArray(connections) && connections.some((c: any) => c?.type === 'direct' && typeof c?.endpoint === 'string' && /:6767\b/.test(c.endpoint))) {
|
||||
throw new Error(`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`);
|
||||
}
|
||||
assertDaemonConnections(connections, expectedEndpoint);
|
||||
|
||||
if (!snapshot.prefsRaw) {
|
||||
throw new Error('E2E expected @paseo:create-agent-preferences to be set before app load.');
|
||||
}
|
||||
try {
|
||||
const prefs = JSON.parse(snapshot.prefsRaw) as any;
|
||||
if (prefs?.serverId !== daemon.serverId) {
|
||||
throw new Error(
|
||||
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${daemon.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.');
|
||||
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
|
||||
}
|
||||
assertPreferencesMatch(snapshot.prefsRaw, serverId);
|
||||
}
|
||||
|
||||
export const gotoHome = async (page: Page) => {
|
||||
await page.goto('/');
|
||||
export const gotoAppShell = async (page: Page) => {
|
||||
await page.goto("/");
|
||||
await ensureE2EStorageSeeded(page);
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible();
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await addProjectCta.isVisible().catch(() => false)) ||
|
||||
(await addProjectSidebar.isVisible().catch(() => false)) ||
|
||||
(await newAgentButton.isVisible().catch(() => false)),
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.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();
|
||||
}
|
||||
}
|
||||
await expect(composer.first()).toBeVisible({ timeout: 30000 });
|
||||
};
|
||||
|
||||
export const openSettings = async (page: Page) => {
|
||||
// Navigate directly to settings page
|
||||
await page.goto('/settings');
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
// Navigate through the real app control so route changes stay aligned with UI behavior.
|
||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(settingsButton).toBeVisible();
|
||||
await settingsButton.click();
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
};
|
||||
|
||||
export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
@@ -173,23 +228,19 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
.first();
|
||||
await expect(workingDirectorySelect).toBeVisible({ timeout: 30000 });
|
||||
|
||||
const legacyInput = page.getByRole('textbox', { name: '/path/to/project' }).first();
|
||||
const directorySearchInput = page.getByRole('textbox', { name: /search directories/i }).first();
|
||||
const worktreePicker = page.getByTestId('worktree-attach-picker');
|
||||
const worktreeSheetTitle = page.getByText('Select worktree', { exact: true });
|
||||
const legacyInput = page.getByRole("textbox", { name: "/path/to/project" }).first();
|
||||
const directorySearchInput = page.getByRole("textbox", { name: /search directories/i }).first();
|
||||
const worktreePicker = page.getByTestId("worktree-attach-picker");
|
||||
const worktreeSheetTitle = page.getByText("Select worktree", { exact: true }).first();
|
||||
const closeBottomSheet = async () => {
|
||||
const bottomSheetBackdrop = page
|
||||
.getByRole('button', { name: 'Bottom sheet backdrop' })
|
||||
.first();
|
||||
const bottomSheetHandle = page
|
||||
.getByRole('slider', { name: 'Bottom sheet handle' })
|
||||
.first();
|
||||
const bottomSheetBackdrop = page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
||||
const bottomSheetHandle = page.getByRole("slider", { name: "Bottom sheet handle" }).first();
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
if (!(await bottomSheetBackdrop.isVisible())) {
|
||||
return;
|
||||
}
|
||||
await bottomSheetBackdrop.click({ force: true });
|
||||
await page.keyboard.press('Escape').catch(() => undefined);
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
if (await bottomSheetBackdrop.isVisible()) {
|
||||
@@ -209,7 +260,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
if (!(await worktreeSheetTitle.isVisible()) && !(await worktreePicker.isVisible())) {
|
||||
return;
|
||||
}
|
||||
const attachToggle = page.getByTestId('worktree-attach-toggle');
|
||||
const attachToggle = page.getByTestId("worktree-attach-toggle");
|
||||
if (await attachToggle.isVisible()) {
|
||||
await attachToggle.click({ force: true });
|
||||
await page.waitForTimeout(200);
|
||||
@@ -230,26 +281,23 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
await closeBottomSheet();
|
||||
await workingDirectorySelect.click({ force: true });
|
||||
}
|
||||
await expect
|
||||
.poll(async () => pickerInputVisible(), { timeout: 10000 })
|
||||
.toBe(true);
|
||||
await expect.poll(async () => pickerInputVisible(), { timeout: 10000 }).toBe(true);
|
||||
}
|
||||
|
||||
const trimmedDirectory = directory.replace(/\/+$/, '');
|
||||
const activeInput =
|
||||
(await directorySearchInput.isVisible().catch(() => false))
|
||||
? directorySearchInput
|
||||
: legacyInput;
|
||||
const trimmedDirectory = directory.replace(/\/+$/, "");
|
||||
const activeInput = (await directorySearchInput.isVisible().catch(() => false))
|
||||
? directorySearchInput
|
||||
: legacyInput;
|
||||
|
||||
await activeInput.fill(trimmedDirectory);
|
||||
|
||||
if (activeInput === directorySearchInput) {
|
||||
// Combobox custom rows can be either plain path labels or prefixed labels.
|
||||
const plainOption = page
|
||||
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, 'i'))
|
||||
.getByText(new RegExp(`^${escapeRegex(trimmedDirectory)}$`, "i"))
|
||||
.first();
|
||||
const prefixedUseOption = page
|
||||
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, 'i'))
|
||||
.getByText(new RegExp(`^Use "${escapeRegex(trimmedDirectory)}"$`, "i"))
|
||||
.first();
|
||||
|
||||
if (await plainOption.isVisible().catch(() => false)) {
|
||||
@@ -258,33 +306,38 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
await prefixedUseOption.click({ force: true });
|
||||
} else {
|
||||
// Fallback: accept highlighted option (directory suggestion).
|
||||
await activeInput.press('Enter');
|
||||
await activeInput.press("Enter");
|
||||
}
|
||||
} else {
|
||||
// Legacy path picker fallback.
|
||||
await activeInput.press('Enter');
|
||||
await activeInput.press("Enter");
|
||||
}
|
||||
|
||||
// Wait for picker to close.
|
||||
await expect(activeInput).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
const directoryCandidates = new Set<string>([trimmedDirectory]);
|
||||
if (trimmedDirectory.startsWith('/var/')) {
|
||||
if (trimmedDirectory.startsWith("/var/")) {
|
||||
directoryCandidates.add(`/private${trimmedDirectory}`);
|
||||
}
|
||||
if (trimmedDirectory.startsWith('/private/var/')) {
|
||||
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ''));
|
||||
if (trimmedDirectory.startsWith("/private/var/")) {
|
||||
directoryCandidates.add(trimmedDirectory.replace(/^\/private/, ""));
|
||||
}
|
||||
const basename = trimmedDirectory.split('/').filter(Boolean).pop() ?? trimmedDirectory;
|
||||
const basename = trimmedDirectory.split("/").findLast(Boolean) ?? trimmedDirectory;
|
||||
|
||||
await expect.poll(async () => {
|
||||
const text = await workingDirectorySelect.innerText().catch(() => '');
|
||||
if (text.includes(basename)) return true;
|
||||
for (const candidate of directoryCandidates) {
|
||||
if (text.includes(candidate)) return true;
|
||||
}
|
||||
return false;
|
||||
}, { timeout: 30000 }).toBe(true);
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const text = await workingDirectorySelect.innerText().catch(() => "");
|
||||
if (text.includes(basename)) return true;
|
||||
for (const candidate of directoryCandidates) {
|
||||
if (text.includes(candidate)) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
.toBe(true);
|
||||
};
|
||||
|
||||
export const ensureHostSelected = async (page: Page) => {
|
||||
@@ -302,19 +355,21 @@ export const ensureHostSelected = async (page: Page) => {
|
||||
}
|
||||
|
||||
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 any[];
|
||||
const prefs = JSON.parse(prefsRaw) as any;
|
||||
if (!Array.isArray(registry) || registry.length !== 1) return { ok: false, reason: 'registry shape' } as const;
|
||||
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;
|
||||
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));
|
||||
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);
|
||||
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1";
|
||||
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
@@ -326,20 +381,22 @@ export const ensureHostSelected = async (page: Page) => {
|
||||
await assertE2EUsesSeededTestDaemon(page);
|
||||
}
|
||||
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(input).toBeVisible();
|
||||
|
||||
if (await input.isEditable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectHostLabel = page.getByText('Select host', { exact: true });
|
||||
const selectHostLabel = page.getByText("Select host", { exact: true });
|
||||
if (await selectHostLabel.isVisible()) {
|
||||
await selectHostLabel.click();
|
||||
|
||||
// E2E safety: 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 }).first();
|
||||
const localhostOption = page.getByText("localhost", { exact: true }).first();
|
||||
const daemonIdOption = page
|
||||
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
|
||||
.first();
|
||||
|
||||
if (await localhostOption.isVisible()) {
|
||||
await localhostOption.click();
|
||||
@@ -353,18 +410,90 @@ export const ensureHostSelected = async (page: Page) => {
|
||||
};
|
||||
|
||||
export const createAgent = async (page: Page, message: string) => {
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(input).toBeEditable();
|
||||
await preferFastThinkingOption(page);
|
||||
await input.fill(message);
|
||||
await input.press('Enter');
|
||||
await input.press("Enter");
|
||||
|
||||
// Expo Router navigations can be "same-document" updates, so avoid waiting for a full `load`.
|
||||
await page.waitForURL(/\/agent\//, { waitUntil: 'commit' });
|
||||
// The composer may remain on the draft screen briefly while the initial run starts,
|
||||
// so assert the user-visible result instead of forcing one route shape here.
|
||||
await expect(page).toHaveURL(/\/(workspace|agent|new-agent)(\/|$|\?)/, { timeout: 30000 });
|
||||
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30000,
|
||||
});
|
||||
};
|
||||
|
||||
async function preferFastThinkingOption(page: Page): Promise<void> {
|
||||
const providerTrigger = page
|
||||
.locator(
|
||||
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
|
||||
)
|
||||
.first();
|
||||
if (await providerTrigger.isVisible().catch(() => false)) {
|
||||
const providerText = ((await providerTrigger.innerText().catch(() => "")) ?? "").trim();
|
||||
if (!/codex/i.test(providerText)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const thinkingTrigger = page.getByTestId("agent-thinking-selector").first();
|
||||
if (!(await thinkingTrigger.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentThinkingLabel = ((await thinkingTrigger.innerText().catch(() => "")) ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (/\b(low|minimal|off)\b/.test(currentThinkingLabel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await thinkingTrigger.click();
|
||||
const menu = page.getByTestId("agent-thinking-menu").first();
|
||||
if (!(await menu.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preferredLabels = ["low", "minimal", "off", "medium"];
|
||||
let selected = false;
|
||||
for (const label of preferredLabels) {
|
||||
const option = menu
|
||||
.getByRole("button", { name: new RegExp(`^${escapeRegex(label)}$`, "i") })
|
||||
.first();
|
||||
if (await option.isVisible().catch(() => false)) {
|
||||
await option.click({ force: true });
|
||||
selected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
const options = menu.getByRole("button");
|
||||
const count = await options.count();
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const option = options.nth(index);
|
||||
const label = ((await option.innerText().catch(() => "")) ?? "").trim();
|
||||
if (!label) {
|
||||
continue;
|
||||
}
|
||||
if (label.toLowerCase() === currentThinkingLabel) {
|
||||
continue;
|
||||
}
|
||||
await option.click({ force: true });
|
||||
selected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(menu).not.toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
export interface AgentConfig {
|
||||
directory: string;
|
||||
provider?: string;
|
||||
@@ -374,54 +503,124 @@ export interface AgentConfig {
|
||||
}
|
||||
|
||||
export const selectProvider = async (page: Page, provider: string) => {
|
||||
const providerLabel = page.getByText('PROVIDER', { exact: true }).first();
|
||||
await expect(providerLabel).toBeVisible();
|
||||
await providerLabel.click();
|
||||
const normalizedProvider = provider.trim();
|
||||
if (!normalizedProvider) {
|
||||
throw new Error("Provider must be a non-empty string.");
|
||||
}
|
||||
|
||||
const option = page.getByText(provider, { exact: true }).first();
|
||||
const providerTrigger = page
|
||||
.locator(
|
||||
'[data-testid="agent-provider-selector"]:visible, [data-testid="draft-provider-select"]:visible',
|
||||
)
|
||||
.first();
|
||||
if (
|
||||
await providerTrigger
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i"))
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await providerTrigger.isVisible().catch(() => false)) {
|
||||
await providerTrigger.click();
|
||||
} else {
|
||||
const providerLabel = page.getByText("PROVIDER", { exact: true }).first();
|
||||
await expect(providerLabel).toBeVisible();
|
||||
await providerLabel.click();
|
||||
}
|
||||
|
||||
const dialog = page.getByRole("dialog").last();
|
||||
const searchInput = dialog.getByRole("textbox", { name: /search provider/i }).first();
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
await searchInput.fill(normalizedProvider);
|
||||
}
|
||||
|
||||
const option = dialog.getByText(new RegExp(`^${escapeRegex(normalizedProvider)}$`, "i")).first();
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
};
|
||||
|
||||
export const selectModel = async (page: Page, model: string) => {
|
||||
const modelLabel = page.getByText('MODEL', { exact: true }).first();
|
||||
await expect(modelLabel).toBeVisible();
|
||||
await modelLabel.click();
|
||||
const normalizedModel = model.trim();
|
||||
if (!normalizedModel) {
|
||||
throw new Error("Model must be a non-empty string.");
|
||||
}
|
||||
|
||||
const modelTrigger = page
|
||||
.locator(
|
||||
'[data-testid="agent-model-selector"]:visible, [data-testid="draft-model-select"]:visible',
|
||||
)
|
||||
.first();
|
||||
if (
|
||||
await modelTrigger
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await modelTrigger.isVisible().catch(() => false)) {
|
||||
await modelTrigger.click();
|
||||
} else {
|
||||
const modelLabel = page.getByText("MODEL", { exact: true }).first();
|
||||
await expect(modelLabel).toBeVisible();
|
||||
await modelLabel.click();
|
||||
}
|
||||
|
||||
// Wait for the model dropdown to open
|
||||
const searchInput = page.getByRole('textbox', { name: /search model/i });
|
||||
const searchInput = page.getByRole("textbox", { name: /search model/i });
|
||||
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Type to search/filter models
|
||||
await searchInput.fill(model);
|
||||
await searchInput.fill(normalizedModel);
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
const option = dialog
|
||||
.getByText(new RegExp(`^${escapeRegex(model)}$`, 'i'))
|
||||
const dialog = page.getByRole("dialog");
|
||||
const exactOption = dialog
|
||||
.getByText(new RegExp(`^${escapeRegex(normalizedModel)}$`, "i"))
|
||||
.first();
|
||||
await expect(option).toBeVisible({ timeout: 30000 });
|
||||
await option.click({ force: true });
|
||||
const exactVisible = await exactOption.isVisible().catch(() => false);
|
||||
if (exactVisible) {
|
||||
await exactOption.click({ force: true });
|
||||
} else {
|
||||
// Modern labels include version suffixes (for example "Haiku 4.5"), so
|
||||
// select the first filtered result using keyboard confirm.
|
||||
await searchInput.press("Enter");
|
||||
}
|
||||
|
||||
// Wait for dropdown to close
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
}
|
||||
await expect(searchInput).not.toBeVisible({ timeout: 5000 });
|
||||
};
|
||||
|
||||
export const selectMode = async (page: Page, mode: string) => {
|
||||
const modeLabel = page.getByText('MODE', { exact: true }).first();
|
||||
await expect(modeLabel).toBeVisible();
|
||||
await modeLabel.click();
|
||||
const modeTrigger = page
|
||||
.locator(
|
||||
'[data-testid="agent-mode-selector"]:visible, [data-testid="draft-mode-select"]:visible',
|
||||
)
|
||||
.first();
|
||||
if (await modeTrigger.isVisible().catch(() => false)) {
|
||||
await modeTrigger.click();
|
||||
} else {
|
||||
const modeLabel = page.getByText("MODE", { exact: true }).first();
|
||||
await expect(modeLabel).toBeVisible();
|
||||
await modeLabel.click();
|
||||
}
|
||||
|
||||
// Wait for the mode dropdown to open
|
||||
const searchInput = page.getByRole('textbox', { name: /search mode/i });
|
||||
const searchInput = page.getByRole("textbox", { name: /search mode/i });
|
||||
await expect(searchInput).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Type to filter modes
|
||||
await searchInput.fill(mode);
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
const option = dialog
|
||||
.getByText(new RegExp(`^${escapeRegex(mode)}$`, 'i'))
|
||||
.first();
|
||||
const dialog = page.getByRole("dialog");
|
||||
const option = dialog.getByText(new RegExp(`^${escapeRegex(mode)}$`, "i")).first();
|
||||
await expect(option).toBeVisible();
|
||||
await option.click({ force: true });
|
||||
|
||||
@@ -449,26 +648,36 @@ export const createAgentWithConfig = async (page: Page, config: AgentConfig) =>
|
||||
await createAgent(page, config.prompt);
|
||||
};
|
||||
|
||||
export const createAgentInRepo = async (
|
||||
page: Page,
|
||||
config: Pick<AgentConfig, "directory" | "prompt">,
|
||||
) => {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
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();
|
||||
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();
|
||||
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();
|
||||
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 });
|
||||
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);
|
||||
@@ -485,9 +694,11 @@ export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
||||
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
|
||||
});
|
||||
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 });
|
||||
|
||||
312
packages/app/e2e/helpers/archive-tab.ts
Normal file
312
packages/app/e2e/helpers/archive-tab.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostSessionsRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
export interface ArchiveTabAgent {
|
||||
id: string;
|
||||
title: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
interface ArchiveTabDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
createAgent(options: {
|
||||
provider: string;
|
||||
model: string;
|
||||
thinkingOptionId?: string;
|
||||
modeId: string;
|
||||
cwd: string;
|
||||
title: string;
|
||||
initialPrompt?: string;
|
||||
}): Promise<{ id: string }>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
||||
waitForAgentUpsert(
|
||||
agentId: string,
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
}
|
||||
|
||||
function getDaemonPort(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
if (daemonPort === "6767") {
|
||||
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
|
||||
}
|
||||
return daemonPort;
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
|
||||
}
|
||||
|
||||
function buildSeededStoragePayload() {
|
||||
const nowIso = new Date().toISOString();
|
||||
return {
|
||||
daemon: buildSeededHost({
|
||||
serverId: getServerId(),
|
||||
endpoint: `127.0.0.1:${getDaemonPort()}`,
|
||||
nowIso,
|
||||
}),
|
||||
preferences: buildCreateAgentPreferences(getServerId()),
|
||||
};
|
||||
}
|
||||
|
||||
interface ArchiveTabDaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `app-e2e-archive-tab-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
webSocketFactory,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function createIdleAgent(
|
||||
client: ArchiveTabDaemonClient,
|
||||
input: { cwd: string; title: string },
|
||||
): Promise<ArchiveTabAgent> {
|
||||
const created = await client.createAgent({
|
||||
provider: "opencode",
|
||||
model: "opencode/gpt-5-nano",
|
||||
modeId: "bypassPermissions",
|
||||
cwd: input.cwd,
|
||||
title: input.title,
|
||||
});
|
||||
const snapshot = await client.waitForAgentUpsert(
|
||||
created.id,
|
||||
(agent) => agent.status === "idle",
|
||||
30_000,
|
||||
);
|
||||
if (snapshot.status !== "idle") {
|
||||
throw new Error(`Expected agent ${created.id} to become idle, got ${snapshot.status}.`);
|
||||
}
|
||||
return {
|
||||
id: created.id,
|
||||
title: input.title,
|
||||
cwd: input.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
export async function archiveAgentFromDaemon(
|
||||
client: ArchiveTabDaemonClient,
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
await client.archiveAgent(agentId);
|
||||
}
|
||||
|
||||
export async function primeAdditionalPage(page: Page): Promise<void> {
|
||||
const seedNonce = randomUUID();
|
||||
const { daemon, preferences } = buildSeededStoragePayload();
|
||||
|
||||
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." });
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ daemon: seededDaemon, preferences: seededPreferences, seedNonce: nonce }) => {
|
||||
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);
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
|
||||
},
|
||||
{ daemon, preferences, seedNonce },
|
||||
);
|
||||
await page.goto("/");
|
||||
}
|
||||
|
||||
export async function resetSeededPageState(page: Page): Promise<void> {
|
||||
const { daemon, preferences } = buildSeededStoragePayload();
|
||||
await page.goto("/");
|
||||
await page.evaluate(
|
||||
({ daemon: seededDaemon, preferences: seededPreferences }) => {
|
||||
localStorage.clear();
|
||||
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.goto("/");
|
||||
}
|
||||
|
||||
export async function openWorkspaceWithAgents(
|
||||
page: Page,
|
||||
agents: [ArchiveTabAgent, ArchiveTabAgent],
|
||||
): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
for (const agent of agents) {
|
||||
await page.goto(buildHostAgentDetailRoute(serverId, agent.id, agent.cwd));
|
||||
|
||||
// The workspace layout consumes `?open=agent:xxx`, returns null during the effect,
|
||||
// then replaces the URL with the clean workspace route after preparing the tab.
|
||||
// On CI, Expo Router's rootNavigationState may take time to initialize,
|
||||
// so we allow a generous timeout here (matching terminal-perf pattern).
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectWorkspaceTabVisible(page: Page, agentId: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectWorkspaceTabHidden(page: Page, agentId: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }),
|
||||
).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectWorkspaceArchiveOutcome(
|
||||
page: Page,
|
||||
input: { archivedAgentId: string; survivingAgentId: string },
|
||||
): Promise<void> {
|
||||
await expectWorkspaceTabHidden(page, input.archivedAgentId);
|
||||
await expectWorkspaceTabVisible(page, input.survivingAgentId);
|
||||
}
|
||||
|
||||
export async function closeWorkspaceAgentTab(page: Page, agentId: string): Promise<void> {
|
||||
const closeButton = page.getByTestId(`workspace-agent-close-${agentId}`).filter({
|
||||
visible: true,
|
||||
});
|
||||
await expect(closeButton.first()).toBeVisible({ timeout: 30_000 });
|
||||
await closeButton.first().click();
|
||||
await expectWorkspaceTabHidden(page, agentId);
|
||||
}
|
||||
|
||||
export async function expectArchivedAgentFocused(page: Page, agentId: string): Promise<void> {
|
||||
await expectWorkspaceTabVisible(page, agentId);
|
||||
await expect(
|
||||
page.getByText("This agent is archived").filter({ visible: true }).first(),
|
||||
).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function reloadWorkspace(page: Page, workspaceId: string): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
}
|
||||
|
||||
export async function openSessions(page: Page): Promise<void> {
|
||||
const sessionsButton = page.getByTestId("sidebar-sessions");
|
||||
await expect(sessionsButton).toBeVisible({ timeout: 30_000 });
|
||||
await sessionsButton.click();
|
||||
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText("Sessions", { exact: true }).last()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
function getSessionRowByTitle(page: Page, title: string) {
|
||||
return page.locator('[data-testid^="agent-row-"]').filter({ hasText: title }).first();
|
||||
}
|
||||
|
||||
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
|
||||
await expect(getSessionRowByTitle(page, title)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectSessionRowArchived(page: Page, title: string): Promise<void> {
|
||||
await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function clickSessionRow(page: Page, title: string): Promise<void> {
|
||||
const row = getSessionRowByTitle(page, title);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
}
|
||||
|
||||
export async function archiveAgentFromSessions(
|
||||
page: Page,
|
||||
input: { agentId: string; title: string },
|
||||
): Promise<void> {
|
||||
const row = getSessionRowByTitle(page, input.title);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
const box = await row.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error(`Could not read bounding box for session row ${input.agentId}.`);
|
||||
}
|
||||
|
||||
// Long-press the row. Idle agents are archived immediately (no modal).
|
||||
// Running/initializing agents show a confirmation modal instead.
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(900);
|
||||
await page.mouse.up();
|
||||
|
||||
// If a confirmation modal appears (running agent), click the archive button.
|
||||
const archiveButton = page.getByTestId("agent-action-archive").first();
|
||||
const modalVisible = await archiveButton.isVisible().catch(() => false);
|
||||
if (modalVisible) {
|
||||
await archiveButton.click();
|
||||
}
|
||||
|
||||
await expectSessionRowArchived(page, input.title);
|
||||
}
|
||||
39
packages/app/e2e/helpers/daemon-registry.ts
Normal file
39
packages/app/e2e/helpers/daemon-registry.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export const TEST_HOST_LABEL = "localhost";
|
||||
|
||||
export const TEST_PROVIDER_PREFERENCES = {
|
||||
claude: { model: "haiku" },
|
||||
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
|
||||
} as const;
|
||||
|
||||
export function buildDirectTcpConnection(endpoint: string) {
|
||||
return {
|
||||
id: `direct:${endpoint}`,
|
||||
type: "directTcp" as const,
|
||||
endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSeededHost(input: {
|
||||
serverId: string;
|
||||
endpoint: string;
|
||||
label?: string;
|
||||
nowIso: string;
|
||||
}) {
|
||||
const connection = buildDirectTcpConnection(input.endpoint);
|
||||
return {
|
||||
serverId: input.serverId,
|
||||
label: input.label ?? TEST_HOST_LABEL,
|
||||
connections: [connection],
|
||||
preferredConnectionId: connection.id,
|
||||
createdAt: input.nowIso,
|
||||
updatedAt: input.nowIso,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCreateAgentPreferences(serverId: string) {
|
||||
return {
|
||||
serverId,
|
||||
provider: "codex" as const,
|
||||
providerPreferences: TEST_PROVIDER_PREFERENCES,
|
||||
};
|
||||
}
|
||||
41
packages/app/e2e/helpers/file-explorer.ts
Normal file
41
packages/app/e2e/helpers/file-explorer.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
function fileExplorerTree(page: Page) {
|
||||
return page.getByTestId("file-explorer-tree-scroll");
|
||||
}
|
||||
|
||||
function fileExplorerEntry(page: Page, name: string) {
|
||||
return fileExplorerTree(page).getByText(name, { exact: true }).first();
|
||||
}
|
||||
|
||||
export async function openFileExplorer(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open explorer" }).first().click();
|
||||
await page.getByTestId("explorer-tab-files").click();
|
||||
await expect(fileExplorerTree(page)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expandFolder(page: Page, folderName: string): Promise<void> {
|
||||
await fileExplorerEntry(page, folderName).click();
|
||||
}
|
||||
|
||||
export async function collapseFolder(page: Page, folderName: string): Promise<void> {
|
||||
await fileExplorerEntry(page, folderName).click();
|
||||
}
|
||||
|
||||
export async function openFileFromExplorer(page: Page, fileName: string): Promise<void> {
|
||||
await fileExplorerEntry(page, fileName).click();
|
||||
}
|
||||
|
||||
export async function expectExplorerEntryVisible(page: Page, name: string): Promise<void> {
|
||||
await expect(fileExplorerEntry(page, name)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectExplorerEntryHidden(page: Page, name: string): Promise<void> {
|
||||
await expect(fileExplorerEntry(page, name)).toBeHidden({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectFileTabOpen(page: Page, filePath: string): Promise<void> {
|
||||
await expect(page.getByTestId(`workspace-tab-file_${filePath}`).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
203
packages/app/e2e/helpers/launcher.ts
Normal file
203
packages/app/e2e/helpers/launcher.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Navigate to a workspace and wait for the tab bar to appear. */
|
||||
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
|
||||
const route = buildHostWorkspaceRoute(getServerId(), cwd);
|
||||
await page.goto(route);
|
||||
await waitForTabBar(page);
|
||||
}
|
||||
|
||||
// ─── Tab bar queries ───────────────────────────────────────────────────────
|
||||
|
||||
/** Wait for the workspace tab bar to be visible. */
|
||||
export async function waitForTabBar(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("workspace-tabs-row").filter({ visible: true }).first(),
|
||||
).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Return all tab test IDs currently in the tab bar. */
|
||||
export async function getTabTestIds(page: Page): Promise<string[]> {
|
||||
const tabs = page
|
||||
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
|
||||
.filter({ visible: true });
|
||||
const count = await tabs.count();
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const testId = await tabs.nth(i).getAttribute("data-testid");
|
||||
if (testId) ids.push(testId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Return the number of tabs matching a kind prefix (e.g. "launcher", "draft", "terminal", "agent"). */
|
||||
export async function countTabsOfKind(page: Page, kind: string): Promise<number> {
|
||||
const ids = await getTabTestIds(page);
|
||||
return ids.filter((id) => id.includes(kind)).length;
|
||||
}
|
||||
|
||||
/** Return the currently active tab's test ID (the one with aria-selected or focus styling). */
|
||||
export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
||||
// Active tab has the focused highlight — check for the aria-selected or data-active attribute
|
||||
const activeTab = page
|
||||
.locator(
|
||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])[aria-selected="true"]',
|
||||
)
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
if (await activeTab.isVisible().catch(() => false)) {
|
||||
return activeTab.getAttribute("data-testid");
|
||||
}
|
||||
// Fallback: the tab with focused styling
|
||||
return 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";
|
||||
await page.keyboard.press(`${modifier}+t`);
|
||||
}
|
||||
|
||||
// ─── 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(
|
||||
page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
/** Assert the new terminal button is visible in the tab bar. */
|
||||
export async function assertTerminalTileVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-terminal").filter({ visible: true }).first(),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
// ─── Tab creation actions ─────────────────────────────────────────────────
|
||||
|
||||
/** Click the new agent tab button to create a draft/chat tab. */
|
||||
export async function clickNewChat(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 to create a terminal tab. */
|
||||
export async function clickTerminal(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();
|
||||
}
|
||||
|
||||
// ─── Tab title assertions ──────────────────────────────────────────────────
|
||||
|
||||
/** Wait for any tab in the bar to display the given title text. */
|
||||
export async function waitForTabWithTitle(
|
||||
page: Page,
|
||||
title: string | RegExp,
|
||||
timeout = 30_000,
|
||||
): Promise<void> {
|
||||
const matcher = typeof title === "string" ? new RegExp(title, "i") : title;
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
|
||||
.filter({ hasText: matcher })
|
||||
.filter({ visible: true })
|
||||
.first(),
|
||||
).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
/** Assert the new agent tab button is visible in the tab bar. */
|
||||
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||
const buttons = page.getByTestId("workspace-new-agent-tab").filter({ visible: true });
|
||||
const count = await buttons.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
// ─── No-flash measurement ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Measure the time between clicking a launcher tile and the replacement panel becoming visible.
|
||||
* Returns elapsed milliseconds.
|
||||
*/
|
||||
export async function measureTileTransition(
|
||||
page: Page,
|
||||
clickAction: () => Promise<void>,
|
||||
successLocator: ReturnType<Page["locator"]>,
|
||||
timeout = 5_000,
|
||||
): Promise<number> {
|
||||
const start = Date.now();
|
||||
await clickAction();
|
||||
await expect(successLocator).toBeVisible({ timeout });
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample tab IDs at high frequency across a transition to detect blank/intermediate states.
|
||||
* Returns all unique snapshots observed.
|
||||
*/
|
||||
export async function sampleTabsDuringTransition(
|
||||
page: Page,
|
||||
action: () => Promise<void>,
|
||||
durationMs = 2_000,
|
||||
intervalMs = 30,
|
||||
): Promise<string[][]> {
|
||||
const snapshots: string[][] = [];
|
||||
const startSampling = async () => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < durationMs) {
|
||||
snapshots.push(await getTabTestIds(page));
|
||||
await page.waitForTimeout(intervalMs);
|
||||
}
|
||||
};
|
||||
|
||||
const samplingPromise = startSampling();
|
||||
await action();
|
||||
await samplingPromise;
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
// ─── Workspace setup ───────────────────────────────────────────────────────
|
||||
|
||||
/** Create a temp git repo and return its path with a cleanup function. */
|
||||
export async function createWorkspace(
|
||||
prefix = "launcher-e2e-",
|
||||
): ReturnType<typeof createTempGitRepo> {
|
||||
return createTempGitRepo(prefix);
|
||||
}
|
||||
252
packages/app/e2e/helpers/new-workspace.ts
Normal file
252
packages/app/e2e/helpers/new-workspace.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { DaemonClient as ServerDaemonClient } from "@server/client/daemon-client";
|
||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
|
||||
type NewWorkspaceDaemonClient = Pick<
|
||||
ServerDaemonClient,
|
||||
| "archivePaseoWorktree"
|
||||
| "archiveWorkspace"
|
||||
| "close"
|
||||
| "connect"
|
||||
| "createPaseoWorktree"
|
||||
| "openProject"
|
||||
>;
|
||||
|
||||
interface NewWorkspaceDaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
|
||||
|
||||
export interface OpenedProject {
|
||||
workspaceId: string;
|
||||
projectKey: string;
|
||||
projectDisplayName: string;
|
||||
workspaceName: string;
|
||||
}
|
||||
|
||||
function getDaemonPort(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
if (daemonPort === "6767") {
|
||||
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
|
||||
}
|
||||
return daemonPort;
|
||||
}
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: NewWorkspaceDaemonClientConfig) => NewWorkspaceDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
function requireWorkspace(payload: OpenProjectPayload) {
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
if (!payload.workspace) {
|
||||
throw new Error("openProject returned no workspace.");
|
||||
}
|
||||
return payload.workspace;
|
||||
}
|
||||
|
||||
function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | null {
|
||||
const pathname = new URL(page.url()).pathname;
|
||||
const match = pathname.match(
|
||||
new RegExp(`^/h/${serverId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/workspace/([^/?#]+)`),
|
||||
);
|
||||
if (!match?.[1]) {
|
||||
return null;
|
||||
}
|
||||
return decodeWorkspaceIdFromPathSegment(match[1]);
|
||||
}
|
||||
|
||||
export async function connectNewWorkspaceDaemonClient(): Promise<NewWorkspaceDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `app-e2e-new-workspace-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
webSocketFactory,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function openProjectViaDaemon(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
repoPath: string,
|
||||
): Promise<OpenedProject> {
|
||||
const workspace = requireWorkspace(await client.openProject(repoPath));
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
projectKey: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
workspaceName: workspace.name,
|
||||
};
|
||||
}
|
||||
|
||||
export async function archiveWorkspaceFromDaemon(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const payload = await client.archivePaseoWorktree({ worktreePath: workspaceId });
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error.message);
|
||||
}
|
||||
if (!payload.success) {
|
||||
throw new Error(`Failed to archive workspace: ${workspaceId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function archiveLocalWorkspaceFromDaemon(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const payload = await client.archiveWorkspace(workspaceId);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
if (!payload.archivedAt) {
|
||||
throw new Error(`Failed to archive workspace: ${workspaceId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWorktreeViaDaemon(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
input: { cwd: string; slug: string },
|
||||
): Promise<OpenedProject> {
|
||||
const payload = await client.createPaseoWorktree({
|
||||
cwd: input.cwd,
|
||||
worktreeSlug: input.slug,
|
||||
});
|
||||
const workspace = requireWorkspace(payload);
|
||||
return {
|
||||
workspaceId: workspace.id,
|
||||
projectKey: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
workspaceName: workspace.name,
|
||||
};
|
||||
}
|
||||
|
||||
export async function openNewWorkspaceComposer(
|
||||
page: Page,
|
||||
input: { projectKey: string; projectDisplayName: string },
|
||||
): Promise<void> {
|
||||
const projectRow = page.getByTestId(`sidebar-project-row-${input.projectKey}`).first();
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await projectRow.hover();
|
||||
|
||||
const button = page.getByTestId(`sidebar-project-new-worktree-${input.projectKey}`).first();
|
||||
await expect(button).toBeVisible({ timeout: 30_000 });
|
||||
await button.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/h\/[^/]+\/new(?:\?.*)?$/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clickNewWorkspaceButton(
|
||||
page: Page,
|
||||
input: { projectKey: string; projectDisplayName: string },
|
||||
): Promise<void> {
|
||||
await openNewWorkspaceComposer(page, input);
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||
await createButton.click();
|
||||
}
|
||||
|
||||
export async function openStartingRefPicker(page: Page): Promise<void> {
|
||||
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
}
|
||||
|
||||
export async function selectBranchInPicker(page: Page, name: string): Promise<void> {
|
||||
const branchRow = page.getByTestId(`new-workspace-ref-picker-branch-${name}`);
|
||||
await expect(branchRow).toBeVisible({ timeout: 30_000 });
|
||||
await branchRow.click();
|
||||
}
|
||||
|
||||
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
|
||||
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
|
||||
await expect(prRow).toBeVisible({ timeout: 30_000 });
|
||||
await prRow.click();
|
||||
}
|
||||
|
||||
export async function expectStartingRefPickerTriggerPr(
|
||||
page: Page,
|
||||
input: { number: number; title: string; headRef: string },
|
||||
): Promise<void> {
|
||||
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
|
||||
await expect(trigger).toContainText(`#${input.number}`);
|
||||
await expect(trigger).toContainText(input.title);
|
||||
await expect(trigger).not.toContainText(input.headRef);
|
||||
}
|
||||
|
||||
export async function expectComposerGithubAttachmentPill(
|
||||
page: Page,
|
||||
input: { number: number; title: string },
|
||||
): Promise<void> {
|
||||
const pills = page.getByTestId("composer-github-attachment-pill");
|
||||
await expect(pills).toHaveCount(1);
|
||||
await expect(pills.first()).toContainText(`#${input.number}`);
|
||||
await expect(pills.first()).toContainText(input.title);
|
||||
}
|
||||
|
||||
export async function assertNewWorkspaceSidebarAndHeader(
|
||||
page: Page,
|
||||
input: { serverId: string; previousWorkspaceId: string; projectDisplayName: string },
|
||||
): Promise<{ workspaceId: string }> {
|
||||
// Wait for URL to redirect to the newly created workspace.
|
||||
// Uses URL as source of truth to avoid picking up sidebar rows from concurrent tests.
|
||||
let workspaceId: string | null = null;
|
||||
const deadline = Date.now() + 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
|
||||
if (workspaceId && workspaceId !== input.previousWorkspaceId) {
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
|
||||
throw new Error(`Expected URL to redirect to a new workspace.\nCurrent URL: ${page.url()}`);
|
||||
}
|
||||
|
||||
const createdWorkspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${input.serverId}:${workspaceId}`,
|
||||
);
|
||||
await expect(createdWorkspaceRow.first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspaceLabelFromPath(workspaceId),
|
||||
subtitle: input.projectDisplayName,
|
||||
});
|
||||
|
||||
return { workspaceId };
|
||||
}
|
||||
27
packages/app/e2e/helpers/node-ws-factory.ts
Normal file
27
packages/app/e2e/helpers/node-ws-factory.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
interface WebSocketLike {
|
||||
readyState: number;
|
||||
send: (data: string | Uint8Array | ArrayBuffer) => void;
|
||||
close: (code?: number, reason?: string) => void;
|
||||
binaryType?: string;
|
||||
on?: (event: string, listener: (...args: unknown[]) => void) => void;
|
||||
off?: (event: string, listener: (...args: unknown[]) => void) => void;
|
||||
removeListener?: (event: string, listener: (...args: unknown[]) => void) => void;
|
||||
addEventListener?: (event: string, listener: (event: unknown) => void) => void;
|
||||
removeEventListener?: (event: string, listener: (event: unknown) => void) => void;
|
||||
onopen?: ((event: unknown) => void) | null;
|
||||
onclose?: ((event: unknown) => void) | null;
|
||||
onerror?: ((event: unknown) => void) | null;
|
||||
onmessage?: ((event: unknown) => void) | null;
|
||||
}
|
||||
|
||||
export type NodeWebSocketFactory = (
|
||||
url: string,
|
||||
options?: { headers?: Record<string, string> },
|
||||
) => WebSocketLike;
|
||||
|
||||
export function createNodeWebSocketFactory(): NodeWebSocketFactory {
|
||||
return (url: string, options?: { headers?: Record<string, string> }) =>
|
||||
new WebSocket(url, { headers: options?.headers }) as unknown as WebSocketLike;
|
||||
}
|
||||
131
packages/app/e2e/helpers/paseo-home-fork.ts
Normal file
131
packages/app/e2e/helpers/paseo-home-fork.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { copyFile, mkdir, readdir, rm, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export interface PaseoHomeMetadataForkResult {
|
||||
sourceHome: string;
|
||||
targetHome: string;
|
||||
agentFiles: number;
|
||||
agentBytes: number;
|
||||
projectFiles: number;
|
||||
projectBytes: number;
|
||||
copiedFiles: number;
|
||||
copiedBytes: number;
|
||||
skippedMissing: string[];
|
||||
}
|
||||
|
||||
interface CopyStats {
|
||||
files: number;
|
||||
bytes: number;
|
||||
skippedMissing: string[];
|
||||
}
|
||||
|
||||
export function resolvePaseoHomePath(value: string): string {
|
||||
if (value === "~") {
|
||||
return homedir();
|
||||
}
|
||||
if (value.startsWith("~/")) {
|
||||
return path.join(homedir(), value.slice(2));
|
||||
}
|
||||
return path.resolve(value);
|
||||
}
|
||||
|
||||
async function copyJsonTree(sourceDir: string, targetDir: string): Promise<CopyStats> {
|
||||
if (!existsSync(sourceDir)) {
|
||||
return { files: 0, bytes: 0, skippedMissing: [sourceDir] };
|
||||
}
|
||||
|
||||
const stats: CopyStats = { files: 0, bytes: 0, skippedMissing: [] };
|
||||
const entries = await readdir(sourceDir, { withFileTypes: true });
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const sourcePath = path.join(sourceDir, entry.name);
|
||||
const targetPath = path.join(targetDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const nested = await copyJsonTree(sourcePath, targetPath);
|
||||
stats.files += nested.files;
|
||||
stats.bytes += nested.bytes;
|
||||
stats.skippedMissing.push(...nested.skippedMissing);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile() || !entry.name.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(targetPath), { recursive: true });
|
||||
await copyFile(sourcePath, targetPath);
|
||||
const fileStat = await stat(sourcePath);
|
||||
stats.files += 1;
|
||||
stats.bytes += fileStat.size;
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
async function copyProjectRegistryFiles(
|
||||
sourceHome: string,
|
||||
targetHome: string,
|
||||
): Promise<CopyStats> {
|
||||
const stats: CopyStats = { files: 0, bytes: 0, skippedMissing: [] };
|
||||
const sourceProjectsDir = path.join(sourceHome, "projects");
|
||||
const targetProjectsDir = path.join(targetHome, "projects");
|
||||
await mkdir(targetProjectsDir, { recursive: true });
|
||||
|
||||
for (const fileName of ["projects.json", "workspaces.json"]) {
|
||||
const sourcePath = path.join(sourceProjectsDir, fileName);
|
||||
const targetPath = path.join(targetProjectsDir, fileName);
|
||||
if (!existsSync(sourcePath)) {
|
||||
stats.skippedMissing.push(sourcePath);
|
||||
continue;
|
||||
}
|
||||
await copyFile(sourcePath, targetPath);
|
||||
const fileStat = await stat(sourcePath);
|
||||
stats.files += 1;
|
||||
stats.bytes += fileStat.size;
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
export async function forkPaseoHomeMetadata(input: {
|
||||
sourceHome: string;
|
||||
targetHome: string;
|
||||
}): Promise<PaseoHomeMetadataForkResult> {
|
||||
const sourceHome = resolvePaseoHomePath(input.sourceHome);
|
||||
const targetHome = resolvePaseoHomePath(input.targetHome);
|
||||
|
||||
if (sourceHome === targetHome) {
|
||||
throw new Error("Refusing to fork Paseo metadata onto the same PASEO_HOME.");
|
||||
}
|
||||
|
||||
await mkdir(targetHome, { recursive: true });
|
||||
|
||||
// Reset only the copied metadata surface. In particular, do not copy or remove
|
||||
// worktrees here: forked workspace records should continue to point at the
|
||||
// original checkout/worktree paths from the source home.
|
||||
await rm(path.join(targetHome, "agents"), { recursive: true, force: true });
|
||||
await rm(path.join(targetHome, "projects", "projects.json"), { force: true });
|
||||
await rm(path.join(targetHome, "projects", "workspaces.json"), { force: true });
|
||||
|
||||
const agents = await copyJsonTree(
|
||||
path.join(sourceHome, "agents"),
|
||||
path.join(targetHome, "agents"),
|
||||
);
|
||||
const projects = await copyProjectRegistryFiles(sourceHome, targetHome);
|
||||
|
||||
return {
|
||||
sourceHome,
|
||||
targetHome,
|
||||
agentFiles: agents.files,
|
||||
agentBytes: agents.bytes,
|
||||
projectFiles: projects.files,
|
||||
projectBytes: projects.bytes,
|
||||
copiedFiles: agents.files + projects.files,
|
||||
copiedBytes: agents.bytes + projects.bytes,
|
||||
skippedMissing: [...agents.skippedMissing, ...projects.skippedMissing],
|
||||
};
|
||||
}
|
||||
234
packages/app/e2e/helpers/startup-dsl.ts
Normal file
234
packages/app/e2e/helpers/startup-dsl.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { expect, type Page } from "../fixtures";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
|
||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||
const REGISTRY_KEY = "@paseo:daemon-registry";
|
||||
const E2E_KEY = "@paseo:e2e";
|
||||
|
||||
interface SavedHostInput {
|
||||
serverId: string;
|
||||
label: string;
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
export function startupScenario(page: Page) {
|
||||
return new StartupScenario(page);
|
||||
}
|
||||
|
||||
class StartupScenario {
|
||||
private readonly page: Page;
|
||||
private savedHosts: SavedHostInput[] = [];
|
||||
private desktopBridge = false;
|
||||
private blockedEndpointPorts = new Set<string>();
|
||||
private viewport: { width: number; height: number } | null = null;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
withMobileViewport(): this {
|
||||
this.viewport = { width: 390, height: 844 };
|
||||
return this;
|
||||
}
|
||||
|
||||
withSavedHost(input: SavedHostInput): this {
|
||||
this.savedHosts.push(input);
|
||||
const port = input.endpoint.match(/:(\d+)$/)?.[1];
|
||||
if (port) {
|
||||
this.blockedEndpointPorts.add(port);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
withPendingDesktopDaemon(): this {
|
||||
this.desktopBridge = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
withBlockedPort(port: string): this {
|
||||
this.blockedEndpointPorts.add(port);
|
||||
return this;
|
||||
}
|
||||
|
||||
async openRoot(): Promise<StartupAssertions> {
|
||||
await this.prepare();
|
||||
await this.page.goto("/");
|
||||
return new StartupAssertions(this.page);
|
||||
}
|
||||
|
||||
async openHostWorkspace(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<StartupAssertions> {
|
||||
await this.prepare();
|
||||
await this.page.goto(
|
||||
`/h/${encodeURIComponent(input.serverId)}/workspace/${encodeURIComponent(input.workspaceId)}`,
|
||||
);
|
||||
return new StartupAssertions(this.page);
|
||||
}
|
||||
|
||||
private async prepare(): Promise<void> {
|
||||
if (this.viewport) {
|
||||
await this.page.setViewportSize(this.viewport);
|
||||
}
|
||||
|
||||
if (this.desktopBridge) {
|
||||
await installPendingDesktopBridge(this.page);
|
||||
}
|
||||
|
||||
for (const port of this.blockedEndpointPorts) {
|
||||
await this.page.routeWebSocket(new RegExp(`:${escapeRegex(port)}\\b`), async (ws) => {
|
||||
await ws.close({ code: 1008, reason: "Blocked unreachable startup test host." });
|
||||
});
|
||||
}
|
||||
|
||||
if (this.savedHosts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Let the shared fixture create its seed nonce, then opt out of that seed for
|
||||
// the next navigation so this scenario owns the stored host registry.
|
||||
await this.page.goto("/");
|
||||
const nowIso = new Date().toISOString();
|
||||
const registry = this.savedHosts.map((host) =>
|
||||
buildStoredHost({
|
||||
serverId: host.serverId,
|
||||
endpoint: host.endpoint,
|
||||
label: host.label,
|
||||
nowIso,
|
||||
}),
|
||||
);
|
||||
const firstHost = registry[0];
|
||||
if (!firstHost) {
|
||||
throw new Error("Expected at least one startup test host.");
|
||||
}
|
||||
const createAgentPreferences = buildStoredCreateAgentPreferences(firstHost.serverId);
|
||||
|
||||
await this.page.evaluate(
|
||||
({ keys, registry: storedRegistry, createAgentPreferences: storedPreferences }) => {
|
||||
const nonce = localStorage.getItem(keys.seedNonce);
|
||||
if (!nonce) {
|
||||
throw new Error("Expected e2e seed nonce before overriding startup registry.");
|
||||
}
|
||||
|
||||
localStorage.setItem(keys.e2e, "1");
|
||||
localStorage.setItem(keys.registry, JSON.stringify(storedRegistry));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(storedPreferences));
|
||||
localStorage.setItem(keys.disableDefaultSeedOnce, nonce);
|
||||
},
|
||||
{
|
||||
keys: {
|
||||
disableDefaultSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY,
|
||||
e2e: E2E_KEY,
|
||||
registry: REGISTRY_KEY,
|
||||
seedNonce: SEED_NONCE_KEY,
|
||||
},
|
||||
registry,
|
||||
createAgentPreferences,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StartupAssertions {
|
||||
private readonly page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
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();
|
||||
await expect(this.page.getByTestId("welcome-scan-qr")).toHaveCount(0);
|
||||
return this;
|
||||
}
|
||||
|
||||
async expectsNoSavedHostStatus(input: { label: string }): Promise<this> {
|
||||
await expect(this.page.getByText(input.label, { exact: true })).toHaveCount(0);
|
||||
await expect(this.page.getByText("Connection error", { exact: true })).toHaveCount(0);
|
||||
await expect(this.page.getByText("Offline", { exact: true })).toHaveCount(0);
|
||||
return this;
|
||||
}
|
||||
|
||||
async expectsNoLocalServerStartupCopy(): Promise<this> {
|
||||
await expect(this.page.getByText("Starting local server...", { exact: true })).toHaveCount(0);
|
||||
await expect(this.page.getByText("Connecting to local server...", { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
async expectsDesktopDaemonStartup(): Promise<this> {
|
||||
await expect(this.page.getByTestId("startup-splash")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
async expectsSidebarHidden(): Promise<this> {
|
||||
await expect(this.page.locator('[data-testid="sidebar-settings"]:visible')).toHaveCount(0);
|
||||
await expect(this.page.locator('[data-testid="sidebar-project-list"]:visible')).toHaveCount(0);
|
||||
return this;
|
||||
}
|
||||
|
||||
async expectsNoUndefinedRoute(): Promise<this> {
|
||||
await expect(this.page).not.toHaveURL(/\/h\/undefined\/workspace\/undefined/);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
async function installPendingDesktopBridge(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
||||
platform: "darwin",
|
||||
invoke: async (command: string) => {
|
||||
if (command === "start_desktop_daemon") {
|
||||
await new Promise(() => {
|
||||
// Keep the daemon in the startup phase until the test ends.
|
||||
});
|
||||
}
|
||||
if (command === "desktop_daemon_status") {
|
||||
return {
|
||||
serverId: "srv_desktop_pending",
|
||||
status: "starting",
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: null,
|
||||
home: "",
|
||||
version: null,
|
||||
desktopManaged: true,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
if (command === "desktop_daemon_logs") {
|
||||
return { logPath: "", contents: "" };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getPendingOpenProject: async () => null,
|
||||
events: { on: async () => () => undefined },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildStoredHost(input: {
|
||||
serverId: string;
|
||||
endpoint: string;
|
||||
label: string;
|
||||
nowIso: string;
|
||||
}) {
|
||||
return buildSeededHost(input);
|
||||
}
|
||||
|
||||
function buildStoredCreateAgentPreferences(serverId: string) {
|
||||
return buildCreateAgentPreferences(serverId);
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
98
packages/app/e2e/helpers/terminal-dsl.ts
Normal file
98
packages/app/e2e/helpers/terminal-dsl.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import {
|
||||
connectTerminalClient,
|
||||
navigateToTerminal,
|
||||
setupDeterministicPrompt,
|
||||
type TerminalPerfDaemonClient,
|
||||
} from "./terminal-perf";
|
||||
|
||||
interface TempRepo {
|
||||
path: string;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface TerminalInstance {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
export class TerminalE2EHarness {
|
||||
readonly client: TerminalPerfDaemonClient;
|
||||
readonly tempRepo: TempRepo;
|
||||
readonly workspaceId: string;
|
||||
|
||||
private constructor(input: {
|
||||
client: TerminalPerfDaemonClient;
|
||||
tempRepo: TempRepo;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
this.client = input.client;
|
||||
this.tempRepo = input.tempRepo;
|
||||
this.workspaceId = input.workspaceId;
|
||||
}
|
||||
|
||||
static async create(input: { tempPrefix: string }): Promise<TerminalE2EHarness> {
|
||||
const tempRepo = await createTempGitRepo(input.tempPrefix);
|
||||
const client = await connectTerminalClient();
|
||||
const seedResult = await client.openProject(tempRepo.path);
|
||||
if (!seedResult.workspace) {
|
||||
await client.close().catch(() => {});
|
||||
await tempRepo.cleanup().catch(() => {});
|
||||
throw new Error(seedResult.error ?? "Failed to seed workspace");
|
||||
}
|
||||
return new TerminalE2EHarness({
|
||||
client,
|
||||
tempRepo,
|
||||
workspaceId: seedResult.workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
await this.client.close().catch(() => {});
|
||||
await this.tempRepo.cleanup().catch(() => {});
|
||||
}
|
||||
|
||||
async createTerminal(input: { name: string }): Promise<TerminalInstance> {
|
||||
const result = await this.client.createTerminal(this.tempRepo.path, input.name);
|
||||
if (!result.terminal) {
|
||||
throw new Error(`Failed to create terminal: ${result.error}`);
|
||||
}
|
||||
return result.terminal;
|
||||
}
|
||||
|
||||
async killTerminal(terminalId: string): Promise<void> {
|
||||
await this.client.killTerminal(terminalId).catch(() => {});
|
||||
}
|
||||
|
||||
async openTerminal(page: Page, input: { terminalId: string }): Promise<void> {
|
||||
await navigateToTerminal(page, {
|
||||
workspaceId: this.workspaceId,
|
||||
terminalId: input.terminalId,
|
||||
});
|
||||
}
|
||||
|
||||
terminalSurface(page: Page) {
|
||||
return page.locator('[data-testid="terminal-surface"]');
|
||||
}
|
||||
|
||||
async setupPrompt(page: Page, sentinel?: string): Promise<void> {
|
||||
await setupDeterministicPrompt(page, sentinel);
|
||||
}
|
||||
}
|
||||
|
||||
export async function withTerminalInApp<T>(
|
||||
page: Page,
|
||||
harness: TerminalE2EHarness,
|
||||
input: { name: string },
|
||||
fn: (terminal: TerminalInstance) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const terminal = await harness.createTerminal({ name: input.name });
|
||||
try {
|
||||
await harness.openTerminal(page, { terminalId: terminal.id });
|
||||
return await fn(terminal);
|
||||
} finally {
|
||||
await harness.killTerminal(terminal.id);
|
||||
}
|
||||
}
|
||||
270
packages/app/e2e/helpers/terminal-perf.ts
Normal file
270
packages/app/e2e/helpers/terminal-perf.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
|
||||
export interface TerminalPerfDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
openProject(cwd: string): Promise<{
|
||||
workspace: { id: string; name: string; projectRootPath: string } | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
createTerminal(
|
||||
cwd: string,
|
||||
name?: string,
|
||||
): Promise<{
|
||||
terminal: { id: string; name: string; cwd: string } | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
createAgent(options: {
|
||||
provider: string;
|
||||
cwd: string;
|
||||
title?: string;
|
||||
modeId?: string;
|
||||
}): Promise<{ id: string; status: string }>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
subscribeTerminal(
|
||||
terminalId: string,
|
||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||
sendTerminalInput(
|
||||
terminalId: string,
|
||||
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
|
||||
): void;
|
||||
onTerminalStreamEvent(
|
||||
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
|
||||
): () => void;
|
||||
killTerminal(terminalId: string): Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
return `ws://127.0.0.1:${daemonPort}/ws`;
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
interface TerminalPerfDaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectTerminalClient(): Promise<TerminalPerfDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `terminal-perf-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
webSocketFactory,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string {
|
||||
const serverId = getServerId();
|
||||
const route = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
return `${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`;
|
||||
}
|
||||
|
||||
function buildWorkspaceUrl(workspaceId: string): string {
|
||||
return buildHostWorkspaceRoute(getServerId(), workspaceId);
|
||||
}
|
||||
|
||||
export async function getTerminalBufferText(page: Page): Promise<string> {
|
||||
return page.evaluate(() => {
|
||||
const term = (
|
||||
window as Window & {
|
||||
__paseoTerminal?: {
|
||||
buffer: {
|
||||
active: {
|
||||
length: number;
|
||||
getLine: (i: number) => { translateToString: (trim: boolean) => string } | null;
|
||||
};
|
||||
};
|
||||
onWriteParsed: (cb: () => void) => { dispose: () => void };
|
||||
};
|
||||
}
|
||||
).__paseoTerminal;
|
||||
if (!term) {
|
||||
return "";
|
||||
}
|
||||
const buf = term.buffer.active;
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
const line = buf.getLine(i);
|
||||
if (line) {
|
||||
lines.push(line.translateToString(true));
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForTerminalContent(
|
||||
page: Page,
|
||||
predicate: (text: string) => boolean,
|
||||
timeout: number,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
const text = await getTerminalBufferText(page);
|
||||
if (predicate(text)) {
|
||||
return;
|
||||
}
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
throw new Error(`Terminal content did not match predicate within ${timeout}ms`);
|
||||
}
|
||||
|
||||
export async function navigateToTerminal(
|
||||
page: Page,
|
||||
input: { workspaceId: string; terminalId: string },
|
||||
): Promise<void> {
|
||||
// Boot the app at the workspace route directly.
|
||||
// The fixtures.ts beforeEach addInitScript seeds localStorage on every navigation,
|
||||
// so the daemon registry is already configured when the app starts.
|
||||
const workspaceRoute = buildTerminalWorkspaceUrl(input.workspaceId, input.terminalId);
|
||||
await page.goto(workspaceRoute);
|
||||
|
||||
// The workspace layout consumes `?open=...`, returns null during the effect,
|
||||
// then replaces the URL with the clean workspace route after preparing the tab.
|
||||
// On CI, Expo Router's rootNavigationState may take time to initialize,
|
||||
// so we allow a generous timeout here.
|
||||
const cleanWorkspaceRoute = buildWorkspaceUrl(input.workspaceId);
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname === cleanWorkspaceRoute && !url.searchParams.has("open"),
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
// Wait for daemon connection (sidebar shows host label)
|
||||
await page
|
||||
.getByText("localhost", { exact: true })
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout: 30_000 });
|
||||
|
||||
// The open intent should have prepared and focused the exact pre-created terminal tab.
|
||||
// The tab reconciliation effect also auto-creates terminal tabs once hydration completes,
|
||||
// so we give it enough time for the full workspace hydration + tab creation cycle.
|
||||
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal_${input.terminalId}"]`);
|
||||
await terminalTab.waitFor({ state: "visible", timeout: 30_000 });
|
||||
await terminalTab.click();
|
||||
|
||||
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
|
||||
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
|
||||
|
||||
// Wait for loading overlay to disappear (terminal attached)
|
||||
await page
|
||||
.locator('[data-testid="terminal-attach-loading"]')
|
||||
.waitFor({ state: "hidden", timeout: 10_000 })
|
||||
.catch(() => {
|
||||
// overlay may never appear if attachment is instant
|
||||
});
|
||||
|
||||
await terminalSurface.scrollIntoViewIfNeeded();
|
||||
await terminalSurface.click();
|
||||
}
|
||||
|
||||
export async function setupDeterministicPrompt(page: Page, sentinel?: string): Promise<void> {
|
||||
const tag = sentinel ?? `READY_${Date.now()}`;
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
|
||||
await terminal.pressSequentially(`echo ${tag}\n`, { delay: 0 });
|
||||
await waitForTerminalContent(page, (text) => text.includes(tag), 10_000);
|
||||
|
||||
await terminal.pressSequentially("export PS1='$ '\n", { delay: 0 });
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
export interface LatencySample {
|
||||
char: string;
|
||||
latencyMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures keystroke echo round-trip latency.
|
||||
*
|
||||
* Starts a high-resolution timer on the browser keydown event (capture phase)
|
||||
* and stops it when xterm.js finishes parsing the echoed write. This measures
|
||||
* the full path: keydown → WebSocket → daemon PTY echo → WebSocket → xterm render.
|
||||
*/
|
||||
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
|
||||
await page.evaluate(() => {
|
||||
const win = window as Window & {
|
||||
__paseoTerminal?: { onWriteParsed: (cb: () => void) => { dispose: () => void } };
|
||||
__perfKeystroke?: { promise: Promise<number> | null };
|
||||
};
|
||||
if (!win.__paseoTerminal) {
|
||||
throw new Error("__paseoTerminal not available");
|
||||
}
|
||||
const term = win.__paseoTerminal;
|
||||
|
||||
const state = (win.__perfKeystroke = {
|
||||
promise: null as Promise<number> | null,
|
||||
});
|
||||
|
||||
state.promise = new Promise<number>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
document.removeEventListener("keydown", onKeyDown, true);
|
||||
reject(new Error("keystroke echo timeout (5s)"));
|
||||
}, 5000);
|
||||
|
||||
function onKeyDown() {
|
||||
document.removeEventListener("keydown", onKeyDown, true);
|
||||
const start = performance.now();
|
||||
const disposable = term.onWriteParsed(() => {
|
||||
clearTimeout(timeout);
|
||||
disposable.dispose();
|
||||
resolve(performance.now() - start);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", onKeyDown, true);
|
||||
});
|
||||
});
|
||||
|
||||
await page.keyboard.press(char);
|
||||
|
||||
return page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __perfKeystroke: { promise: Promise<number> } }).__perfKeystroke
|
||||
.promise,
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
return sorted[Math.max(0, index)];
|
||||
}
|
||||
|
||||
export function round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
713
packages/app/e2e/helpers/terminal-probes.ts
Normal file
713
packages/app/e2e/helpers/terminal-probes.ts
Normal file
@@ -0,0 +1,713 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
export interface TerminalRenderProbeSnapshot {
|
||||
setCount: number;
|
||||
unsetCount: number;
|
||||
writeCount: number;
|
||||
resetWrites: number;
|
||||
clearScreenWrites: number;
|
||||
altEnterWrites: number;
|
||||
altExitWrites: number;
|
||||
events: TerminalRenderProbeEvent[];
|
||||
frames: TerminalFrame[];
|
||||
}
|
||||
|
||||
export interface TerminalRenderProbeEvent {
|
||||
at: number;
|
||||
type: "set" | "unset" | "reset-write" | "clear-write" | "alt-enter-write" | "alt-exit-write";
|
||||
preview?: string;
|
||||
}
|
||||
|
||||
export interface TerminalFrame {
|
||||
at: number;
|
||||
rowCount: number;
|
||||
nonEmptyRows: number;
|
||||
firstNonEmptyRow: number | null;
|
||||
text: string;
|
||||
topText: string;
|
||||
}
|
||||
|
||||
export type TerminalRenderProbeSummary = Omit<TerminalRenderProbeSnapshot, "frames"> & {
|
||||
frameCount: number;
|
||||
};
|
||||
|
||||
export interface TerminalKeystrokeStressReport {
|
||||
inputTextLength: number;
|
||||
keydownCount: number;
|
||||
inputFrameCount: number;
|
||||
outputFrameCount: number;
|
||||
textMessageFrameCount: number;
|
||||
textMessagePayloadBytes: number;
|
||||
largeTextMessageCount: number;
|
||||
largestTextMessageBytes: number;
|
||||
agentStreamTextMessageCount: number;
|
||||
agentStreamTextMessagePayloadBytes: number;
|
||||
largeAgentStreamTextMessageCount: number;
|
||||
largestAgentStreamTextMessageBytes: number;
|
||||
appEventCount: number;
|
||||
appEventCounts: Record<string, number>;
|
||||
runtimeMaxQueueDepth: number;
|
||||
xtermWriteCount: number;
|
||||
inputFramePayloadBytes: number;
|
||||
outputFramePayloadBytes: number;
|
||||
keydownToInputFrameMs: LatencyStats | null;
|
||||
inputFrameToOutputFrameMs: LatencyStats | null;
|
||||
appBinaryReceivedToFrameDecodedMs: LatencyStats | null;
|
||||
appFrameDecodedToTerminalEmitMs: LatencyStats | null;
|
||||
appTerminalEmitListenerDurationMs: LatencyStats | null;
|
||||
appTerminalEmitToStreamControllerOutputMs: LatencyStats | null;
|
||||
appStreamControllerDecodeToOnOutputMs: LatencyStats | null;
|
||||
appStreamControllerToEmulatorWriteMs: LatencyStats | null;
|
||||
appEmulatorWriteToRuntimeEnqueuedMs: LatencyStats | null;
|
||||
appRuntimeEnqueuedToOperationStartMs: LatencyStats | null;
|
||||
appRuntimeOperationStartToXtermWriteMs: LatencyStats | null;
|
||||
appRuntimeXtermWriteToCommitMs: LatencyStats | null;
|
||||
appBinaryReceivedToRuntimeEnqueuedMs: LatencyStats | null;
|
||||
appBinaryReceivedToRuntimeOperationStartMs: LatencyStats | null;
|
||||
appBinaryReceivedToXtermCommitMs: LatencyStats | null;
|
||||
outputFrameToXtermWriteMs: LatencyStats | null;
|
||||
xtermWriteDurationMs: LatencyStats | null;
|
||||
keydownToXtermCommitMs: LatencyStats | null;
|
||||
firstKeydownAt: number | null;
|
||||
lastXtermCommitAt: number | null;
|
||||
}
|
||||
|
||||
export interface LatencyStats {
|
||||
count: number;
|
||||
minMs: number;
|
||||
p50Ms: number;
|
||||
p95Ms: number;
|
||||
maxMs: number;
|
||||
avgMs: number;
|
||||
}
|
||||
|
||||
export async function installTerminalRenderProbe(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
interface ProbeTerm {
|
||||
write?: (data: string | Uint8Array, callback?: () => void) => void;
|
||||
__paseoRenderProbeWriteWrapped?: boolean;
|
||||
}
|
||||
interface ProbeState {
|
||||
term: ProbeTerm | undefined;
|
||||
setCount: number;
|
||||
unsetCount: number;
|
||||
writeCount: number;
|
||||
resetWrites: number;
|
||||
clearScreenWrites: number;
|
||||
altEnterWrites: number;
|
||||
altExitWrites: number;
|
||||
events: TerminalRenderProbeEvent[];
|
||||
frames: TerminalFrame[];
|
||||
rafId: number | null;
|
||||
sampleUntil: number;
|
||||
reset: () => void;
|
||||
snapshot: () => TerminalRenderProbeSnapshot;
|
||||
startSampling: (durationMs: number) => void;
|
||||
}
|
||||
|
||||
const win = window as unknown as Record<string, unknown> & {
|
||||
__terminalRenderProbe?: ProbeState;
|
||||
__paseoTerminal?: ProbeTerm;
|
||||
};
|
||||
const existingDescriptor = Object.getOwnPropertyDescriptor(win, "__paseoTerminal");
|
||||
const getExisting = () =>
|
||||
existingDescriptor?.get ? existingDescriptor.get.call(win) : existingDescriptor?.value;
|
||||
|
||||
const probe: ProbeState = {
|
||||
term: getExisting(),
|
||||
setCount: 0,
|
||||
unsetCount: 0,
|
||||
writeCount: 0,
|
||||
resetWrites: 0,
|
||||
clearScreenWrites: 0,
|
||||
altEnterWrites: 0,
|
||||
altExitWrites: 0,
|
||||
events: [],
|
||||
frames: [],
|
||||
rafId: null,
|
||||
sampleUntil: 0,
|
||||
reset() {
|
||||
this.setCount = 0;
|
||||
this.unsetCount = 0;
|
||||
this.writeCount = 0;
|
||||
this.resetWrites = 0;
|
||||
this.clearScreenWrites = 0;
|
||||
this.altEnterWrites = 0;
|
||||
this.altExitWrites = 0;
|
||||
this.events = [];
|
||||
this.frames = [];
|
||||
},
|
||||
snapshot() {
|
||||
return {
|
||||
setCount: this.setCount,
|
||||
unsetCount: this.unsetCount,
|
||||
writeCount: this.writeCount,
|
||||
resetWrites: this.resetWrites,
|
||||
clearScreenWrites: this.clearScreenWrites,
|
||||
altEnterWrites: this.altEnterWrites,
|
||||
altExitWrites: this.altExitWrites,
|
||||
events: this.events,
|
||||
frames: this.frames,
|
||||
};
|
||||
},
|
||||
startSampling(durationMs: number) {
|
||||
this.sampleUntil = performance.now() + durationMs;
|
||||
if (this.rafId !== null) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
}
|
||||
const sample = () => {
|
||||
const rows = Array.from(document.querySelectorAll(".xterm-rows > div")).map(
|
||||
(row) => row.textContent ?? "",
|
||||
);
|
||||
const nonEmptyRows = rows.filter((row) => row.trim().length > 0);
|
||||
const firstNonEmptyRow = rows.findIndex((row) => row.trim().length > 0);
|
||||
this.frames.push({
|
||||
at: performance.now(),
|
||||
rowCount: rows.length,
|
||||
nonEmptyRows: nonEmptyRows.length,
|
||||
firstNonEmptyRow: firstNonEmptyRow === -1 ? null : firstNonEmptyRow,
|
||||
text: rows.join("\n"),
|
||||
topText: rows.slice(0, 3).join("\n"),
|
||||
});
|
||||
if (performance.now() < this.sampleUntil) {
|
||||
this.rafId = requestAnimationFrame(sample);
|
||||
} else {
|
||||
this.rafId = null;
|
||||
}
|
||||
};
|
||||
this.rafId = requestAnimationFrame(sample);
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(win, "__terminalRenderProbe", {
|
||||
configurable: true,
|
||||
value: probe,
|
||||
});
|
||||
|
||||
Object.defineProperty(win, "__paseoTerminal", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return probe.term;
|
||||
},
|
||||
set(next: ProbeTerm | undefined) {
|
||||
if (next === undefined) {
|
||||
probe.unsetCount += 1;
|
||||
probe.events.push({ at: performance.now(), type: "unset" });
|
||||
probe.term = next;
|
||||
return;
|
||||
}
|
||||
|
||||
probe.setCount += 1;
|
||||
probe.events.push({ at: performance.now(), type: "set" });
|
||||
probe.term = next;
|
||||
|
||||
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);
|
||||
probe.writeCount += 1;
|
||||
const preview = text
|
||||
.replaceAll("\u001b", "\\x1b")
|
||||
.replace(/\r/g, "\\r")
|
||||
.replace(/\n/g, "\\n")
|
||||
.slice(0, 160);
|
||||
if (text.includes("\u001bc")) {
|
||||
probe.resetWrites += 1;
|
||||
probe.events.push({ at: performance.now(), type: "reset-write", preview });
|
||||
}
|
||||
if (text.includes("\u001b[2J")) {
|
||||
probe.clearScreenWrites += 1;
|
||||
probe.events.push({ at: performance.now(), type: "clear-write", preview });
|
||||
}
|
||||
if (text.includes("\u001b[?1049h")) {
|
||||
probe.altEnterWrites += 1;
|
||||
probe.events.push({ at: performance.now(), type: "alt-enter-write", preview });
|
||||
}
|
||||
if (text.includes("\u001b[?1049l")) {
|
||||
probe.altExitWrites += 1;
|
||||
probe.events.push({ at: performance.now(), type: "alt-exit-write", preview });
|
||||
}
|
||||
return originalWrite(data, callback);
|
||||
};
|
||||
next.__paseoRenderProbeWriteWrapped = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface TerminalRenderProbeWindow {
|
||||
__terminalRenderProbe: {
|
||||
reset: () => void;
|
||||
startSampling: (durationMs: number) => void;
|
||||
snapshot: () => TerminalRenderProbeSnapshot;
|
||||
};
|
||||
}
|
||||
|
||||
export async function resetTerminalRenderProbe(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as TerminalRenderProbeWindow).__terminalRenderProbe.reset();
|
||||
});
|
||||
}
|
||||
|
||||
export async function startTerminalFrameSampling(page: Page, durationMs = 2500): Promise<void> {
|
||||
await page.evaluate((ms) => {
|
||||
(window as unknown as TerminalRenderProbeWindow).__terminalRenderProbe.startSampling(ms);
|
||||
}, durationMs);
|
||||
}
|
||||
|
||||
export async function readTerminalRenderProbe(page: Page): Promise<TerminalRenderProbeSnapshot> {
|
||||
return page.evaluate(() =>
|
||||
(window as unknown as TerminalRenderProbeWindow).__terminalRenderProbe.snapshot(),
|
||||
);
|
||||
}
|
||||
|
||||
export async function terminalVisibleText(page: Page): Promise<string> {
|
||||
return page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll(".xterm-rows > div"))
|
||||
.map((row) => row.textContent ?? "")
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
export function summarizeTerminalRenderProbe(
|
||||
probe: TerminalRenderProbeSnapshot,
|
||||
): TerminalRenderProbeSummary {
|
||||
return {
|
||||
setCount: probe.setCount,
|
||||
unsetCount: probe.unsetCount,
|
||||
writeCount: probe.writeCount,
|
||||
resetWrites: probe.resetWrites,
|
||||
clearScreenWrites: probe.clearScreenWrites,
|
||||
altEnterWrites: probe.altEnterWrites,
|
||||
altExitWrites: probe.altExitWrites,
|
||||
events: probe.events,
|
||||
frameCount: probe.frames.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function installTerminalKeystrokeStressProbe(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
interface TimedTextEvent {
|
||||
at: number;
|
||||
text: string;
|
||||
bytes: number;
|
||||
}
|
||||
interface TimedTextMessageEvent {
|
||||
at: number;
|
||||
bytes: number;
|
||||
kind: string | null;
|
||||
}
|
||||
interface XtermWriteEvent {
|
||||
at: number;
|
||||
committedAt: number | null;
|
||||
text: string;
|
||||
bytes: number;
|
||||
}
|
||||
interface AppProbeEvent {
|
||||
type: string;
|
||||
at: number;
|
||||
bytes?: number;
|
||||
queueDepth?: number;
|
||||
}
|
||||
interface StressProbeState {
|
||||
keydowns: Array<{ at: number; key: string }>;
|
||||
inputFrames: TimedTextEvent[];
|
||||
outputFrames: TimedTextEvent[];
|
||||
textMessageFrames: TimedTextMessageEvent[];
|
||||
xtermWrites: XtermWriteEvent[];
|
||||
appEvents: AppProbeEvent[];
|
||||
reset: () => void;
|
||||
report: (inputText: string) => TerminalKeystrokeStressReport;
|
||||
}
|
||||
|
||||
const INPUT_OPCODE = 0x02;
|
||||
const OUTPUT_OPCODE = 0x01;
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
function bytesFrom(data: unknown): Uint8Array | null {
|
||||
if (data instanceof Uint8Array) return data;
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function eventDataBytes(data: unknown): Promise<Uint8Array | null> {
|
||||
const bytes = bytesFrom(data);
|
||||
if (bytes) return Promise.resolve(bytes);
|
||||
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
||||
return data.arrayBuffer().then((buffer) => new Uint8Array(buffer));
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
function frameText(bytes: Uint8Array): string {
|
||||
return decoder.decode(bytes.slice(2));
|
||||
}
|
||||
|
||||
function eventDataText(data: unknown): Promise<string | null> {
|
||||
if (typeof data === "string") {
|
||||
return Promise.resolve(data);
|
||||
}
|
||||
if (typeof Blob !== "undefined" && data instanceof Blob) {
|
||||
return data.text();
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
function textMessageKind(text: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as {
|
||||
type?: unknown;
|
||||
message?: {
|
||||
type?: unknown;
|
||||
};
|
||||
};
|
||||
if (typeof parsed.type !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (typeof parsed.message?.type === "string") {
|
||||
return `${parsed.type}:${parsed.message.type}`;
|
||||
}
|
||||
return parsed.type;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function summarize(values: number[]): LatencyStats | null {
|
||||
if (values.length === 0) return null;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const percentile = (p: number) => {
|
||||
const index = Math.ceil((p / 100) * sorted.length) - 1;
|
||||
return sorted[Math.max(0, Math.min(sorted.length - 1, index))];
|
||||
};
|
||||
const total = values.reduce((sum, value) => sum + value, 0);
|
||||
const round2 = (value: number) => Math.round(value * 100) / 100;
|
||||
return {
|
||||
count: values.length,
|
||||
minMs: round2(sorted[0] ?? 0),
|
||||
p50Ms: round2(percentile(50)),
|
||||
p95Ms: round2(percentile(95)),
|
||||
maxMs: round2(sorted[sorted.length - 1] ?? 0),
|
||||
avgMs: round2(total / values.length),
|
||||
};
|
||||
}
|
||||
|
||||
function firstAtOrAfter<T extends { at: number }>(events: T[], at: number): T | null {
|
||||
return events.find((event) => event.at >= at) ?? null;
|
||||
}
|
||||
|
||||
function firstCommitAtOrAfter(events: XtermWriteEvent[], at: number): XtermWriteEvent | null {
|
||||
return (
|
||||
events.find((event) => typeof event.committedAt === "number" && event.committedAt >= at) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function countByType(events: AppProbeEvent[]): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const event of events) {
|
||||
counts[event.type] = (counts[event.type] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function appEventsOf(type: string, events: AppProbeEvent[]): AppProbeEvent[] {
|
||||
return events.filter((event) => event.type === type);
|
||||
}
|
||||
|
||||
function latencyByIndex(from: AppProbeEvent[], to: AppProbeEvent[]): number[] {
|
||||
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);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
const probe: StressProbeState = {
|
||||
keydowns: [],
|
||||
inputFrames: [],
|
||||
outputFrames: [],
|
||||
textMessageFrames: [],
|
||||
xtermWrites: [],
|
||||
appEvents: [],
|
||||
reset() {
|
||||
this.keydowns = [];
|
||||
this.inputFrames = [];
|
||||
this.outputFrames = [];
|
||||
this.textMessageFrames = [];
|
||||
this.xtermWrites = [];
|
||||
this.appEvents = [];
|
||||
},
|
||||
report(inputText: string) {
|
||||
const binaryReceived = appEventsOf("daemon-client-binary-received", this.appEvents);
|
||||
const frameDecoded = appEventsOf("daemon-client-frame-decoded", this.appEvents);
|
||||
const terminalEmit = appEventsOf("daemon-client-terminal-emit", this.appEvents);
|
||||
const terminalEmitted = appEventsOf("daemon-client-terminal-emitted", this.appEvents);
|
||||
const streamControllerOutput = appEventsOf("stream-controller-output", this.appEvents);
|
||||
const streamControllerOnOutput = appEventsOf("stream-controller-on-output", this.appEvents);
|
||||
const emulatorWriteOutput = appEventsOf("terminal-emulator-write-output", this.appEvents);
|
||||
const runtimeWriteEnqueued = appEventsOf("runtime-write-enqueued", this.appEvents);
|
||||
const runtimeOperationStart = appEventsOf("runtime-operation-start", this.appEvents);
|
||||
const runtimeXtermWrite = appEventsOf("runtime-xterm-write", this.appEvents);
|
||||
const runtimeXtermCommitted = appEventsOf("runtime-xterm-committed", this.appEvents);
|
||||
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);
|
||||
const inputFrameToOutputFrame = this.inputFrames
|
||||
.map((input) => {
|
||||
const output = firstAtOrAfter(this.outputFrames, input.at);
|
||||
return output ? output.at - input.at : null;
|
||||
})
|
||||
.filter((value): value is number => value !== null);
|
||||
const outputFrameToXtermWrite = this.outputFrames
|
||||
.map((output) => {
|
||||
const write = firstAtOrAfter(this.xtermWrites, output.at);
|
||||
return write ? write.at - output.at : null;
|
||||
})
|
||||
.filter((value): value is number => value !== null);
|
||||
const xtermWriteDurations = this.xtermWrites
|
||||
.map((write) => (write.committedAt === null ? null : write.committedAt - write.at))
|
||||
.filter((value): value is number => value !== null);
|
||||
const keydownToXtermCommit = this.keydowns
|
||||
.map((keydown) => {
|
||||
const write = firstCommitAtOrAfter(this.xtermWrites, keydown.at);
|
||||
return write?.committedAt ? write.committedAt - keydown.at : null;
|
||||
})
|
||||
.filter((value): value is number => value !== null);
|
||||
|
||||
return {
|
||||
inputTextLength: inputText.length,
|
||||
keydownCount: this.keydowns.length,
|
||||
inputFrameCount: this.inputFrames.length,
|
||||
outputFrameCount: this.outputFrames.length,
|
||||
textMessageFrameCount: this.textMessageFrames.length,
|
||||
textMessagePayloadBytes: this.textMessageFrames.reduce(
|
||||
(sum, frame) => sum + frame.bytes,
|
||||
0,
|
||||
),
|
||||
largeTextMessageCount: this.textMessageFrames.filter((frame) => frame.bytes >= 50_000)
|
||||
.length,
|
||||
largestTextMessageBytes: Math.max(
|
||||
0,
|
||||
...this.textMessageFrames.map((frame) => frame.bytes),
|
||||
),
|
||||
agentStreamTextMessageCount: this.textMessageFrames.filter(
|
||||
(frame) => frame.kind === "session:agent_stream",
|
||||
).length,
|
||||
agentStreamTextMessagePayloadBytes: this.textMessageFrames
|
||||
.filter((frame) => frame.kind === "session:agent_stream")
|
||||
.reduce((sum, frame) => sum + frame.bytes, 0),
|
||||
largeAgentStreamTextMessageCount: this.textMessageFrames.filter(
|
||||
(frame) => frame.kind === "session:agent_stream" && frame.bytes >= 50_000,
|
||||
).length,
|
||||
largestAgentStreamTextMessageBytes: Math.max(
|
||||
0,
|
||||
...this.textMessageFrames
|
||||
.filter((frame) => frame.kind === "session:agent_stream")
|
||||
.map((frame) => frame.bytes),
|
||||
),
|
||||
appEventCount: this.appEvents.length,
|
||||
appEventCounts: countByType(this.appEvents),
|
||||
runtimeMaxQueueDepth: Math.max(
|
||||
0,
|
||||
...this.appEvents
|
||||
.map((event) => event.queueDepth)
|
||||
.filter((value): value is number => typeof value === "number"),
|
||||
),
|
||||
xtermWriteCount: this.xtermWrites.length,
|
||||
inputFramePayloadBytes: this.inputFrames.reduce((sum, frame) => sum + frame.bytes, 0),
|
||||
outputFramePayloadBytes: this.outputFrames.reduce((sum, frame) => sum + frame.bytes, 0),
|
||||
keydownToInputFrameMs: summarize(keydownToInputFrame),
|
||||
inputFrameToOutputFrameMs: summarize(inputFrameToOutputFrame),
|
||||
appBinaryReceivedToFrameDecodedMs: summarize(
|
||||
latencyByIndex(binaryReceived, frameDecoded),
|
||||
),
|
||||
appFrameDecodedToTerminalEmitMs: summarize(latencyByIndex(frameDecoded, terminalEmit)),
|
||||
appTerminalEmitListenerDurationMs: summarize(
|
||||
latencyByIndex(terminalEmit, terminalEmitted),
|
||||
),
|
||||
appTerminalEmitToStreamControllerOutputMs: summarize(
|
||||
latencyByIndex(terminalEmit, streamControllerOutput),
|
||||
),
|
||||
appStreamControllerDecodeToOnOutputMs: summarize(
|
||||
latencyByIndex(streamControllerOutput, streamControllerOnOutput),
|
||||
),
|
||||
appStreamControllerToEmulatorWriteMs: summarize(
|
||||
latencyByIndex(streamControllerOnOutput, emulatorWriteOutput),
|
||||
),
|
||||
appEmulatorWriteToRuntimeEnqueuedMs: summarize(
|
||||
latencyByIndex(emulatorWriteOutput, runtimeWriteEnqueued),
|
||||
),
|
||||
appRuntimeEnqueuedToOperationStartMs: summarize(
|
||||
latencyByIndex(runtimeWriteEnqueued, runtimeOperationStart),
|
||||
),
|
||||
appRuntimeOperationStartToXtermWriteMs: summarize(
|
||||
latencyByIndex(runtimeOperationStart, runtimeXtermWrite),
|
||||
),
|
||||
appRuntimeXtermWriteToCommitMs: summarize(
|
||||
latencyByIndex(runtimeXtermWrite, runtimeXtermCommitted),
|
||||
),
|
||||
appBinaryReceivedToRuntimeEnqueuedMs: summarize(
|
||||
latencyByIndex(binaryReceived, runtimeWriteEnqueued),
|
||||
),
|
||||
appBinaryReceivedToRuntimeOperationStartMs: summarize(
|
||||
latencyByIndex(binaryReceived, runtimeOperationStart),
|
||||
),
|
||||
appBinaryReceivedToXtermCommitMs: summarize(
|
||||
latencyByIndex(binaryReceived, runtimeXtermCommitted),
|
||||
),
|
||||
outputFrameToXtermWriteMs: summarize(outputFrameToXtermWrite),
|
||||
xtermWriteDurationMs: summarize(xtermWriteDurations),
|
||||
keydownToXtermCommitMs: summarize(keydownToXtermCommit),
|
||||
firstKeydownAt: this.keydowns[0]?.at ?? null,
|
||||
lastXtermCommitAt:
|
||||
this.xtermWrites
|
||||
.map((write) => write.committedAt)
|
||||
.findLast((at): at is number => typeof at === "number") ?? null,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(window, "__terminalKeystrokeStressProbe", {
|
||||
configurable: true,
|
||||
value: probe,
|
||||
});
|
||||
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (event.key.length === 1) {
|
||||
probe.keydowns.push({ at: performance.now(), key: event.key });
|
||||
}
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
const NativeWebSocket = window.WebSocket;
|
||||
class InstrumentedWebSocket extends NativeWebSocket {
|
||||
constructor(url: string | URL, protocols?: string | string[]) {
|
||||
if (protocols === undefined) {
|
||||
super(url);
|
||||
} else {
|
||||
super(url, protocols);
|
||||
}
|
||||
super.addEventListener("message", (event) => {
|
||||
void eventDataBytes(event.data).then((bytes) => {
|
||||
if (!bytes || bytes.byteLength < 2 || bytes[0] !== OUTPUT_OPCODE) {
|
||||
return;
|
||||
}
|
||||
probe.outputFrames.push({
|
||||
at: performance.now(),
|
||||
text: frameText(bytes),
|
||||
bytes: bytes.byteLength - 2,
|
||||
});
|
||||
return;
|
||||
});
|
||||
void eventDataText(event.data).then((text) => {
|
||||
if (text === null) {
|
||||
return;
|
||||
}
|
||||
probe.textMessageFrames.push({
|
||||
at: performance.now(),
|
||||
bytes: new TextEncoder().encode(text).byteLength,
|
||||
kind: textMessageKind(text),
|
||||
});
|
||||
return;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
send(data: Parameters<WebSocket["send"]>[0]): void {
|
||||
const bytes = bytesFrom(data);
|
||||
if (bytes && bytes.byteLength >= 2 && bytes[0] === INPUT_OPCODE) {
|
||||
probe.inputFrames.push({
|
||||
at: performance.now(),
|
||||
text: frameText(bytes),
|
||||
bytes: bytes.byteLength - 2,
|
||||
});
|
||||
}
|
||||
super.send(data);
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(InstrumentedWebSocket, "CONNECTING", {
|
||||
value: NativeWebSocket.CONNECTING,
|
||||
});
|
||||
Object.defineProperty(InstrumentedWebSocket, "OPEN", { value: NativeWebSocket.OPEN });
|
||||
Object.defineProperty(InstrumentedWebSocket, "CLOSING", { value: NativeWebSocket.CLOSING });
|
||||
Object.defineProperty(InstrumentedWebSocket, "CLOSED", { value: NativeWebSocket.CLOSED });
|
||||
window.WebSocket = InstrumentedWebSocket as typeof WebSocket;
|
||||
|
||||
const existingDescriptor = Object.getOwnPropertyDescriptor(window, "__paseoTerminal");
|
||||
const getExisting = () =>
|
||||
existingDescriptor?.get ? existingDescriptor.get.call(window) : existingDescriptor?.value;
|
||||
|
||||
let terminal = getExisting();
|
||||
Object.defineProperty(window, "__paseoTerminal", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return terminal;
|
||||
},
|
||||
set(next: {
|
||||
write?: (data: string | Uint8Array, callback?: () => void) => void;
|
||||
__paseoKeystrokeProbeWriteWrapped?: boolean;
|
||||
}) {
|
||||
terminal = next;
|
||||
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 event: XtermWriteEvent = {
|
||||
at: performance.now(),
|
||||
committedAt: null,
|
||||
text,
|
||||
bytes: text.length,
|
||||
};
|
||||
probe.xtermWrites.push(event);
|
||||
return originalWrite(data, () => {
|
||||
event.committedAt = performance.now();
|
||||
callback?.();
|
||||
});
|
||||
};
|
||||
next.__paseoKeystrokeProbeWriteWrapped = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface TerminalKeystrokeStressProbeWindow {
|
||||
__terminalKeystrokeStressProbe: {
|
||||
reset: () => void;
|
||||
report: (text: string) => TerminalKeystrokeStressReport;
|
||||
};
|
||||
}
|
||||
|
||||
export async function resetTerminalKeystrokeStressProbe(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
(
|
||||
window as unknown as TerminalKeystrokeStressProbeWindow
|
||||
).__terminalKeystrokeStressProbe.reset();
|
||||
});
|
||||
}
|
||||
|
||||
export async function readTerminalKeystrokeStressReport(
|
||||
page: Page,
|
||||
inputText: string,
|
||||
): Promise<TerminalKeystrokeStressReport> {
|
||||
return page.evaluate(
|
||||
(text) =>
|
||||
(
|
||||
window as unknown as TerminalKeystrokeStressProbeWindow
|
||||
).__terminalKeystrokeStressProbe.report(text),
|
||||
inputText,
|
||||
);
|
||||
}
|
||||
32
packages/app/e2e/helpers/workspace-lifecycle.ts
Normal file
32
packages/app/e2e/helpers/workspace-lifecycle.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { clickNewChat, clickTerminal } from "./launcher";
|
||||
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
|
||||
|
||||
function terminalSurface(page: Page) {
|
||||
return page.locator('[data-testid="terminal-surface"]').first();
|
||||
}
|
||||
|
||||
function composerInput(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
}
|
||||
|
||||
export async function expectTerminalCwd(page: Page, expectedPath: string): Promise<void> {
|
||||
const terminal = terminalSurface(page);
|
||||
await expect(terminal).toBeVisible({ timeout: 20_000 });
|
||||
await terminal.click();
|
||||
await setupDeterministicPrompt(page, `SENTINEL_${Date.now()}`);
|
||||
await terminal.pressSequentially("pwd\n", { delay: 0 });
|
||||
await waitForTerminalContent(page, (text) => text.includes(expectedPath), 10_000);
|
||||
}
|
||||
|
||||
export async function createStandaloneTerminalFromLauncher(page: Page): Promise<void> {
|
||||
await clickTerminal(page);
|
||||
await expect(terminalSurface(page)).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
export async function createAgentChatFromLauncher(page: Page): Promise<void> {
|
||||
await clickNewChat(page);
|
||||
await expect(composerInput(page)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
|
||||
await expect(page.getByTestId("agent-loading")).toHaveCount(0);
|
||||
}
|
||||
341
packages/app/e2e/helpers/workspace-setup.ts
Normal file
341
packages/app/e2e/helpers/workspace-setup.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { pathToFileURL } from "node:url";
|
||||
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 type { SessionOutboundMessage } from "@server/shared/messages";
|
||||
|
||||
interface WorkspaceSetupDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
openProject(cwd: string): Promise<{
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceDirectory: string;
|
||||
projectRootPath: string;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
createPaseoWorktree(input: { cwd: string; worktreeSlug?: string }): Promise<{
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceDirectory: string;
|
||||
projectRootPath: string;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
fetchWorkspaces(): Promise<{
|
||||
entries: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceDirectory: string;
|
||||
projectRootPath: string;
|
||||
}>;
|
||||
}>;
|
||||
fetchAgents(): Promise<{
|
||||
entries: Array<{
|
||||
agent: { id: string; cwd: string; workspaceId?: string | null };
|
||||
}>;
|
||||
}>;
|
||||
fetchAgent(agentId: string): Promise<{
|
||||
agent: { id: string; cwd: string } | null;
|
||||
project: unknown | null;
|
||||
} | null>;
|
||||
listTerminals(cwd: string): Promise<{
|
||||
cwd?: string;
|
||||
terminals: Array<{ id: string; cwd: string; name: string }>;
|
||||
error?: string | null;
|
||||
}>;
|
||||
subscribeRawMessages(handler: (message: SessionOutboundMessage) => void): () => void;
|
||||
}
|
||||
|
||||
export type WorkspaceSetupProgressPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_setup_progress" }
|
||||
>["payload"];
|
||||
|
||||
export type { WorkspaceSetupDaemonClient };
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
return `ws://127.0.0.1:${daemonPort}/ws`;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}) => WorkspaceSetupDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}) => WorkspaceSetupDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `workspace-setup-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
webSocketFactory,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function seedProjectForWorkspaceSetup(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
repoPath: string,
|
||||
): Promise<void> {
|
||||
const result = await client.openProject(repoPath);
|
||||
if (!result.workspace || result.error) {
|
||||
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function projectNameFromPath(repoPath: string): string {
|
||||
return repoPath.replace(/\/+$/, "").split("/").findLast(Boolean) ?? repoPath;
|
||||
}
|
||||
|
||||
export async function openHomeWithProject(page: Page, repoPath: string): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: projectNameFromPath(repoPath) })
|
||||
.first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
function createWorkspaceButton(page: Page, repoPath: string) {
|
||||
return page.getByRole("button", {
|
||||
name: `Create a new workspace for ${projectNameFromPath(repoPath)}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function revealWorkspaceButton(page: Page, repoPath: string): Promise<void> {
|
||||
await page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: projectNameFromPath(repoPath) })
|
||||
.first()
|
||||
.hover();
|
||||
}
|
||||
|
||||
export async function createWorkspaceFromSidebar(page: Page, repoPath: string): Promise<void> {
|
||||
const button = createWorkspaceButton(page, repoPath);
|
||||
await revealWorkspaceButton(page, repoPath);
|
||||
await expect(button).toBeVisible({ timeout: 30_000 });
|
||||
await expect(button).toBeEnabled({ timeout: 30_000 });
|
||||
await button.click();
|
||||
await expect(page).toHaveURL(/\/new\?/, { timeout: 30_000 });
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCurrentWorkspaceIdFromRoute(page: Page): Promise<string> {
|
||||
await expect
|
||||
.poll(
|
||||
() => parseHostWorkspaceRouteFromPathname(new URL(page.url()).pathname)?.workspaceId ?? null,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
|
||||
const workspaceId =
|
||||
parseHostWorkspaceRouteFromPathname(new URL(page.url()).pathname)?.workspaceId ?? null;
|
||||
if (!workspaceId) {
|
||||
throw new Error(`Expected a workspace route but found ${page.url()}`);
|
||||
}
|
||||
|
||||
return workspaceId;
|
||||
}
|
||||
|
||||
function workspaceSetupDialog(page: Page) {
|
||||
return page.getByTestId("workspace-setup-dialog");
|
||||
}
|
||||
|
||||
export async function createChatAgentFromWorkspaceSetup(
|
||||
page: Page,
|
||||
input: { message: string },
|
||||
): Promise<void> {
|
||||
const messageInput = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await expect(messageInput).toBeVisible({ timeout: 15_000 });
|
||||
await messageInput.fill(input.message);
|
||||
await messageInput.press("Enter");
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated The new workspace screen no longer has a standalone terminal button.
|
||||
* Use the daemon API to create a workspace, then open a terminal from the launcher.
|
||||
*/
|
||||
export async function createStandaloneTerminalFromWorkspaceSetup(page: Page): Promise<void> {
|
||||
await workspaceSetupDialog(page)
|
||||
.getByRole("button", { name: /^Terminal Create the workspace/i })
|
||||
.click();
|
||||
}
|
||||
|
||||
export async function waitForWorkspaceSetupDialogToClose(
|
||||
page: Page,
|
||||
timeoutMs = 45_000,
|
||||
): Promise<void> {
|
||||
const dialog = workspaceSetupDialog(page);
|
||||
|
||||
try {
|
||||
await expect(dialog).toHaveCount(0, { timeout: timeoutMs });
|
||||
} catch (error) {
|
||||
const dialogText = (await dialog.textContent().catch(() => null))?.replace(/\s+/g, " ").trim();
|
||||
throw new Error(
|
||||
dialogText
|
||||
? `Workspace setup dialog stayed open. Visible text: ${dialogText}`
|
||||
: `Workspace setup dialog did not close within ${timeoutMs}ms`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectSetupPanel(page: Page): Promise<void> {
|
||||
// If the setup panel is already visible (auto-opened), we're done.
|
||||
const panel = page.getByTestId("workspace-setup-panel");
|
||||
if (await panel.isVisible().catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
// Otherwise open it manually via workspace header actions menu.
|
||||
// Use the specific testID to avoid matching the sidebar kebab which shares
|
||||
// the same "Workspace actions" accessibility label.
|
||||
const actionsButton = page.getByTestId("workspace-header-menu-trigger");
|
||||
await expect(actionsButton).toBeVisible({ timeout: 10_000 });
|
||||
await actionsButton.click();
|
||||
const showSetup = page.getByTestId("workspace-header-show-setup");
|
||||
await expect(showSetup).toBeVisible({ timeout: 5_000 });
|
||||
await showSetup.click();
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectSetupStatus(
|
||||
page: Page,
|
||||
status: "Running" | "Completed" | "Failed",
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-setup-status")).toContainText(status, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectSetupLogContains(page: Page, text: string): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-setup-log")).toContainText(text, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectNoSetupMessage(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByText("No setup commands ran for this workspace.", { exact: true }),
|
||||
).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createWorkspaceThroughDaemon(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
input: { cwd: string; worktreeSlug: string },
|
||||
): Promise<{ id: string; name: string }> {
|
||||
const result = await client.createPaseoWorktree(input);
|
||||
if (!result.workspace || result.error) {
|
||||
throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`);
|
||||
}
|
||||
return {
|
||||
id: String(result.workspace.id),
|
||||
name: result.workspace.name,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findWorktreeWorkspaceForProject(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
repoPath: string,
|
||||
): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
projectRootPath: string;
|
||||
workspaceDirectory: string;
|
||||
}> {
|
||||
const payload = await client.fetchWorkspaces();
|
||||
const normalizedRepoPath = realpathSync(repoPath);
|
||||
const workspace =
|
||||
payload.entries.find(
|
||||
(entry) =>
|
||||
entry.projectRootPath === normalizedRepoPath &&
|
||||
entry.workspaceDirectory !== normalizedRepoPath,
|
||||
) ?? null;
|
||||
if (!workspace) {
|
||||
throw new Error(`Failed to find created worktree workspace for ${repoPath}`);
|
||||
}
|
||||
return {
|
||||
id: String(workspace.id),
|
||||
name: workspace.name,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchWorkspaceById(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceDirectory: string;
|
||||
projectRootPath: string;
|
||||
}> {
|
||||
const payload = await client.fetchWorkspaces();
|
||||
const workspace = payload.entries.find((entry) => String(entry.id) === workspaceId) ?? null;
|
||||
if (!workspace) {
|
||||
throw new Error(`Workspace not found: ${workspaceId}`);
|
||||
}
|
||||
return workspace;
|
||||
}
|
||||
|
||||
export async function waitForWorkspaceSetupProgress(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
predicate: (payload: WorkspaceSetupProgressPayload) => boolean,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<WorkspaceSetupProgressPayload> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error(`Timed out waiting for workspace_setup_progress after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
const unsubscribe = client.subscribeRawMessages((message) => {
|
||||
if (message.type !== "workspace_setup_progress") {
|
||||
return;
|
||||
}
|
||||
if (!predicate(message.payload)) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve(message.payload);
|
||||
});
|
||||
});
|
||||
}
|
||||
86
packages/app/e2e/helpers/workspace-tabs.ts
Normal file
86
packages/app/e2e/helpers/workspace-tabs.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export async function getWorkspaceTabTestIds(page: Page): Promise<string[]> {
|
||||
const tabs = page.locator('[data-testid^="workspace-tab-"]');
|
||||
const count = await tabs.count();
|
||||
const ids: string[] = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const testId = await tabs.nth(index).getAttribute("data-testid");
|
||||
if (testId && !ids.includes(testId)) {
|
||||
ids.push(testId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function visibleTestId(page: Page, testId: string) {
|
||||
return page.getByTestId(testId).filter({ visible: true });
|
||||
}
|
||||
|
||||
export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
||||
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(visibleTestId(page, "workspace-new-agent-tab").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getVisibleWorkspaceAgentTabIds(page: Page): Promise<string[]> {
|
||||
const tabs = page.locator('[data-testid^="workspace-tab-agent_"]').filter({ visible: true });
|
||||
const count = await tabs.count();
|
||||
const ids: string[] = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const testId = await tabs.nth(index).getAttribute("data-testid");
|
||||
if (testId && !ids.includes(testId)) {
|
||||
ids.push(testId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function expectOnlyWorkspaceAgentTabsVisible(
|
||||
page: Page,
|
||||
expectedAgentIds: string[],
|
||||
): Promise<void> {
|
||||
const expected = new Set(expectedAgentIds.map((id) => `workspace-tab-agent_${id}`));
|
||||
const visible = await getVisibleWorkspaceAgentTabIds(page);
|
||||
const unexpected = visible.filter((id) => !expected.has(id));
|
||||
|
||||
expect(unexpected).toEqual([]);
|
||||
expect(visible.length).toBe(expected.size);
|
||||
for (const expectedId of expectedAgentIds) {
|
||||
await expect(visibleTestId(page, `workspace-tab-agent_${expectedId}`).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise<void> {
|
||||
const toggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
if (!(await toggle.isVisible().catch(() => false))) {
|
||||
return;
|
||||
}
|
||||
const isExpanded = (await toggle.getAttribute("aria-expanded")) === "true";
|
||||
if (isExpanded) {
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false", {
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function sampleWorkspaceTabIds(
|
||||
page: Page,
|
||||
options: { durationMs?: number; intervalMs?: number } = {},
|
||||
): Promise<string[][]> {
|
||||
const durationMs = options.durationMs ?? 2_500;
|
||||
const intervalMs = options.intervalMs ?? 50;
|
||||
const snapshots: string[][] = [];
|
||||
const start = Date.now();
|
||||
while (Date.now() - start <= durationMs) {
|
||||
snapshots.push(await getWorkspaceTabTestIds(page));
|
||||
await page.waitForTimeout(intervalMs);
|
||||
}
|
||||
return snapshots;
|
||||
}
|
||||
123
packages/app/e2e/helpers/workspace-ui.ts
Normal file
123
packages/app/e2e/helpers/workspace-ui.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { gotoHome } from "./app";
|
||||
|
||||
export async function openNewAgentComposer(page: Page): Promise<void> {
|
||||
await gotoHome(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the sidebar to show at least one project row, indicating that the
|
||||
* WebSocket connection is up and workspace hydration has completed.
|
||||
*/
|
||||
export async function waitForSidebarHydration(page: Page, timeout = 60_000): Promise<void> {
|
||||
await page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.first()
|
||||
.waitFor({ state: "visible", timeout });
|
||||
}
|
||||
|
||||
export function workspaceLabelFromPath(value: string): string {
|
||||
const normalized = value.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
return parts[parts.length - 1] ?? normalized;
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function candidateWorkspaceIds(inputPath: string): string[] {
|
||||
const trimmed = inputPath.replace(/\/+$/, "");
|
||||
const candidates = new Set<string>([trimmed]);
|
||||
if (trimmed.startsWith("/var/")) {
|
||||
candidates.add(`/private${trimmed}`);
|
||||
}
|
||||
if (trimmed.startsWith("/private/var/")) {
|
||||
candidates.add(trimmed.replace(/^\/private/, ""));
|
||||
}
|
||||
return Array.from(candidates);
|
||||
}
|
||||
|
||||
function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) {
|
||||
const ids = candidateWorkspaceIds(workspacePath).map(
|
||||
(id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`,
|
||||
);
|
||||
return page.locator(ids.join(",")).first();
|
||||
}
|
||||
|
||||
export async function expectSidebarWorkspaceSelected(input: {
|
||||
page: Page;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
selected?: boolean;
|
||||
}): Promise<void> {
|
||||
const row = workspaceRowLocator(input.page, input.serverId, input.workspaceId);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
const expected = input.selected === false ? "false" : "true";
|
||||
|
||||
const hasDataSelected = await row.getAttribute("data-selected");
|
||||
if (hasDataSelected !== null) {
|
||||
await expect(row).toHaveAttribute("data-selected", expected, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(row).toHaveAttribute("aria-selected", expected, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function switchWorkspaceViaSidebar(input: {
|
||||
page: Page;
|
||||
serverId: string;
|
||||
targetWorkspacePath: string;
|
||||
}): Promise<void> {
|
||||
const row = workspaceRowLocator(input.page, input.serverId, input.targetWorkspacePath);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
|
||||
const targetWorkspaceRoute = buildHostWorkspaceRoute(input.serverId, input.targetWorkspacePath);
|
||||
await expect(input.page).toHaveURL(new RegExp(escapeRegex(targetWorkspaceRoute)), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a workspace's sidebar row to appear, confirming the workspace
|
||||
* descriptor has been hydrated into the session store.
|
||||
*/
|
||||
export async function waitForWorkspaceInSidebar(
|
||||
page: Page,
|
||||
input: { serverId: string; workspaceId: string },
|
||||
): Promise<void> {
|
||||
const candidates = candidateWorkspaceIds(input.workspaceId);
|
||||
const selector = candidates
|
||||
.map((id) => `[data-testid="sidebar-workspace-row-${input.serverId}:${id}"]`)
|
||||
.join(",");
|
||||
await page.locator(selector).first().waitFor({ state: "visible", timeout: 60_000 });
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHeader(
|
||||
page: Page,
|
||||
input: { title: string; subtitle: string },
|
||||
): Promise<void> {
|
||||
const titleLocator = page.getByTestId("workspace-header-title").filter({ visible: true });
|
||||
const subtitleLocator = page.getByTestId("workspace-header-subtitle").filter({ visible: true });
|
||||
|
||||
await expect(titleLocator.first()).toHaveText(input.title, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(subtitleLocator.first()).toHaveText(input.subtitle, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
await input.fill(marker);
|
||||
await input.press("Enter");
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
}
|
||||
@@ -1,40 +1,120 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtemp, writeFile, rm, mkdir, realpath } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
type TempRepo = {
|
||||
interface TempRepo {
|
||||
path: string;
|
||||
branchHeads: Record<string, string>;
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
export const createTempGitRepo = async (
|
||||
prefix = 'paseo-e2e-',
|
||||
options?: { withRemote?: boolean }
|
||||
prefix = "paseo-e2e-",
|
||||
options?: {
|
||||
withRemote?: boolean;
|
||||
paseoConfig?: Record<string, unknown>;
|
||||
files?: Array<{ path: string; content: string }>;
|
||||
branches?: string[];
|
||||
},
|
||||
): Promise<TempRepo> => {
|
||||
const repoPath = await mkdtemp(path.join(tmpdir(), prefix));
|
||||
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
||||
// Resolve symlinks (macOS: /tmp → /private/tmp) so paths match the daemon's resolved paths.
|
||||
const tempRoot = process.platform === "win32" ? tmpdir() : await realpath("/tmp");
|
||||
const repoPath = await mkdtemp(path.join(tempRoot, prefix));
|
||||
const withRemote = options?.withRemote ?? false;
|
||||
|
||||
execSync('git init -b main', { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: 'ignore' });
|
||||
await writeFile(path.join(repoPath, 'README.md'), '# Temp Repo\n');
|
||||
execSync('git add README.md', { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: 'ignore' });
|
||||
execSync("git init -b main", { cwd: repoPath, stdio: "ignore" });
|
||||
execSync('git config user.email "e2e@paseo.test"', { cwd: repoPath, stdio: "ignore" });
|
||||
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
|
||||
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
|
||||
if (options?.paseoConfig) {
|
||||
await writeFile(
|
||||
path.join(repoPath, "paseo.json"),
|
||||
JSON.stringify(options.paseoConfig, null, 2),
|
||||
);
|
||||
}
|
||||
for (const file of options?.files ?? []) {
|
||||
const filePath = path.join(repoPath, file.path);
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, file.content);
|
||||
}
|
||||
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
|
||||
if (options?.paseoConfig) {
|
||||
execSync("git add paseo.json", { cwd: repoPath, stdio: "ignore" });
|
||||
}
|
||||
for (const file of options?.files ?? []) {
|
||||
execSync(`git add ${JSON.stringify(file.path)}`, { cwd: repoPath, stdio: "ignore" });
|
||||
}
|
||||
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
|
||||
|
||||
const branchHeads: Record<string, string> = {};
|
||||
const branches = Array.from(new Set(options?.branches ?? []));
|
||||
for (const branch of branches) {
|
||||
if (branch !== "main") {
|
||||
execSync(`git checkout -b ${JSON.stringify(branch)} main`, {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
const markerPath = `.paseo-e2e-${branch.replace(/[^a-zA-Z0-9._-]/g, "-")}.txt`;
|
||||
await writeFile(path.join(repoPath, markerPath), `branch ${branch}\n`);
|
||||
execSync(`git add ${JSON.stringify(markerPath)}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync(`git commit -m ${JSON.stringify(`Add ${branch} marker`)}`, {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
branchHeads[branch] = execSync(`git rev-parse ${JSON.stringify(branch)}`, {
|
||||
cwd: repoPath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
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');
|
||||
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 main', { cwd: repoPath, stdio: 'ignore' });
|
||||
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 {
|
||||
path: repoPath,
|
||||
branchHeads,
|
||||
cleanup: async () => {
|
||||
await rm(repoPath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{
|
||||
currentBranch: string;
|
||||
hasAncestor: (ref: string) => boolean;
|
||||
}> {
|
||||
const currentBranch = execSync("git branch --show-current", {
|
||||
cwd: worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
return {
|
||||
currentBranch,
|
||||
hasAncestor: (ref: string) => {
|
||||
try {
|
||||
execSync(`git merge-base --is-ancestor ${JSON.stringify(ref)} HEAD`, {
|
||||
cwd: worktreePath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('no hosts shows welcome; direct connection adds host and lands on agent create', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
||||
localStorage.removeItem('@paseo:create-agent-preferences');
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByText('Welcome to Paseo', { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByText('Direct connection', { exact: true }).click();
|
||||
|
||||
await page.getByPlaceholder('host:6767').fill(`127.0.0.1:${daemonPort}`);
|
||||
|
||||
await page.getByText('Connect', { exact: true }).click();
|
||||
|
||||
// First-time connection prompts for an optional label.
|
||||
const nameModal = page.getByTestId('name-host-modal');
|
||||
const showedNameModal = await nameModal
|
||||
.waitFor({ state: 'visible', timeout: 5000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (showedNameModal) {
|
||||
await nameModal.getByTestId('name-host-skip').click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId('sidebar-new-agent')).toBeVisible();
|
||||
await expect(page.getByText(serverId, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Online', { exact: true })).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
@@ -1,109 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test('host removal removes the host from UI and persists after reload', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const seededServerId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!seededServerId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
const extraPort = Number(daemonPort) + 1;
|
||||
const extraEndpoint = `127.0.0.1:${extraPort}`;
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
const extraDaemon = {
|
||||
serverId: 'srv_e2e_extra_daemon',
|
||||
label: 'extra',
|
||||
connections: [
|
||||
{ id: `direct:${extraEndpoint}`, type: 'direct', endpoint: extraEndpoint },
|
||||
],
|
||||
preferredConnectionId: `direct:${extraEndpoint}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
const seededTestDaemon = {
|
||||
serverId: seededServerId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{ id: `direct:127.0.0.1:${daemonPort}`, type: 'direct', endpoint: `127.0.0.1:${daemonPort}` },
|
||||
],
|
||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
const seedOnceKey = `@paseo:e2e-host-removal-seeded:${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
// Add a second host once (fixtures seed the primary host on every navigation).
|
||||
await page.addInitScript(
|
||||
({ daemon, seededTestDaemon, seedOnceKey }) => {
|
||||
if (localStorage.getItem(seedOnceKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||
let parsed: any[] = [];
|
||||
if (raw) {
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = [];
|
||||
}
|
||||
}
|
||||
const list = Array.isArray(parsed) ? parsed : [];
|
||||
localStorage.setItem(seedOnceKey, '1');
|
||||
|
||||
const next = [...list];
|
||||
const hasSeeded = next.some((entry: any) => entry && entry.serverId === seededTestDaemon.serverId);
|
||||
if (!hasSeeded) {
|
||||
next.push(seededTestDaemon);
|
||||
}
|
||||
|
||||
const alreadyPresent = next.some((entry: any) => entry && entry.serverId === daemon.serverId);
|
||||
if (!alreadyPresent) {
|
||||
next.push(daemon);
|
||||
}
|
||||
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify(next));
|
||||
},
|
||||
{ daemon: extraDaemon, seededTestDaemon, seedOnceKey }
|
||||
);
|
||||
|
||||
await page.goto('/settings');
|
||||
|
||||
await expect(page.getByText('extra', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText(extraEndpoint, { exact: true }).first()).toBeVisible();
|
||||
|
||||
await page.getByTestId(`daemon-menu-trigger-${extraDaemon.serverId}`).click();
|
||||
await page.getByTestId(`daemon-menu-remove-${extraDaemon.serverId}`).click();
|
||||
await expect(page.getByTestId('remove-host-confirm-modal')).toBeVisible();
|
||||
await page.getByTestId('remove-host-confirm').click();
|
||||
|
||||
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
|
||||
await page.waitForFunction(
|
||||
(serverId) => {
|
||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||
if (!raw) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) && !parsed.some((entry: any) => entry && entry.serverId === serverId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
extraDaemon.serverId,
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
// Prevent the fixture from overwriting storage on reload; verify persistence.
|
||||
await page.evaluate(() => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
});
|
||||
await page.reload();
|
||||
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { ensureHostSelected, gotoHome } from './helpers/app';
|
||||
|
||||
test('new agent auto-selects the previous host', async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
await ensureHostSelected(page);
|
||||
|
||||
await gotoHome(page);
|
||||
|
||||
// The selected host should be restored after a full reload without manual selection.
|
||||
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable({ timeout: 30000 });
|
||||
});
|
||||
|
||||
test('new agent respects serverId in the URL', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
// Ensure this test's storage is deterministic even under parallel load.
|
||||
const nowIso = new Date().toISOString();
|
||||
const testDaemon = {
|
||||
serverId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{
|
||||
id: `direct:127.0.0.1:${daemonPort}`,
|
||||
type: 'direct',
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
const createAgentPreferences = {
|
||||
serverId: testDaemon.serverId,
|
||||
provider: 'claude',
|
||||
providerPreferences: {
|
||||
claude: { model: 'haiku' },
|
||||
codex: { model: 'gpt-5.1-codex-mini' },
|
||||
},
|
||||
};
|
||||
|
||||
await page.goto('/settings');
|
||||
await page.evaluate(
|
||||
({ daemon, preferences }) => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
},
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences }
|
||||
);
|
||||
await page.reload();
|
||||
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await page.goto(`/?serverId=${encodeURIComponent(serverId)}`);
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable({ timeout: 30000 });
|
||||
});
|
||||
|
||||
test('new agent auto-selects first online host when no preference is stored', async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!daemonPort) {
|
||||
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
|
||||
}
|
||||
if (!serverId) {
|
||||
throw new Error('E2E_SERVER_ID is not set (expected from globalSetup).');
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const testDaemon = {
|
||||
serverId,
|
||||
label: 'localhost',
|
||||
connections: [
|
||||
{
|
||||
id: `direct:127.0.0.1:${daemonPort}`,
|
||||
type: 'direct',
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
},
|
||||
],
|
||||
preferredConnectionId: `direct:127.0.0.1:${daemonPort}`,
|
||||
createdAt: nowIso,
|
||||
updatedAt: nowIso,
|
||||
};
|
||||
|
||||
await page.goto('/');
|
||||
await page.evaluate(
|
||||
({ daemon }) => {
|
||||
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
|
||||
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
|
||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||
localStorage.removeItem('@paseo:create-agent-preferences');
|
||||
localStorage.removeItem('@paseo:settings');
|
||||
},
|
||||
{ daemon: testDaemon }
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Host should be auto-selected (no manual selection required).
|
||||
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable({ timeout: 30000 });
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoHome } from "./helpers/app";
|
||||
|
||||
test("question mark opens keyboard shortcuts dialog", async ({ page }) => {
|
||||
await gotoHome(page);
|
||||
await page.getByTestId("menu-button").first().focus();
|
||||
|
||||
await page.keyboard.press("Shift+/");
|
||||
|
||||
const dialog = page.getByTestId("keyboard-shortcuts-dialog");
|
||||
const content = page.getByTestId("keyboard-shortcuts-dialog-content");
|
||||
|
||||
await expect(dialog).toBeVisible({ timeout: 10000 });
|
||||
await expect(content).toBeVisible({ timeout: 10000 });
|
||||
await expect(content).toContainText("Show keyboard shortcuts");
|
||||
await expect(content).toContainText("Toggle left sidebar");
|
||||
});
|
||||
257
packages/app/e2e/launcher-tab.spec.ts
Normal file
257
packages/app/e2e/launcher-tab.spec.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
gotoWorkspace,
|
||||
assertNewChatTileVisible,
|
||||
assertTerminalTileVisible,
|
||||
assertSingleNewTabButton,
|
||||
clickNewTabButton,
|
||||
pressNewTabShortcut,
|
||||
clickNewChat,
|
||||
clickTerminal,
|
||||
countTabsOfKind,
|
||||
getTabTestIds,
|
||||
waitForTabWithTitle,
|
||||
measureTileTransition,
|
||||
sampleTabsDuringTransition,
|
||||
} from "./helpers/launcher";
|
||||
import {
|
||||
connectTerminalClient,
|
||||
setupDeterministicPrompt,
|
||||
type TerminalPerfDaemonClient,
|
||||
} from "./helpers/terminal-perf";
|
||||
|
||||
// ─── Shared state ──────────────────────────────────────────────────────────
|
||||
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
let workspaceId: string;
|
||||
let seedClient: TerminalPerfDaemonClient;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("launcher-e2e-");
|
||||
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);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (seedClient) await seedClient.close();
|
||||
if (tempRepo) await tempRepo.cleanup();
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Tab Creation Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe("Tab creation", () => {
|
||||
test("Cmd+T opens a new agent tab with composer", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
test("opening two new tabs creates two draft tabs", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
const countBefore = await countTabsOfKind(page, "draft");
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
await expect
|
||||
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
|
||||
.toBe(countBefore + 1);
|
||||
const countAfterFirst = await countTabsOfKind(page, "draft");
|
||||
|
||||
// Blur the composer so the second shortcut isn't swallowed by the focused input
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement)?.blur?.());
|
||||
await pressNewTabShortcut(page);
|
||||
await expect
|
||||
.poll(() => countTabsOfKind(page, "draft"), { timeout: 15_000 })
|
||||
.toBe(countAfterFirst + 1);
|
||||
});
|
||||
|
||||
test("clicking new agent tab creates a draft tab", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
await clickNewTabButton(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 });
|
||||
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
|
||||
expect(draftCountAfter).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("clicking terminal button creates a standalone terminal", async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
await clickTerminal(page);
|
||||
|
||||
// Terminal surface should appear
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
|
||||
expect(terminalTabs.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("tab bar shows action buttons per pane", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await assertSingleNewTabButton(page);
|
||||
await assertNewChatTileVisible(page);
|
||||
await assertTerminalTileVisible(page);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Terminal Title Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe("Terminal title propagation", () => {
|
||||
// OSC title escape sequence propagation is inherently flaky — the terminal
|
||||
// must process the sequence, emit a title change event, and the tab bar
|
||||
// must re-render before the assertion deadline. Allow retries.
|
||||
test.describe.configure({ retries: 2 });
|
||||
|
||||
let client: TerminalPerfDaemonClient;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
client = await connectTerminalClient();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (client) await client.close();
|
||||
});
|
||||
|
||||
test.skip("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const result = await client.createTerminal(tempRepo.path, "title-test");
|
||||
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
try {
|
||||
// Navigate to workspace and open a terminal
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickTerminal(page);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
await terminal.first().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 });
|
||||
|
||||
// Wait for the tab to reflect the new title
|
||||
await waitForTabWithTitle(page, testTitle, 15_000);
|
||||
} finally {
|
||||
await client.killTerminal(terminalId).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test.skip("title debouncing coalesces rapid changes", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const result = await client.createTerminal(tempRepo.path, "debounce-test");
|
||||
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickTerminal(page);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
await terminal.first().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 terminal
|
||||
.first()
|
||||
.pressSequentially(`printf '\\033]0;${finalTitle}\\007'\n`, { delay: 0 });
|
||||
|
||||
// The tab should eventually settle on the final title
|
||||
await waitForTabWithTitle(page, finalTitle, 15_000);
|
||||
} finally {
|
||||
await client.killTerminal(terminalId).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// No-Flash Transition Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe("Tab transitions (no flash)", () => {
|
||||
test("New agent tab transition has no blank intermediate tab state", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
// Sample tabs at high frequency across the transition
|
||||
const snapshots = await sampleTabsDuringTransition(page, () => clickNewChat(page), 2_000, 30);
|
||||
|
||||
// Every snapshot should have at least one tab — no blank/zero-tab frames
|
||||
for (const snapshot of snapshots) {
|
||||
expect(snapshot.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
// Tab count should never spike excessively (no duplicate flash from add-then-remove).
|
||||
// When running in-suite, previous tests may have created tabs on the shared workspace,
|
||||
// so we allow +2 tolerance for accumulated state and React render batching.
|
||||
const counts = snapshots.map((s) => s.length);
|
||||
const maxCount = Math.max(...counts);
|
||||
const initialCount = counts[0] ?? 0;
|
||||
|
||||
expect(maxCount).toBeLessThanOrEqual(initialCount + 2);
|
||||
});
|
||||
|
||||
test("Terminal transition completes within visual budget", async ({ page }) => {
|
||||
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(),
|
||||
20_000,
|
||||
);
|
||||
|
||||
// Terminal surface should appear within a reasonable budget.
|
||||
// Note: terminal creation involves a server round-trip, so we allow more time
|
||||
// than a pure in-memory transition, but it should still be well under 5 seconds.
|
||||
expect(elapsed).toBeLessThan(5_000);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// Draft creation is fully in-memory — should be fast
|
||||
// We use a generous budget here because CI can be slow, but the key assertion
|
||||
// is that no blank/flash frame appears (tested above).
|
||||
expect(elapsed).toBeLessThan(3_000);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user