mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
691 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
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 | ||
|
|
2dd597f5f5 | ||
|
|
fcfb9c99da | ||
|
|
5e1b24b217 | ||
|
|
2ca7dd71e2 | ||
|
|
449a4b48d2 | ||
|
|
5d5a79a2a8 | ||
|
|
59bc3bd3d1 | ||
|
|
86cee8e43a | ||
|
|
c4c17df022 | ||
|
|
34c27fe77e | ||
|
|
b730d83943 | ||
|
|
f1ebe518f8 | ||
|
|
68c8efc5d3 | ||
|
|
73e6a42141 | ||
|
|
060f3d457c | ||
|
|
255270b5ee | ||
|
|
59cb4d7499 | ||
|
|
d75dd33773 | ||
|
|
f1ffecfcd4 | ||
|
|
a7d5ab2497 | ||
|
|
d8243bfb72 | ||
|
|
5b3ec572cb | ||
|
|
806079c7ad | ||
|
|
1f6ffa9e0e | ||
|
|
266d042528 | ||
|
|
e026223c80 | ||
|
|
2190910e6f | ||
|
|
0d3f59feed | ||
|
|
edcd2fe5d5 | ||
|
|
7d02e24d84 |
2
.github/workflows/android-apk-release.yml
vendored
2
.github/workflows/android-apk-release.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
release create "$RELEASE_TAG"
|
||||
--repo "${{ github.repository }}"
|
||||
--title "Paseo $RELEASE_TAG"
|
||||
--generate-notes
|
||||
--notes ""
|
||||
)
|
||||
|
||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||
|
||||
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"
|
||||
16
.github/workflows/deploy-app.yml
vendored
16
.github/workflows/deploy-app.yml
vendored
@@ -3,10 +3,10 @@ name: Deploy App
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '!v*-rc.*'
|
||||
- 'app-v*'
|
||||
- '!app-v*-rc.*'
|
||||
- "v*"
|
||||
- "!v*-beta.*"
|
||||
- "app-v*"
|
||||
- "!app-v*-beta.*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -18,10 +18,10 @@ 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
|
||||
|
||||
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 }}
|
||||
|
||||
|
||||
17
.github/workflows/deploy-website.yml
vendored
17
.github/workflows/deploy-website.yml
vendored
@@ -4,13 +4,14 @@ 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, edited]
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -23,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
|
||||
|
||||
325
.github/workflows/desktop-release.yml
vendored
325
.github/workflows/desktop-release.yml
vendored
@@ -3,27 +3,45 @@ name: Desktop Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- 'desktop-v*'
|
||||
- 'desktop-macos-v*'
|
||||
- 'desktop-linux-v*'
|
||||
- 'desktop-windows-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.'
|
||||
description: "Optional desktop platform to build."
|
||||
required: false
|
||||
default: 'all'
|
||||
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 }}
|
||||
@@ -31,12 +49,52 @@ concurrency:
|
||||
|
||||
env:
|
||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
DESKTOP_WORKSPACE: '@getpaseo/desktop'
|
||||
DESKTOP_PACKAGE_PATH: 'packages/desktop'
|
||||
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:
|
||||
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:
|
||||
if: ${{ (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'))) }}
|
||||
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:
|
||||
@@ -54,7 +112,7 @@ jobs:
|
||||
- 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
|
||||
@@ -63,11 +121,11 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: package-lock.json
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
scope: '@boudra'
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
@@ -103,28 +161,31 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
PASEO_DESKTOP_SMOKE: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish_mode="never"
|
||||
publish_args=()
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
build_args=(-- --publish "$publish_mode" --mac --${{ matrix.electron_arch }})
|
||||
if [[ "$SHOULD_PUBLISH" == "true" && "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
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" -- --publish "$publish_mode" --mac --${{ matrix.electron_arch }} "${publish_args[@]}"
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
|
||||
|
||||
- name: Upload manifest artifact
|
||||
if: env.IS_SMOKE_TAG != 'true'
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mac-manifest-${{ matrix.electron_arch }}
|
||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/latest-mac.yml
|
||||
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' }}
|
||||
if: ${{ needs.publish-macos.result == 'success' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false') }}
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
@@ -132,13 +193,30 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: scripts
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
|
||||
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
|
||||
@@ -150,76 +228,21 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
node <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
|
||||
// Simple YAML parser for electron-builder's latest-mac.yml format
|
||||
function parseManifest(text) {
|
||||
const lines = text.split('\n');
|
||||
const result = { files: [] };
|
||||
let currentFile = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('version:')) result.version = line.split(': ')[1].trim();
|
||||
else if (line.startsWith('path:')) result.path = line.split(': ')[1].trim();
|
||||
else if (line.startsWith('sha512:') && !currentFile) result.sha512 = line.split(': ')[1].trim();
|
||||
else if (line.startsWith('releaseDate:')) result.releaseDate = line.split(': ')[1].trim().replace(/'/g, '');
|
||||
else if (line.trim().startsWith('- url:')) {
|
||||
currentFile = { url: line.trim().replace('- url: ', '') };
|
||||
result.files.push(currentFile);
|
||||
} else if (line.trim().startsWith('sha512:') && currentFile) {
|
||||
currentFile.sha512 = line.trim().split(': ')[1].trim();
|
||||
} else if (line.trim().startsWith('size:') && currentFile) {
|
||||
currentFile.size = parseInt(line.trim().split(': ')[1].trim(), 10);
|
||||
currentFile = null;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toYaml(manifest) {
|
||||
let out = `version: ${manifest.version}\n`;
|
||||
out += `files:\n`;
|
||||
for (const f of manifest.files) {
|
||||
out += ` - url: ${f.url}\n`;
|
||||
out += ` sha512: ${f.sha512}\n`;
|
||||
out += ` size: ${f.size}\n`;
|
||||
}
|
||||
out += `path: ${manifest.path}\n`;
|
||||
out += `sha512: ${manifest.sha512}\n`;
|
||||
out += `releaseDate: '${manifest.releaseDate}'\n`;
|
||||
return out;
|
||||
}
|
||||
|
||||
const arm64Text = fs.readFileSync('mac-manifest-arm64/latest-mac.yml', 'utf8');
|
||||
const x64Text = fs.readFileSync('mac-manifest-x64/latest-mac.yml', 'utf8');
|
||||
|
||||
const arm64 = parseManifest(arm64Text);
|
||||
const x64 = parseManifest(x64Text);
|
||||
|
||||
// Merge: all files from both, default path points to arm64 zip
|
||||
const merged = {
|
||||
version: arm64.version,
|
||||
files: [...arm64.files, ...x64.files],
|
||||
path: arm64.path,
|
||||
sha512: arm64.sha512,
|
||||
releaseDate: arm64.releaseDate || x64.releaseDate,
|
||||
};
|
||||
|
||||
const output = toYaml(merged);
|
||||
fs.writeFileSync('latest-mac.yml', output);
|
||||
console.log('Merged manifest:\n' + output);
|
||||
NODE
|
||||
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" latest-mac.yml --clobber --repo "${{ github.repository }}"
|
||||
run: gh release upload "$RELEASE_TAG" "$RELEASE_CHANNEL-mac.yml" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
publish-linux:
|
||||
if: ${{ (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'))) }}
|
||||
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
|
||||
@@ -229,7 +252,7 @@ jobs:
|
||||
- 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
|
||||
@@ -238,11 +261,11 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: package-lock.json
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
scope: '@boudra'
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
@@ -268,24 +291,31 @@ jobs:
|
||||
- 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"
|
||||
publish_args=()
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
build_args=(-- --publish "$publish_mode" --linux --x64)
|
||||
if [[ "$SHOULD_PUBLISH" == "true" && "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
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" -- --publish "$publish_mode" --linux --x64 "${publish_args[@]}"
|
||||
npm run build --workspace="$DESKTOP_WORKSPACE" "${build_args[@]}"
|
||||
|
||||
publish-windows:
|
||||
if: ${{ (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'))) }}
|
||||
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
|
||||
@@ -295,7 +325,7 @@ jobs:
|
||||
- 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
|
||||
@@ -304,11 +334,11 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
cache-dependency-path: package-lock.json
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
scope: '@boudra'
|
||||
registry-url: "https://npm.pkg.github.com"
|
||||
scope: "@boudra"
|
||||
|
||||
- name: Install JS dependencies
|
||||
run: npm ci
|
||||
@@ -347,13 +377,94 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EP_GH_IGNORE_TIME: true
|
||||
PASEO_DESKTOP_SMOKE: "1"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish_mode="never"
|
||||
publish_args=()
|
||||
if [[ "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
build_args=(-- --publish "$publish_mode" --win --x64)
|
||||
if [[ "$SHOULD_PUBLISH" == "true" && "$IS_SMOKE_TAG" != "true" ]]; then
|
||||
publish_mode="always"
|
||||
publish_args+=("-c.publish.releaseType=$RELEASE_TYPE")
|
||||
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" -- --publish "$publish_mode" --win --x64 "${publish_args[@]}"
|
||||
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"
|
||||
12
.github/workflows/fix-nix-hash.yml
vendored
12
.github/workflows/fix-nix-hash.yml
vendored
@@ -4,12 +4,12 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
pull_request:
|
||||
paths:
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -25,8 +25,8 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
|
||||
36
.github/workflows/nix-build.yml
vendored
36
.github/workflows/nix-build.yml
vendored
@@ -4,27 +4,27 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'nix/**'
|
||||
- 'flake.nix'
|
||||
- 'flake.lock'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'packages/highlight/**'
|
||||
- 'packages/server/**'
|
||||
- 'packages/relay/**'
|
||||
- 'packages/cli/**'
|
||||
- "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/**'
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
2
.github/workflows/release-notes-sync.yml
vendored
2
.github/workflows/release-notes-sync.yml
vendored
@@ -54,7 +54,7 @@ jobs:
|
||||
fi
|
||||
|
||||
create_if_missing="false"
|
||||
if [ "$EVENT_NAME" = "push" ] && [[ "$REF" == refs/tags/v* ]]; then
|
||||
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"
|
||||
|
||||
20
.github/workflows/server-ci.yml
vendored
20
.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
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
431
CHANGELOG.md
431
CHANGELOG.md
@@ -1,57 +1,334 @@
|
||||
# Changelog
|
||||
|
||||
## 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 — live token usage indicator for Claude Code, Codex, and OpenCode shows how much of the context window has been consumed, with color thresholds at 70% and 90%.
|
||||
- Open in editor — open the current workspace directory in Cursor, VS Code, Zed, or the system file manager directly from the toolbar. Remembers your preferred editor.
|
||||
- Side-by-side diff layout — toggle between unified and split-column diff views in the Changes pane, with a whitespace visibility toggle.
|
||||
- Spoken messages — voice-mode speak tool calls now render inline in the conversation as labeled spoken messages instead of raw tool call blocks.
|
||||
- Plan approval actions — plan permission cards now show provider-defined action buttons (e.g. "Implement", "Deny") instead of hardcoded accept/reject.
|
||||
- Background git fetch — the daemon periodically fetches from origin so the Changes pane shows accurate ahead/behind counts without manual refreshes.
|
||||
|
||||
- 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
|
||||
- File explorer and diff pane expanded/collapsed state persists across tab switches and rehydration.
|
||||
- Workspace list and updates are served instantly on connect; reconciliation happens in the background, eliminating the initial loading delay.
|
||||
- Provider list in Settings now includes a Refresh button and shows inline error details.
|
||||
- Workspace tabs close optimistically — the tab disappears immediately while the daemon archives the agent in the background.
|
||||
- Reload agent action moved away from the close button to prevent accidental taps.
|
||||
|
||||
- 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
|
||||
- WorkingIndicator no longer remounts on every stream update on native.
|
||||
- Silero VAD state is now reset between voice turns, preventing LSTM drift that could cause false speech detections in long sessions.
|
||||
- OpenCode context window meter updates correctly after the first turn.
|
||||
- Garbled overlapping text in plan card markdown.
|
||||
- Worktree branch tracking now prefers `origin/{branch}` over the local branch ref, fixing stale diff baselines.
|
||||
- Session ID reset on query restart prevents an overwrite crash when restarting an agent quickly.
|
||||
- Copilot ACP permission prompts are now bypassed in autopilot mode.
|
||||
- Direct connection and pairing modal content now displays correctly on tablets.
|
||||
- `wait_for_finish` errors from agents are now surfaced to the caller instead of silently swallowed.
|
||||
- Workspace diff stats preserved across rehydration instead of resetting to zero.
|
||||
- Diff toolbar toggle buttons polished for consistent sizing and alignment.
|
||||
|
||||
- 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 app connect instead of requiring a second status refresh.
|
||||
- Model picker on running agents now only shows models from the agent's own provider, not every provider on the server.
|
||||
- Model data is now prefetched consistently regardless of which screen you open first.
|
||||
- Draft and running-agent flows now share the same provider data path, eliminating stale model lists from legacy fallbacks.
|
||||
|
||||
- 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.
|
||||
@@ -59,6 +336,7 @@
|
||||
## 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.
|
||||
@@ -67,6 +345,7 @@
|
||||
## 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.
|
||||
@@ -75,6 +354,7 @@
|
||||
## 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 .`.
|
||||
@@ -87,11 +367,13 @@
|
||||
- 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.
|
||||
@@ -108,6 +390,7 @@
|
||||
## 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.
|
||||
@@ -117,16 +400,19 @@
|
||||
## 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.
|
||||
@@ -134,11 +420,13 @@
|
||||
## 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 (`\\...`).
|
||||
@@ -150,18 +438,22 @@
|
||||
- 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.
|
||||
@@ -172,61 +464,73 @@
|
||||
## 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.
|
||||
@@ -235,6 +539,7 @@
|
||||
- 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.
|
||||
@@ -251,6 +556,7 @@
|
||||
## 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.
|
||||
@@ -259,6 +565,7 @@
|
||||
## 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.
|
||||
@@ -268,6 +575,7 @@
|
||||
- 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.
|
||||
@@ -282,6 +590,7 @@
|
||||
- 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.
|
||||
@@ -290,16 +599,19 @@
|
||||
## 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.
|
||||
@@ -307,30 +619,36 @@
|
||||
## 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.
|
||||
@@ -338,15 +656,18 @@
|
||||
## 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.
|
||||
@@ -356,61 +677,79 @@
|
||||
## 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.
|
||||
@@ -418,6 +757,7 @@
|
||||
- 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.
|
||||
@@ -425,7 +765,9 @@
|
||||
- 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.
|
||||
@@ -433,6 +775,7 @@
|
||||
- `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.
|
||||
@@ -440,16 +783,20 @@
|
||||
- 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.
|
||||
@@ -457,11 +804,14 @@
|
||||
- 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.
|
||||
@@ -470,6 +820,7 @@
|
||||
- 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.
|
||||
@@ -479,6 +830,7 @@
|
||||
- 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.
|
||||
@@ -492,25 +844,32 @@
|
||||
- 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
|
||||
|
||||
### 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.
|
||||
@@ -518,73 +877,93 @@
|
||||
- Fixed web tool-detail wheel event routing at scroll edges.
|
||||
|
||||
## 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
|
||||
|
||||
### Notes
|
||||
|
||||
- No major visible product changes in this patch release.
|
||||
|
||||
## 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
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
|
||||
### Notes
|
||||
|
||||
- No major visible product changes in this patch release.
|
||||
|
||||
## 0.1.1 - 2026-02-11
|
||||
|
||||
### Added
|
||||
|
||||
- Initial `0.1.x` release line.
|
||||
|
||||
77
CLAUDE.md
77
CLAUDE.md
@@ -17,16 +17,17 @@ This is an npm workspace monorepo:
|
||||
|
||||
## Documentation
|
||||
|
||||
| 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/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 |
|
||||
| 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 |
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -35,6 +36,9 @@ 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
|
||||
```
|
||||
|
||||
See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requirements, and debugging.
|
||||
@@ -44,7 +48,17 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
|
||||
- **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.
|
||||
- **Always run typecheck after every change.**
|
||||
- **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.
|
||||
@@ -52,7 +66,46 @@ See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for full setup, build sync requir
|
||||
- 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
|
||||
|
||||
|
||||
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.
|
||||
23
README.md
23
README.md
@@ -4,6 +4,21 @@
|
||||
|
||||
<h1 align="center">Paseo</h1>
|
||||
|
||||
<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">
|
||||
@@ -54,6 +69,7 @@ paseo
|
||||
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)
|
||||
|
||||
@@ -101,6 +117,7 @@ Then use them in any agent conversation:
|
||||
## 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
|
||||
@@ -127,6 +144,12 @@ npm run build:daemon
|
||||
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
|
||||
|
||||
AGPL-3.0
|
||||
|
||||
@@ -55,7 +55,7 @@ Host header validation and CORS origin checks are defense-in-depth controls for
|
||||
|
||||
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
|
||||
|
||||
Paseo 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
|
||||
|
||||
|
||||
32
biome.json
32
biome.json
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": ["**", "!*.lock"]
|
||||
},
|
||||
"formatter": {
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"trailingCommas": "all",
|
||||
"semicolons": "always"
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"cssModules": true,
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
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` |
|
||||
| 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`.
|
||||
@@ -51,7 +51,7 @@ 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)
|
||||
|
||||
Release candidate tags like `v0.1.1-rc.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||
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
|
||||
|
||||
|
||||
@@ -53,17 +53,17 @@ The heart of Paseo. A Node.js process that:
|
||||
|
||||
**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) |
|
||||
| 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)
|
||||
|
||||
@@ -132,6 +132,7 @@ Server → Client: WSWelcomeMessage { clientId, daemonVersion, sessionId, capab
|
||||
**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
|
||||
@@ -153,13 +154,14 @@ initializing → idle → running → idle (or error → closed)
|
||||
|
||||
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 |
|
||||
| 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
|
||||
|
||||
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.
|
||||
@@ -40,7 +40,10 @@ No complex inline types in public function signatures.
|
||||
function enqueueJob(input: { userId: string; priority: "low" | "normal" | "high" }) {}
|
||||
|
||||
// Good
|
||||
interface EnqueueJobInput { userId: string; priority: "low" | "normal" | "high" }
|
||||
interface EnqueueJobInput {
|
||||
userId: string;
|
||||
priority: "low" | "normal" | "high";
|
||||
}
|
||||
function enqueueJob(input: EnqueueJobInput) {}
|
||||
```
|
||||
|
||||
@@ -53,7 +56,11 @@ If a function needs more than one argument, use a single object parameter.
|
||||
function createToolCall(provider: string, toolName: string, payload: unknown) {}
|
||||
|
||||
// Good: object param
|
||||
interface CreateToolCallInput { provider: string; toolName: string; payload: unknown }
|
||||
interface CreateToolCallInput {
|
||||
provider: string;
|
||||
toolName: string;
|
||||
payload: unknown;
|
||||
}
|
||||
function createToolCall(input: CreateToolCallInput) {}
|
||||
```
|
||||
|
||||
@@ -78,7 +85,11 @@ Use discriminated unions instead of bags of booleans and optionals.
|
||||
|
||||
```typescript
|
||||
// Bad
|
||||
interface FetchState { isLoading: boolean; error?: Error; data?: Data }
|
||||
interface FetchState {
|
||||
isLoading: boolean;
|
||||
error?: Error;
|
||||
data?: Data;
|
||||
}
|
||||
|
||||
// Good
|
||||
type FetchState =
|
||||
@@ -136,7 +147,10 @@ Avoid packing branching, lookup, and transformation into single dense expression
|
||||
// Bad: nested ternaries + inline lookups
|
||||
const billing = shouldUseLegacy(account)
|
||||
? getLegacy(account)
|
||||
: buildBilling(account, rates.find((r) => r.region === account.region));
|
||||
: buildBilling(
|
||||
account,
|
||||
rates.find((r) => r.region === account.region),
|
||||
);
|
||||
|
||||
// Good: named steps, then assemble
|
||||
const rate = rates.find((r) => r.region === account.region);
|
||||
|
||||
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` |
|
||||
@@ -22,10 +22,16 @@ 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`
|
||||
|
||||
@@ -35,6 +41,64 @@ In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
|
||||
|
||||
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
|
||||
@@ -84,11 +148,13 @@ $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/
|
||||
```
|
||||
@@ -98,11 +164,13 @@ rg -l "some title text" $PASEO_HOME/agents/
|
||||
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
|
||||
```
|
||||
|
||||
@@ -35,8 +35,8 @@ 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>`,
|
||||
```
|
||||
@@ -52,58 +52,58 @@ cat node_modules/material-icon-theme/icons/ICON_NAME.svg
|
||||
|
||||
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 |
|
||||
| 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.
|
||||
@@ -95,6 +95,64 @@ 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.
|
||||
@@ -165,7 +223,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
<Animated.View style={[styles.sidebar, animatedStyle]} />
|
||||
<Animated.View style={[styles.sidebar, animatedStyle]} />;
|
||||
```
|
||||
|
||||
```tsx
|
||||
@@ -186,7 +244,7 @@ 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`.
|
||||
|
||||
@@ -30,6 +30,7 @@ Each project opens as a workspace. For git projects, the default workspace is th
|
||||
### 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
|
||||
@@ -39,6 +40,7 @@ A workspace is a flexible canvas:
|
||||
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
|
||||
|
||||
@@ -58,10 +58,10 @@ type MyProviderClientOptions = {
|
||||
export class MyProviderACPAgentClient extends ACPAgentClient {
|
||||
constructor(options: MyProviderClientOptions) {
|
||||
super({
|
||||
provider: "my-provider", // Must match the ID used everywhere else
|
||||
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
|
||||
defaultCommand: ["my-agent-binary", "--acp"], // CLI command to spawn
|
||||
defaultModes: MY_PROVIDER_MODES,
|
||||
capabilities: MY_PROVIDER_CAPABILITIES,
|
||||
});
|
||||
@@ -70,7 +70,7 @@ export class MyProviderACPAgentClient extends ACPAgentClient {
|
||||
// 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 false; // Binary not found
|
||||
}
|
||||
return Boolean(process.env["MY_PROVIDER_API_KEY"]);
|
||||
}
|
||||
@@ -212,8 +212,8 @@ export const agentConfigs = {
|
||||
provider: "my-provider",
|
||||
model: "default-model-id",
|
||||
modes: {
|
||||
full: "autonomous", // Mode with no permission prompts
|
||||
ask: "default", // Mode that requires permission approval
|
||||
full: "autonomous", // Mode with no permission prompts
|
||||
ask: "default", // Mode that requires permission approval
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<string, AgentTestConfig>;
|
||||
@@ -264,8 +264,15 @@ If your agent does not speak ACP, implement the interfaces from `agent-sdk-types
|
||||
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>;
|
||||
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:
|
||||
|
||||
159
docs/RELEASE.md
159
docs/RELEASE.md
@@ -7,7 +7,7 @@ All workspaces share one version and release together.
|
||||
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. **Release candidate flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
|
||||
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)
|
||||
|
||||
@@ -37,31 +37,91 @@ npm run release:publish # Publish to npm
|
||||
npm run release:push # Push HEAD + tag (triggers CI workflows)
|
||||
```
|
||||
|
||||
## Release candidate flow
|
||||
## Beta flow
|
||||
|
||||
```bash
|
||||
npm run release:rc:patch # Bump to X.Y.Z-rc.1, push commit + tag
|
||||
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:rc:next # Optional: cut X.Y.Z-rc.2, rc.3, ...
|
||||
npm run release:promote # Promote X.Y.Z-rc.N to stable X.Y.Z
|
||||
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
|
||||
```
|
||||
|
||||
- RC tags are published GitHub prereleases like `v0.1.41-rc.1`
|
||||
- RCs 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 RC tag
|
||||
- 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`
|
||||
- **Do NOT create a changelog entry for RCs.** The changelog remains stable-only. RC release notes are generated automatically so the website stays pinned to the latest published stable release.
|
||||
- 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 RC path when you need to:
|
||||
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 `rc.1`, `rc.2`, `rc.3`, and so on before deciding to ship broadly
|
||||
- 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.
|
||||
|
||||
### 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):**
|
||||
|
||||
```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 a concurrency group with `desktop-release.yml`'s `finalize-rollout` job keyed on the tag, so it serializes safely against an in-flight tag-push pipeline targeting the same release.
|
||||
|
||||
### Faster ramp at release time
|
||||
|
||||
For low-risk changes (doc-only, dependency bumps with no behavior change), trigger `desktop-release.yml` manually with a shorter rollout window so the build itself stamps a smaller `rolloutHours`:
|
||||
|
||||
```bash
|
||||
gh workflow run desktop-release.yml \
|
||||
-f tag=v0.1.43 \
|
||||
-f rollout_hours=6
|
||||
```
|
||||
|
||||
### 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 RC prereleases are public on GitHub Releases, but they do **not** become the website download target.
|
||||
- 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
|
||||
@@ -88,13 +148,13 @@ git tag -f desktop-windows-v0.1.28 HEAD && git push origin desktop-windows-v0.1.
|
||||
# Android APK
|
||||
git tag -f android-v0.1.28 HEAD && git push origin android-v0.1.28 --force
|
||||
|
||||
# RC
|
||||
git tag -f v0.1.29-rc.2 HEAD && git push origin v0.1.29-rc.2 --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-rc.N` rebuilds the full tagged release
|
||||
- `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
|
||||
@@ -105,33 +165,82 @@ This ensures the checkout ref matches the actual code on `main` with the fix inc
|
||||
- `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 RC prereleases do not replace the stable download target.
|
||||
- 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
|
||||
|
||||
Stable release notes depend on the changelog heading format. The heading **must** be strictly followed:
|
||||
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` is for **final stable releases only**.
|
||||
- Do not add or edit changelog entries while iterating on RCs.
|
||||
- Write the proper changelog entry when you are cutting the final stable release that comes after the RC cycle.
|
||||
- Between stable releases, keep changelog work out of the repo until the final release is ready.
|
||||
- `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 (RC or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
||||
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:
|
||||
|
||||
@@ -149,10 +258,10 @@ The agent's job is a deep sanity check, not a full code review. If it flags anyt
|
||||
|
||||
The changelog always covers **stable-to-HEAD**:
|
||||
|
||||
- **RC release**: the diff and release notes cover `latest stable tag → HEAD`. RC release notes are auto-generated and not added to `CHANGELOG.md`.
|
||||
- **Stable release**: the diff and changelog entry cover `latest stable tag → HEAD`. Any intermediate RCs are skipped — the changelog captures the full delta from the previous stable release, not just what changed since the last RC.
|
||||
- **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, RCs are checkpoints along the way; the changelog only records the final jump from one stable version to the next.
|
||||
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
|
||||
|
||||
|
||||
@@ -79,7 +79,9 @@ const realSender: EmailSender = { send: sendgrid.send };
|
||||
function createTestEmailSender() {
|
||||
const sent: Array<{ to: string; body: string }> = [];
|
||||
return {
|
||||
send: async (to: string, body: string) => { sent.push({ to, body }); },
|
||||
send: async (to: string, body: string) => {
|
||||
sent.push({ to, body });
|
||||
},
|
||||
sent,
|
||||
};
|
||||
}
|
||||
@@ -115,6 +117,7 @@ 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
|
||||
|
||||
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)
|
||||
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
|
||||
@@ -9,6 +9,10 @@ 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";
|
||||
|
||||
@@ -58,12 +62,12 @@ in
|
||||
description = "Whether to open the firewall for the Paseo daemon port.";
|
||||
};
|
||||
|
||||
allowedHosts = lib.mkOption {
|
||||
hostnames = lib.mkOption {
|
||||
type = lib.types.either (lib.types.enum [ true ]) (lib.types.listOf lib.types.str);
|
||||
default = [ ];
|
||||
example = [ ".example.com" "myhost.local" ];
|
||||
description = ''
|
||||
Hosts allowed to connect to the Paseo daemon (DNS rebinding protection).
|
||||
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
|
||||
@@ -141,10 +145,10 @@ in
|
||||
"/run/wrappers/bin"
|
||||
"/nix/var/nix/profiles/default/bin"
|
||||
]);
|
||||
} // lib.optionalAttrs (cfg.allowedHosts == true) {
|
||||
PASEO_ALLOWED_HOSTS = "true";
|
||||
} // lib.optionalAttrs (lib.isList cfg.allowedHosts && cfg.allowedHosts != [ ]) {
|
||||
PASEO_ALLOWED_HOSTS = lib.concatStringsSep "," cfg.allowedHosts;
|
||||
} // 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 = {
|
||||
|
||||
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# 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-TEsFzJRgVubRnjAy7OO6Xkn6HY7CRO3LVxsVdbw3IH4=";
|
||||
npmDepsHash = "sha256-d87dMrQ2iY0sl0ddOapdEBGtnpbewowZh0DMg/smAb0=";
|
||||
|
||||
# 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).
|
||||
|
||||
5189
package-lock.json
generated
5189
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
90
package.json
90
package.json
@@ -1,7 +1,26 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.50",
|
||||
"version": "0.1.63",
|
||||
"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",
|
||||
@@ -14,18 +33,25 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "./scripts/dev.sh",
|
||||
"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": "biome format --write .",
|
||||
"format:check": "biome format .",
|
||||
"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",
|
||||
@@ -36,7 +62,9 @@
|
||||
"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",
|
||||
@@ -44,60 +72,42 @@
|
||||
"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:rc:patch": "node scripts/set-release-version.mjs --mode rc-patch",
|
||||
"version:all:rc:minor": "node scripts/set-release-version.mjs --mode rc-minor",
|
||||
"version:all:rc:major": "node scripts/set-release-version.mjs --mode rc-major",
|
||||
"version:all:rc:next": "node scripts/set-release-version.mjs --mode rc-next",
|
||||
"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:rc:patch": "npm run release:check && npm run version:all:rc:patch && npm run release:push",
|
||||
"release:rc:minor": "npm run release:check && npm run version:all:rc:minor && npm run release:push",
|
||||
"release:rc:major": "npm run release:check && npm run version:all:rc:major && npm run release:push",
|
||||
"release:rc:next": "npm run release:check && npm run version:all:rc:next && 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": {
|
||||
"@biomejs/biome": "^2.4.8",
|
||||
"@types/ws": "^8.5.14",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260423.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"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",
|
||||
"react": "19.1.4",
|
||||
"react-dom": "19.1.4",
|
||||
"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",
|
||||
"homepage": "https://paseo.sh",
|
||||
"keywords": [
|
||||
"openai",
|
||||
"realtime",
|
||||
"voice",
|
||||
"voice-assistant",
|
||||
"development",
|
||||
"mcp"
|
||||
],
|
||||
"author": {
|
||||
"name": "Mohamed Boudra",
|
||||
"email": "hello@moboudra.com"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/getpaseo/paseo.git"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"overrides": {
|
||||
"lightningcss": "1.30.1",
|
||||
"react": "19.1.4",
|
||||
"react-dom": "19.1.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1"
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,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"
|
||||
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 |
@@ -4,10 +4,15 @@ import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
archiveAgentFromSessions,
|
||||
clickSessionRow,
|
||||
closeWorkspaceAgentTab,
|
||||
connectArchiveTabDaemonClient,
|
||||
createIdleAgent,
|
||||
expectArchivedAgentFocused,
|
||||
expectSessionRowArchived,
|
||||
expectSessionRowVisible,
|
||||
expectWorkspaceArchiveOutcome,
|
||||
expectWorkspaceTabHidden,
|
||||
openSessions,
|
||||
openWorkspaceWithAgents,
|
||||
primeAdditionalPage,
|
||||
@@ -19,13 +24,15 @@ 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();
|
||||
await client?.close().catch(() => undefined);
|
||||
await tempRepo?.cleanup();
|
||||
});
|
||||
|
||||
@@ -62,10 +69,7 @@ test.describe("Archive tab reconciliation", () => {
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await reloadWorkspace(passivePage, tempRepo.path);
|
||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await expectWorkspaceTabHidden(passivePage, archived.id);
|
||||
} finally {
|
||||
await passivePage.close();
|
||||
}
|
||||
@@ -91,10 +95,7 @@ test.describe("Archive tab reconciliation", () => {
|
||||
await openSessions(page);
|
||||
await archiveAgentFromSessions(page, { agentId: archived.id, title: archived.title });
|
||||
await reloadWorkspace(page, tempRepo.path);
|
||||
await expectWorkspaceArchiveOutcome(page, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
});
|
||||
await expectWorkspaceTabHidden(page, archived.id);
|
||||
await expectWorkspaceArchiveOutcome(passivePage, {
|
||||
archivedAgentId: archived.id,
|
||||
survivingAgentId: surviving.id,
|
||||
@@ -103,4 +104,26 @@ test.describe("Archive tab reconciliation", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,12 @@ import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-r
|
||||
|
||||
// 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");
|
||||
}
|
||||
await use(`http://localhost:${metroPort}`);
|
||||
await provide(`http://localhost:${metroPort}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -67,7 +67,7 @@ test.beforeEach(async ({ page }) => {
|
||||
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.
|
||||
@@ -75,13 +75,13 @@ test.beforeEach(async ({ page }) => {
|
||||
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-seed-nonce", nonce);
|
||||
|
||||
// Hard-reset anything that could point to a developer's real daemon.
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import { spawn, type ChildProcess, execSync } from "node:child_process";
|
||||
import { spawn, type ChildProcess, execFileSync, execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
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";
|
||||
|
||||
type WaitForServerOptions = {
|
||||
interface WaitForServerOptions {
|
||||
host?: string;
|
||||
timeoutMs?: number;
|
||||
label: string;
|
||||
childProcess?: ChildProcess | null;
|
||||
getRecentOutput?: () => string;
|
||||
};
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -124,16 +125,21 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
|
||||
}
|
||||
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");
|
||||
}
|
||||
resolve();
|
||||
settle();
|
||||
}, 5000);
|
||||
child.once("exit", () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
child.once("exit", settle);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -182,17 +188,77 @@ async function isOpenAiApiKeyUsable(apiKey: string | undefined): Promise<boolean
|
||||
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 {
|
||||
@@ -224,37 +290,89 @@ function decodeOfferFromFragmentUrl(url: string): OfferPayload {
|
||||
return offer as OfferPayload;
|
||||
}
|
||||
|
||||
export default async function globalSetup() {
|
||||
const repoRoot = path.resolve(__dirname, "../../..");
|
||||
ensureRelayBuildArtifact(repoRoot);
|
||||
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();
|
||||
let relayPort = 0;
|
||||
const metroPort = await getAvailablePort();
|
||||
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-"));
|
||||
let relayLineBuffer = createLineBuffer();
|
||||
const metroLineBuffer = createLineBuffer();
|
||||
const daemonLineBuffer = createLineBuffer();
|
||||
|
||||
const cleanup = async () => {
|
||||
await Promise.all([
|
||||
stopProcess(daemonProcess),
|
||||
stopProcess(metroProcess),
|
||||
stopProcess(relayProcess),
|
||||
]);
|
||||
daemonProcess = null;
|
||||
metroProcess = null;
|
||||
relayProcess = null;
|
||||
if (paseoHome) {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
paseoHome = null;
|
||||
}
|
||||
};
|
||||
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(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveDictationConfig(): Promise<DictationConfig> {
|
||||
const openAiUsable = await isOpenAiApiKeyUsable(process.env.OPENAI_API_KEY);
|
||||
const defaultLocalModelsDir = path.join(
|
||||
process.env.HOME ?? "",
|
||||
@@ -277,238 +395,291 @@ export default async function globalSetup() {
|
||||
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) {
|
||||
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,
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
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",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
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}`);
|
||||
}
|
||||
});
|
||||
|
||||
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}`);
|
||||
}
|
||||
});
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
interface DaemonSpawnArgs {
|
||||
port: number;
|
||||
relayPort: number;
|
||||
metroPort: number;
|
||||
paseoHome: string;
|
||||
fakeGhBinDir: string;
|
||||
dictation: DictationConfig;
|
||||
buffer: ReturnType<typeof createLineBuffer>;
|
||||
}
|
||||
|
||||
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,
|
||||
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"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
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;
|
||||
args.buffer.add(`[stdout] ${trimmed}`);
|
||||
console.log(`[daemon] ${trimmed}`);
|
||||
}
|
||||
});
|
||||
|
||||
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}`);
|
||||
}
|
||||
});
|
||||
|
||||
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 relayDir = path.resolve(__dirname, "..", "..", "relay");
|
||||
const maxRelayStartupAttempts = 5;
|
||||
let relayStarted = false;
|
||||
let lastRelayStartupError: unknown = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRelayStartupAttempts; attempt += 1) {
|
||||
relayPort = await getAvailablePort();
|
||||
relayLineBuffer = createLineBuffer();
|
||||
let relayStartupFailureLine: string | null = null;
|
||||
let relayReadyForSelectedPort = 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,
|
||||
},
|
||||
);
|
||||
|
||||
relayProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const lines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
relayLineBuffer.add(`[stdout] ${line}`);
|
||||
const failure = parseRelayStartupFailure(line);
|
||||
if (failure) {
|
||||
relayStartupFailureLine = failure;
|
||||
}
|
||||
const clean = stripAnsi(line);
|
||||
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
|
||||
if (readyMatch && Number(readyMatch[1]) === relayPort) {
|
||||
relayReadyForSelectedPort = true;
|
||||
}
|
||||
console.log(`[relay] ${line}`);
|
||||
}
|
||||
});
|
||||
relayProcess.stderr?.on("data", (data: Buffer) => {
|
||||
const lines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
relayLineBuffer.add(`[stderr] ${line}`);
|
||||
const failure = parseRelayStartupFailure(line);
|
||||
if (failure) {
|
||||
relayStartupFailureLine = failure;
|
||||
}
|
||||
const clean = stripAnsi(line);
|
||||
const readyMatch = clean.match(/Ready on .*:(\d+)\b/i);
|
||||
if (readyMatch && Number(readyMatch[1]) === relayPort) {
|
||||
relayReadyForSelectedPort = true;
|
||||
}
|
||||
console.error(`[relay] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForServer(relayPort, {
|
||||
label: "Relay dev server",
|
||||
timeoutMs: 30000,
|
||||
childProcess: relayProcess,
|
||||
getRecentOutput: relayLineBuffer.dump,
|
||||
});
|
||||
|
||||
const readyDeadline = Date.now() + 5000;
|
||||
while (
|
||||
!relayReadyForSelectedPort &&
|
||||
relayStartupFailureLine === null &&
|
||||
relayProcess?.exitCode === null &&
|
||||
relayProcess?.signalCode === null &&
|
||||
Date.now() < readyDeadline
|
||||
) {
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
if (relayStartupFailureLine) {
|
||||
throw new Error(`Relay startup failed: ${relayStartupFailureLine}`);
|
||||
}
|
||||
if (!relayReadyForSelectedPort) {
|
||||
throw new Error(
|
||||
`Relay process did not report ready for selected port ${relayPort}.${formatRecentOutput(
|
||||
relayLineBuffer.dump,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
if (relayProcess.exitCode !== null || relayProcess.signalCode !== null) {
|
||||
throw new Error(
|
||||
`Relay process exited before startup completed (exit code ${relayProcess.exitCode}, signal ${relayProcess.signalCode}).${formatRecentOutput(
|
||||
relayLineBuffer.dump,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
relayStarted = true;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastRelayStartupError = error;
|
||||
await stopProcess(relayProcess);
|
||||
relayProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!relayStarted) {
|
||||
const message =
|
||||
lastRelayStartupError instanceof Error
|
||||
? lastRelayStartupError.message
|
||||
: String(lastRelayStartupError);
|
||||
throw new Error(
|
||||
`Failed to start relay dev server after ${maxRelayStartupAttempts} attempts. ${message}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Start Metro bundler on dynamic port
|
||||
const appDir = path.resolve(__dirname, "..");
|
||||
metroProcess = spawn("npx", ["expo", "start", "--web", "--port", String(metroPort)], {
|
||||
cwd: appDir,
|
||||
env: {
|
||||
...process.env,
|
||||
BROWSER: "none", // Don't auto-open browser
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: false,
|
||||
const relayPort = await startRelay();
|
||||
metroProcess = startMetro(metroPort, metroLineBuffer);
|
||||
daemonProcess = startDaemon({
|
||||
port,
|
||||
relayPort,
|
||||
metroPort,
|
||||
paseoHome,
|
||||
fakeGhBinDir,
|
||||
dictation,
|
||||
buffer: daemonLineBuffer,
|
||||
});
|
||||
|
||||
metroProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const lines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
metroLineBuffer.add(`[stdout] ${line}`);
|
||||
console.log(`[metro] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
metroProcess.stderr?.on("data", (data: Buffer) => {
|
||||
const lines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
metroLineBuffer.add(`[stderr] ${line}`);
|
||||
console.error(`[metro] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
const serverDir = path.resolve(__dirname, "../../..", "packages/server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
|
||||
let offerPayload: OfferPayload | null = null;
|
||||
let offerResolve: (() => void) | null = null;
|
||||
const offerPromise = new Promise<void>((resolve) => {
|
||||
offerResolve = resolve;
|
||||
});
|
||||
|
||||
daemonProcess = 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}`,
|
||||
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"],
|
||||
detached: false,
|
||||
});
|
||||
|
||||
let stdoutBuffer = "";
|
||||
daemonProcess.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;
|
||||
daemonLineBuffer.add(`[stdout] ${trimmed}`);
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`[daemon] ${trimmed}`);
|
||||
}
|
||||
});
|
||||
|
||||
daemonProcess.stderr?.on("data", (data: Buffer) => {
|
||||
const lines = data
|
||||
.toString()
|
||||
.split("\n")
|
||||
.filter((line) => line.trim());
|
||||
for (const line of lines) {
|
||||
daemonLineBuffer.add(`[stderr] ${line}`);
|
||||
console.error(`[daemon] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for both daemon and Metro to be ready
|
||||
await Promise.all([
|
||||
waitForServer(port, {
|
||||
label: "Paseo daemon",
|
||||
@@ -517,23 +688,16 @@ export default async function globalSetup() {
|
||||
}),
|
||||
waitForServer(metroPort, {
|
||||
label: "Metro web server",
|
||||
timeoutMs: 120000, // Metro can take longer to start
|
||||
timeoutMs: 120000,
|
||||
childProcess: metroProcess,
|
||||
getRecentOutput: metroLineBuffer.dump,
|
||||
}),
|
||||
]);
|
||||
|
||||
// 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;
|
||||
const offer = await waitForPairingOfferFromCli({
|
||||
repoRoot,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
process.env.E2E_DAEMON_PORT = String(port);
|
||||
process.env.E2E_RELAY_PORT = String(relayPort);
|
||||
|
||||
@@ -2,26 +2,27 @@ 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 type ScrollMetrics = {
|
||||
export interface ScrollMetrics {
|
||||
offsetY: number;
|
||||
contentHeight: number;
|
||||
viewportHeight: number;
|
||||
distanceFromBottom: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type SeededAgent = {
|
||||
export interface SeededAgent {
|
||||
id: string;
|
||||
title: string;
|
||||
expectedTailText: string;
|
||||
url: string;
|
||||
workspaceUrl: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type DaemonClientInstance = {
|
||||
export interface DaemonClientInstance {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
createAgent(options: {
|
||||
@@ -35,7 +36,7 @@ export type DaemonClientInstance = {
|
||||
}): 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;
|
||||
@@ -83,33 +84,34 @@ export function createReplyTurn(label: string): {
|
||||
};
|
||||
}
|
||||
|
||||
interface DaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => DaemonClientInstance
|
||||
new (config: DaemonClientConfig) => DaemonClientInstance
|
||||
> {
|
||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||
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: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => DaemonClientInstance;
|
||||
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;
|
||||
@@ -127,7 +129,7 @@ export async function seedBottomAnchorAgent(input: {
|
||||
const lineCount = Math.max(14, input.lineCount ?? 14);
|
||||
const created = await input.client.createAgent({
|
||||
provider: "codex",
|
||||
model: "gpt-5.1-codex-mini",
|
||||
model: "gpt-5.4-mini",
|
||||
thinkingOptionId: "low",
|
||||
modeId: "full-access",
|
||||
cwd: input.cwd,
|
||||
|
||||
@@ -27,19 +27,19 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
const needsReset = await page.evaluate(
|
||||
({ expectedEndpoint, expectedServerId }) => {
|
||||
({ 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 any;
|
||||
if (entry?.serverId !== expectedServerId) 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: any) =>
|
||||
(c: { type?: string; endpoint?: string }) =>
|
||||
c?.type === "directTcp" &&
|
||||
typeof c?.endpoint === "string" &&
|
||||
/:6767\b/.test(c.endpoint),
|
||||
@@ -47,7 +47,8 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
||||
)
|
||||
return true;
|
||||
return !connections.some(
|
||||
(c: any) => c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
|
||||
(c: { type?: string; endpoint?: string }) =>
|
||||
c?.type === "directTcp" && c?.endpoint === endpoint,
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
@@ -68,10 +69,10 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
||||
});
|
||||
const preferences = buildCreateAgentPreferences(expectedServerId);
|
||||
await page.evaluate(
|
||||
({ daemon, preferences }) => {
|
||||
({ daemon: seededDaemon, preferences: seededPreferences }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
},
|
||||
{ daemon, preferences },
|
||||
@@ -80,6 +81,68 @@ async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
||||
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}`;
|
||||
@@ -98,66 +161,18 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
||||
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"}).`,
|
||||
);
|
||||
}
|
||||
|
||||
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 === "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: any) =>
|
||||
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)}).`,
|
||||
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${serverId}).`,
|
||||
);
|
||||
}
|
||||
assertDaemonConnections(connections, expectedEndpoint);
|
||||
|
||||
if (!snapshot.prefsRaw) {
|
||||
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
|
||||
}
|
||||
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.");
|
||||
}
|
||||
assertPreferencesMatch(snapshot.prefsRaw, serverId);
|
||||
}
|
||||
|
||||
export const gotoAppShell = async (page: Page) => {
|
||||
@@ -200,16 +215,11 @@ export const gotoHome = async (page: Page) => {
|
||||
};
|
||||
|
||||
export const openSettings = async (page: Page) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
|
||||
// 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(new RegExp(`/h/${escapeRegex(serverId)}/settings$`));
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
};
|
||||
|
||||
export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
@@ -313,7 +323,7 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
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(
|
||||
@@ -348,8 +358,8 @@ export const ensureHostSelected = async (page: Page) => {
|
||||
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;
|
||||
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;
|
||||
|
||||
@@ -3,30 +3,40 @@ 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";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostSessionsRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from "@/utils/host-routes";
|
||||
|
||||
export type ArchiveTabAgent = {
|
||||
export interface ArchiveTabAgent {
|
||||
id: string;
|
||||
title: string;
|
||||
cwd: string;
|
||||
};
|
||||
}
|
||||
|
||||
type ArchiveTabDaemonClient = {
|
||||
interface ArchiveTabDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
createAgent(options: {
|
||||
provider: string;
|
||||
model: string;
|
||||
thinkingOptionId: string;
|
||||
thinkingOptionId?: string;
|
||||
modeId: string;
|
||||
cwd: string;
|
||||
title: string;
|
||||
initialPrompt: 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;
|
||||
@@ -63,33 +73,34 @@ function buildSeededStoragePayload() {
|
||||
};
|
||||
}
|
||||
|
||||
interface ArchiveTabDaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => ArchiveTabDaemonClient
|
||||
new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||
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: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => ArchiveTabDaemonClient;
|
||||
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;
|
||||
@@ -100,17 +111,19 @@ export async function createIdleAgent(
|
||||
input: { cwd: string; title: string },
|
||||
): Promise<ArchiveTabAgent> {
|
||||
const created = await client.createAgent({
|
||||
provider: "codex",
|
||||
model: "gpt-5.1-codex-mini",
|
||||
thinkingOptionId: "low",
|
||||
modeId: "full-access",
|
||||
provider: "opencode",
|
||||
model: "opencode/gpt-5-nano",
|
||||
modeId: "bypassPermissions",
|
||||
cwd: input.cwd,
|
||||
title: input.title,
|
||||
initialPrompt: "Reply with exactly READY.",
|
||||
});
|
||||
const finished = await client.waitForFinish(created.id, 120_000);
|
||||
if (finished.status !== "idle") {
|
||||
throw new Error(`Expected agent ${created.id} to become idle, got ${finished.status}.`);
|
||||
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,
|
||||
@@ -135,21 +148,21 @@ export async function primeAdditionalPage(page: Page): Promise<void> {
|
||||
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||
});
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce }) => {
|
||||
({ 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 === seedNonce) {
|
||||
if (disableValue === nonce) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:e2e-seed-nonce", seedNonce);
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
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(preferences));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
|
||||
},
|
||||
{ daemon, preferences, seedNonce },
|
||||
);
|
||||
@@ -160,11 +173,11 @@ export async function resetSeededPageState(page: Page): Promise<void> {
|
||||
const { daemon, preferences } = buildSeededStoragePayload();
|
||||
await page.goto("/");
|
||||
await page.evaluate(
|
||||
({ daemon, preferences }) => {
|
||||
({ daemon: seededDaemon, preferences: seededPreferences }) => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
},
|
||||
{ daemon, preferences },
|
||||
@@ -179,19 +192,31 @@ export async function openWorkspaceWithAgents(
|
||||
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}`).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
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}`)).toHaveCount(0, {
|
||||
await expect(
|
||||
page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }),
|
||||
).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -204,6 +229,24 @@ export async function expectWorkspaceArchiveOutcome(
|
||||
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));
|
||||
@@ -234,6 +277,12 @@ export async function expectSessionRowArchived(page: Page, title: string): Promi
|
||||
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 },
|
||||
@@ -245,13 +294,19 @@ export async function archiveAgentFromSessions(
|
||||
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();
|
||||
await expect(archiveButton).toBeVisible({ timeout: 10_000 });
|
||||
await archiveButton.click();
|
||||
const modalVisible = await archiveButton.isVisible().catch(() => false);
|
||||
if (modalVisible) {
|
||||
await archiveButton.click();
|
||||
}
|
||||
|
||||
await expectSessionRowArchived(page, input.title);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ export const TEST_HOST_LABEL = "localhost";
|
||||
|
||||
export const TEST_PROVIDER_PREFERENCES = {
|
||||
claude: { model: "haiku" },
|
||||
codex: { model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
|
||||
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
|
||||
} as const;
|
||||
|
||||
export function buildDirectTcpConnection(endpoint: string) {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,16 @@ 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 type TerminalPerfDaemonClient = {
|
||||
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,
|
||||
@@ -14,6 +19,13 @@ export type TerminalPerfDaemonClient = {
|
||||
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 }>;
|
||||
@@ -25,7 +37,7 @@ export type TerminalPerfDaemonClient = {
|
||||
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;
|
||||
@@ -43,43 +55,64 @@ function getServerId(): string {
|
||||
return serverId;
|
||||
}
|
||||
|
||||
interface TerminalPerfDaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: { url: string; clientId: string; clientType: "cli" }) => TerminalPerfDaemonClient
|
||||
new (config: TerminalPerfDaemonClientConfig) => TerminalPerfDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||
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: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => TerminalPerfDaemonClient;
|
||||
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(cwd: string, terminalId: string): string {
|
||||
export function buildTerminalWorkspaceUrl(workspaceId: string, terminalId: string): string {
|
||||
const serverId = getServerId();
|
||||
const route = buildHostWorkspaceRoute(serverId, cwd);
|
||||
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 any).__paseoTerminal;
|
||||
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 "";
|
||||
}
|
||||
@@ -113,38 +146,38 @@ export async function waitForTerminalContent(
|
||||
|
||||
export async function navigateToTerminal(
|
||||
page: Page,
|
||||
input: { cwd: string; terminalId: string },
|
||||
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 = buildHostWorkspaceRoute(getServerId(), input.cwd);
|
||||
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: 15_000 });
|
||||
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();
|
||||
|
||||
// The workspace should now query listTerminals and discover our terminal.
|
||||
// Click the terminal tab if it auto-appeared, or wait for it.
|
||||
const terminalSurface = page.locator('[data-testid="terminal-surface"]');
|
||||
const surfaceVisible = await terminalSurface.isVisible().catch(() => false);
|
||||
|
||||
if (!surfaceVisible) {
|
||||
// Terminal tab might not be focused — look for it in the tab row and click it
|
||||
const terminalTab = page.locator(`[data-testid="workspace-tab-terminal:${input.terminalId}"]`);
|
||||
const tabExists = await terminalTab.isVisible({ timeout: 5_000 }).catch(() => false);
|
||||
|
||||
if (tabExists) {
|
||||
await terminalTab.click();
|
||||
} else {
|
||||
// Terminal tab not yet created — click "New terminal tab" to create one through the UI
|
||||
const newTerminalBtn = page.getByRole("button", { name: "New terminal tab" });
|
||||
await newTerminalBtn.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await newTerminalBtn.click();
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for terminal surface to be visible
|
||||
await terminalSurface.waitFor({ state: "visible", timeout: 15_000 });
|
||||
|
||||
// Wait for loading overlay to disappear (terminal attached)
|
||||
@@ -155,6 +188,7 @@ export async function navigateToTerminal(
|
||||
// overlay may never appear if attachment is instant
|
||||
});
|
||||
|
||||
await terminalSurface.scrollIntoViewIfNeeded();
|
||||
await terminalSurface.click();
|
||||
}
|
||||
|
||||
@@ -169,10 +203,10 @@ export async function setupDeterministicPrompt(page: Page, sentinel?: string): P
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
export type LatencySample = {
|
||||
export interface LatencySample {
|
||||
char: string;
|
||||
latencyMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures keystroke echo round-trip latency.
|
||||
@@ -183,12 +217,16 @@ export type LatencySample = {
|
||||
*/
|
||||
export async function measureKeystrokeLatency(page: Page, char: string): Promise<number> {
|
||||
await page.evaluate(() => {
|
||||
const term = (window as any).__paseoTerminal;
|
||||
if (!term) {
|
||||
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 = ((window as any).__perfKeystroke = {
|
||||
const state = (win.__perfKeystroke = {
|
||||
promise: null as Promise<number> | null,
|
||||
});
|
||||
|
||||
@@ -214,7 +252,11 @@ export async function measureKeystrokeLatency(page: Page, char: string): Promise
|
||||
|
||||
await page.keyboard.press(char);
|
||||
|
||||
return page.evaluate(() => (window as any).__perfKeystroke.promise);
|
||||
return page.evaluate(
|
||||
() =>
|
||||
(window as unknown as { __perfKeystroke: { promise: Promise<number> } }).__perfKeystroke
|
||||
.promise,
|
||||
);
|
||||
}
|
||||
|
||||
export function computePercentile(samples: number[], p: number): number {
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -13,15 +13,49 @@ export async function getWorkspaceTabTestIds(page: Page): Promise<string[]> {
|
||||
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(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
|
||||
await expect(visibleTestId(page, "workspace-tabs-row").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
|
||||
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))) {
|
||||
|
||||
@@ -6,6 +6,17 @@ 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);
|
||||
@@ -35,6 +46,29 @@ function workspaceRowLocator(page: Page, serverId: string, workspacePath: string
|
||||
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;
|
||||
@@ -50,12 +84,27 @@ export async function switchWorkspaceViaSidebar(input: {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
const subtitleLocator = page.getByTestId("workspace-header-subtitle");
|
||||
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,
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtemp, writeFile, rm, mkdir } from "node:fs/promises";
|
||||
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 },
|
||||
options?: {
|
||||
withRemote?: boolean;
|
||||
paseoConfig?: Record<string, unknown>;
|
||||
files?: Array<{ path: string; content: string }>;
|
||||
branches?: string[];
|
||||
},
|
||||
): Promise<TempRepo> => {
|
||||
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
||||
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
|
||||
// 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;
|
||||
|
||||
@@ -22,22 +29,92 @@ export const createTempGitRepo = async (
|
||||
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");
|
||||
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 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
537
packages/app/e2e/new-workspace.spec.ts
Normal file
537
packages/app/e2e/new-workspace.spec.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
assertNewWorkspaceSidebarAndHeader,
|
||||
clickNewWorkspaceButton,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
expectComposerGithubAttachmentPill,
|
||||
expectStartingRefPickerTriggerPr,
|
||||
openNewWorkspaceComposer,
|
||||
openStartingRefPicker,
|
||||
openProjectViaDaemon,
|
||||
selectBranchInPicker,
|
||||
selectGitHubPrInPicker,
|
||||
} from "./helpers/new-workspace";
|
||||
import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
expectWorkspaceHeader,
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForSidebarHydration,
|
||||
waitForWorkspaceInSidebar,
|
||||
workspaceLabelFromPath,
|
||||
} from "./helpers/workspace-ui";
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(rawMessage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseWebSocketJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getStringField(input: Record<string, unknown>, key: string): string | null {
|
||||
const value = input[key];
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
async function delayBrowserAgentCreatedStatus(page: Page) {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
|
||||
const daemonPortPattern = new RegExp(`:${daemonPort.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`);
|
||||
const createRequestIds = new Set<string>();
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let releaseRequested = false;
|
||||
let resolveCreateRequest: (() => void) | null = null;
|
||||
let resolveDelayedCreatedStatus: (() => void) | null = null;
|
||||
const createRequestSeen = new Promise<void>((resolve) => {
|
||||
resolveCreateRequest = resolve;
|
||||
});
|
||||
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
|
||||
resolveDelayedCreatedStatus = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonPortPattern, (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "create_agent_request") {
|
||||
const requestId = getStringField(sessionMessage, "requestId");
|
||||
if (requestId) {
|
||||
createRequestIds.add(requestId);
|
||||
resolveCreateRequest?.();
|
||||
}
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload =
|
||||
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
|
||||
? (sessionMessage.payload as Record<string, unknown>)
|
||||
: null;
|
||||
const requestId = payload ? getStringField(payload, "requestId") : null;
|
||||
|
||||
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
|
||||
resolveDelayedCreatedStatus?.();
|
||||
if (releaseRequested) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
|
||||
delayedForwards.push(() => ws.send(message));
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
releaseRequested = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
waitForCreateRequest: () => createRequestSeen,
|
||||
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("New workspace flow", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
const localWorkspaceIds = new Set<string>();
|
||||
const createdWorktreeIds = new Set<string>();
|
||||
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
client = await connectNewWorkspaceDaemonClient();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (client) {
|
||||
for (const workspaceId of createdWorktreeIds) {
|
||||
await archiveWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
||||
}
|
||||
for (const workspaceId of localWorkspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(client, workspaceId).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
createdWorktreeIds.clear();
|
||||
localWorkspaceIds.clear();
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("sidebar workspace navigation updates URL and header", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const firstRepo = await createTempGitRepo("workspace-nav-a-");
|
||||
const secondRepo = await createTempGitRepo("workspace-nav-b-");
|
||||
|
||||
try {
|
||||
const firstWorkspace = await openProjectViaDaemon(client, firstRepo.path);
|
||||
const secondWorkspace = await openProjectViaDaemon(client, secondRepo.path);
|
||||
localWorkspaceIds.add(firstWorkspace.workspaceId);
|
||||
localWorkspaceIds.add(secondWorkspace.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: firstWorkspace.workspaceName,
|
||||
subtitle: firstWorkspace.projectDisplayName,
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: secondWorkspace.workspaceId,
|
||||
});
|
||||
await waitForWorkspaceInSidebar(page, {
|
||||
serverId,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: secondWorkspace.workspaceName,
|
||||
subtitle: secondWorkspace.projectDisplayName,
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: firstWorkspace.workspaceName,
|
||||
subtitle: firstWorkspace.projectDisplayName,
|
||||
});
|
||||
} finally {
|
||||
await secondRepo.cleanup();
|
||||
await firstRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("same-project workspaces switch content without requiring refresh", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const repo = await createTempGitRepo("workspace-nav-same-project-");
|
||||
|
||||
try {
|
||||
const rootWorkspace = await openProjectViaDaemon(client, repo.path);
|
||||
const worktreeWorkspace = await createWorktreeViaDaemon(client, {
|
||||
cwd: repo.path,
|
||||
slug: `nav-${Date.now()}`,
|
||||
});
|
||||
localWorkspaceIds.add(rootWorkspace.workspaceId);
|
||||
createdWorktreeIds.add(worktreeWorkspace.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: rootWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: rootWorkspace.workspaceName,
|
||||
subtitle: rootWorkspace.projectDisplayName,
|
||||
});
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: rootWorkspace.workspaceId,
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: worktreeWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: worktreeWorkspace.workspaceName,
|
||||
subtitle: worktreeWorkspace.projectDisplayName,
|
||||
});
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: worktreeWorkspace.workspaceId,
|
||||
});
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: rootWorkspace.workspaceId,
|
||||
selected: false,
|
||||
});
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: rootWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: rootWorkspace.workspaceName,
|
||||
subtitle: rootWorkspace.projectDisplayName,
|
||||
});
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: rootWorkspace.workspaceId,
|
||||
});
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: worktreeWorkspace.workspaceId,
|
||||
selected: false,
|
||||
});
|
||||
} finally {
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("clicking new workspace redirects, renders header, shows sidebar row, and keeps one agent tab", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const tempRepo = await createTempGitRepo("new-workspace-");
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: openedProject.workspaceName,
|
||||
subtitle: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
await clickNewWorkspaceButton(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
|
||||
expect(createdWorkspace.workspaceId).not.toBe(openedProject.workspaceId);
|
||||
await expect(page).toHaveURL(
|
||||
buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId),
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
|
||||
const createdWorkspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`,
|
||||
);
|
||||
await expect(createdWorkspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspaceLabelFromPath(createdWorkspace.workspaceId),
|
||||
subtitle: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
const activeWorkspaceDeckEntry = page
|
||||
.getByTestId(`workspace-deck-entry-${serverId}:${createdWorkspace.workspaceId}`)
|
||||
.filter({ visible: true });
|
||||
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const agentTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]');
|
||||
await expect(agentTabs).toHaveCount(1, { timeout: 30_000 });
|
||||
|
||||
// Workspace setup may auto-open a setup tab that steals focus,
|
||||
// hiding the agent panel (display:none removes it from the
|
||||
// accessibility tree). Click the agent tab to ensure it's active.
|
||||
await agentTabs.first().click();
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("redirects to the optimistic draft tab before agent creation resolves", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const tempRepo = await createTempGitRepo("new-workspace-optimistic-");
|
||||
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: openedProject.workspaceName,
|
||||
subtitle: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||
await createButton.click();
|
||||
|
||||
await agentCreatedDelay.waitForCreateRequest();
|
||||
await agentCreatedDelay.waitForDelayedCreatedStatus();
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
|
||||
await expect(page).toHaveURL(
|
||||
buildHostWorkspaceRoute(serverId, createdWorkspace.workspaceId),
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
|
||||
const activeWorkspaceDeckEntry = page
|
||||
.getByTestId(`workspace-deck-entry-${serverId}:${createdWorkspace.workspaceId}`)
|
||||
.filter({ visible: true });
|
||||
await expect(activeWorkspaceDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const draftTabs = activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-draft_"]');
|
||||
await expect(draftTabs).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(
|
||||
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
agentCreatedDelay.release();
|
||||
await expect(
|
||||
activeWorkspaceDeckEntry.locator('[data-testid^="workspace-tab-agent_"]'),
|
||||
).toHaveCount(1, { timeout: 30_000 });
|
||||
} finally {
|
||||
agentCreatedDelay.release();
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selected branch becomes the base of a new workspace worktree", async ({ page }) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const tempRepo = await createTempGitRepo("new-workspace-ref-", {
|
||||
branches: ["main", "dev"],
|
||||
});
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: openedProject.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: openedProject.workspaceName,
|
||||
subtitle: openedProject.projectDisplayName,
|
||||
});
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await openStartingRefPicker(page);
|
||||
await selectBranchInPicker(page, "dev");
|
||||
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||
await createButton.click();
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeIds.add(createdWorkspace.workspaceId);
|
||||
|
||||
expect(existsSync(createdWorkspace.workspaceId)).toBe(true);
|
||||
|
||||
const branchInfo = await readWorktreeBranchInfo({
|
||||
worktreePath: createdWorkspace.workspaceId,
|
||||
});
|
||||
expect(branchInfo.currentBranch).toBe(path.basename(createdWorkspace.workspaceId));
|
||||
expect(branchInfo.hasAncestor(tempRepo.branchHeads.main)).toBe(true);
|
||||
expect(branchInfo.hasAncestor(tempRepo.branchHeads.dev)).toBe(true);
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("selected GitHub PR shows PR context in the trigger and composer", async ({ page }) => {
|
||||
const tempRepo = await createTempGitRepo("new-workspace-pr-ref-");
|
||||
|
||||
try {
|
||||
const openedProject = await openProjectViaDaemon(client, tempRepo.path);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await openStartingRefPicker(page);
|
||||
await selectGitHubPrInPicker(page, 515);
|
||||
|
||||
await expectStartingRefPickerTriggerPr(page, {
|
||||
number: 515,
|
||||
title: "Review selected start ref",
|
||||
headRef: "feature/start-from-pr",
|
||||
});
|
||||
await expectComposerGithubAttachmentPill(page, {
|
||||
number: 515,
|
||||
title: "Review selected start ref",
|
||||
});
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
122
packages/app/e2e/projects-settings.spec.ts
Normal file
122
packages/app/e2e/projects-settings.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test as base, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { connectNewWorkspaceDaemonClient, openProjectViaDaemon } from "./helpers/new-workspace";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
const updatedSetup = ["npm install", "npm run build"];
|
||||
|
||||
interface ProjectsSettingsProject {
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface ProjectsSettingsFixtures {
|
||||
editableProject: ProjectsSettingsProject;
|
||||
}
|
||||
|
||||
const initialPaseoConfig = {
|
||||
worktree: {
|
||||
setup: ["echo initial setup"],
|
||||
teardown: "echo cleanup",
|
||||
customWorktreeField: "preserved",
|
||||
},
|
||||
scripts: {
|
||||
dev: {
|
||||
command: "npm run dev",
|
||||
type: "server",
|
||||
port: 3000,
|
||||
customScriptField: "preserved",
|
||||
},
|
||||
},
|
||||
customTopLevelField: "preserved",
|
||||
};
|
||||
|
||||
const test = base.extend<ProjectsSettingsFixtures>({
|
||||
editableProject: async ({ page: _page }, provide) => {
|
||||
const client = await connectNewWorkspaceDaemonClient();
|
||||
const repo = await createTempGitRepo("projects-settings-", {
|
||||
paseoConfig: initialPaseoConfig,
|
||||
});
|
||||
const openedProject = await openProjectViaDaemon(client, repo.path);
|
||||
|
||||
await provide({
|
||||
name: openedProject.projectDisplayName,
|
||||
path: repo.path,
|
||||
});
|
||||
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
},
|
||||
});
|
||||
|
||||
async function openProjects(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await page.getByTestId("settings-projects").click();
|
||||
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||
}
|
||||
|
||||
async function openProjectSettings(page: Page, projectName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
|
||||
await page
|
||||
.getByRole("textbox", { name: "Worktree setup commands" })
|
||||
.fill(setupCommands.join("\n"));
|
||||
}
|
||||
|
||||
async function saveProjectConfig(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Save project config" }).click();
|
||||
}
|
||||
|
||||
async function expectProjectConfigSaved(project: ProjectsSettingsProject): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const contents = await readProjectConfigFile(project);
|
||||
return JSON.parse(contents) as unknown;
|
||||
},
|
||||
{
|
||||
timeout: 30_000,
|
||||
},
|
||||
)
|
||||
.toMatchObject({
|
||||
worktree: {
|
||||
setup: updatedSetup,
|
||||
teardown: initialPaseoConfig.worktree.teardown,
|
||||
customWorktreeField: initialPaseoConfig.worktree.customWorktreeField,
|
||||
},
|
||||
scripts: {
|
||||
dev: {
|
||||
command: initialPaseoConfig.scripts.dev.command,
|
||||
type: initialPaseoConfig.scripts.dev.type,
|
||||
port: initialPaseoConfig.scripts.dev.port,
|
||||
customScriptField: initialPaseoConfig.scripts.dev.customScriptField,
|
||||
},
|
||||
},
|
||||
customTopLevelField: initialPaseoConfig.customTopLevelField,
|
||||
});
|
||||
|
||||
const savedConfig = await readProjectConfigFile(project);
|
||||
expect(savedConfig).toBe(`${JSON.stringify(JSON.parse(savedConfig), null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function readProjectConfigFile(project: ProjectsSettingsProject): Promise<string> {
|
||||
return readFile(path.join(project.path, "paseo.json"), "utf8");
|
||||
}
|
||||
|
||||
test.describe("Projects settings", () => {
|
||||
test("user edits worktree setup from the projects page", async ({ page, editableProject }) => {
|
||||
await openProjects(page);
|
||||
await openProjectSettings(page, editableProject.name);
|
||||
await editWorktreeSetup(page, updatedSetup);
|
||||
await saveProjectConfig(page);
|
||||
await expectProjectConfigSaved(editableProject);
|
||||
});
|
||||
});
|
||||
181
packages/app/e2e/settings-host-page.spec.ts
Normal file
181
packages/app/e2e/settings-host-page.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { TEST_HOST_LABEL } from "./helpers/daemon-registry";
|
||||
|
||||
function getSeededServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function getSeededDaemonPort(): string {
|
||||
const port = process.env.E2E_DAEMON_PORT;
|
||||
if (!port) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
async function openHostPage(page: Page, serverId: string) {
|
||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectHostLabelHeader(page: Page) {
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(TEST_HOST_LABEL);
|
||||
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||
}
|
||||
|
||||
test.describe("Settings host page", () => {
|
||||
test("host page shows seeded label, connection endpoint, inject MCP toggle, and all action rows", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getSeededServerId();
|
||||
const port = getSeededDaemonPort();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostPage(page, serverId);
|
||||
|
||||
// Label renders in the detail header with a pencil edit affordance; the input is hidden until edit.
|
||||
await expectHostLabelHeader(page);
|
||||
|
||||
// Connections is its own section with a "Connections" heading and the seeded endpoint row.
|
||||
const connectionsCard = page.getByTestId("host-page-connections-card");
|
||||
await expect(connectionsCard).toBeVisible();
|
||||
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
connectionsCard.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
|
||||
).toBeVisible();
|
||||
|
||||
const injectMcpCard = page.getByTestId("host-page-inject-mcp-card");
|
||||
await expect(injectMcpCard).toBeVisible();
|
||||
await expect(injectMcpCard.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
|
||||
});
|
||||
|
||||
test("clicking the label pencil reveals the inline editor", async ({ page }) => {
|
||||
const serverId = getSeededServerId();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostPage(page, serverId);
|
||||
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("host-page-label-edit-button").click();
|
||||
|
||||
await expect(page.getByTestId("host-page-label-input")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-label-input")).toHaveValue(TEST_HOST_LABEL);
|
||||
await expect(page.getByTestId("host-page-label-save")).toBeVisible();
|
||||
});
|
||||
|
||||
test("host page does not render pair-device or daemon-lifecycle rows for a remote daemon", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getSeededServerId();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostPage(page, serverId);
|
||||
|
||||
// TODO: add local-daemon fixture for positive Pair/Daemon coverage.
|
||||
// Pair-device now lives behind a row that only the local host sees
|
||||
// (gated by useIsLocalDaemon); the seeded host is remote, so it must
|
||||
// not appear. The daemon-lifecycle card is still local-host only.
|
||||
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
|
||||
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("settings sidebar does not expose retired top-level sections", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
|
||||
|
||||
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("navigating to /settings/hosts/[serverId] directly renders the host page", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getSeededServerId();
|
||||
|
||||
await gotoAppShell(page);
|
||||
await page.goto(`/settings/hosts/${encodeURIComponent(serverId)}`);
|
||||
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
await expectHostLabelHeader(page);
|
||||
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
||||
});
|
||||
|
||||
test("sidebar pins the local daemon host first with a Local marker", async ({ page }) => {
|
||||
const serverId = getSeededServerId();
|
||||
|
||||
// Simulate the Electron desktop bridge so `useIsLocalDaemon` resolves the
|
||||
// seeded host to the local daemon. `manageBuiltInDaemon: false` (returned
|
||||
// from get_desktop_settings) bypasses the desktop bootstrap flow so only
|
||||
// the sidebar's status query runs against the seeded test daemon.
|
||||
await page.addInitScript((localServerId) => {
|
||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
||||
platform: "darwin",
|
||||
invoke: async (command: string) => {
|
||||
if (command === "desktop_daemon_status") {
|
||||
return {
|
||||
serverId: localServerId,
|
||||
status: "running",
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: null,
|
||||
home: "",
|
||||
version: null,
|
||||
desktopManaged: true,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
if (command === "get_desktop_settings") {
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: false, keepRunningAfterQuit: true },
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getPendingOpenProject: async () => null,
|
||||
events: { on: async () => () => undefined },
|
||||
};
|
||||
}, serverId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const hostEntries = sidebar.locator('[data-testid^="settings-host-entry-"]');
|
||||
await expect(hostEntries.first()).toHaveAttribute(
|
||||
"data-testid",
|
||||
`settings-host-entry-${serverId}`,
|
||||
);
|
||||
|
||||
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
|
||||
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
|
||||
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
186
packages/app/e2e/settings-navigation.spec.ts
Normal file
186
packages/app/e2e/settings-navigation.spec.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function clickSidebarSection(page: Page, label: string) {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible();
|
||||
await sidebar.getByRole("button", { name: label, exact: true }).click();
|
||||
}
|
||||
|
||||
test.describe("Settings sidebar navigation", () => {
|
||||
test("clicking a sidebar section updates the URL and renders the section", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
await clickSidebarSection(page, "Diagnostics");
|
||||
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("Diagnostics");
|
||||
|
||||
await clickSidebarSection(page, "About");
|
||||
await expect(page).toHaveURL(/\/settings\/about$/);
|
||||
await expect(page.getByText("Version", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("About");
|
||||
|
||||
await clickSidebarSection(page, "General");
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText("General");
|
||||
});
|
||||
|
||||
test("/h/[serverId]/settings redirects to /settings/hosts/[serverId]", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await gotoAppShell(page);
|
||||
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||
);
|
||||
});
|
||||
|
||||
test("the + Add host button opens the add-host method modal", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
await page.getByTestId("settings-add-host").click();
|
||||
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Direct connection" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("sidebar shows a Back to workspace row that leaves /settings", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
|
||||
const backRow = page.getByTestId("settings-back-to-workspace");
|
||||
await expect(backRow).toBeVisible();
|
||||
|
||||
await backRow.click();
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Settings — compact master-detail", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
async function openCompactSettingsRoot(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
// Wait for bootstrap to settle on a host route so storeReady is true before
|
||||
// we navigate into the protected settings stack. A direct page.goto("/settings")
|
||||
// on a cold load is eaten by Stack.Protected while bootstrap is still running.
|
||||
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
|
||||
|
||||
// Drive navigation the same way a user would: open the mobile drawer and tap
|
||||
// the Settings footer icon. This preserves the in-app router state instead of
|
||||
// triggering a full reload through Stack.Protected.
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
const sidebarSettingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(sidebarSettingsButton).toBeVisible();
|
||||
await sidebarSettingsButton.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectCompactSettingsRootList(page: Page) {
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
|
||||
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
test("/settings renders only the sidebar list (no section content)", async ({ page }) => {
|
||||
await openCompactSettingsRoot(page);
|
||||
|
||||
// Sidebar rows are present.
|
||||
await expect(
|
||||
page.getByTestId("settings-sidebar").getByRole("button", { name: "General", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("settings-sidebar")
|
||||
.getByRole("button", { name: "Diagnostics", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("settings-sidebar").getByRole("button", { name: "About", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// Section detail content is NOT rendered at the root.
|
||||
await expectCompactSettingsRootList(page);
|
||||
|
||||
const rootBackButton = page.getByRole("button", { name: "Back", exact: true });
|
||||
await expect(rootBackButton).toBeVisible();
|
||||
await rootBackButton.click();
|
||||
await expect(page).not.toHaveURL(/\/settings(\/|$)/);
|
||||
});
|
||||
|
||||
test("tapping a section pushes /settings/[section] and shows a back button", async ({ page }) => {
|
||||
await openCompactSettingsRoot(page);
|
||||
|
||||
await page
|
||||
.getByTestId("settings-sidebar")
|
||||
.getByRole("button", { name: "Diagnostics", exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/settings\/diagnostics$/);
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
||||
// Sidebar is no longer visible — we are on a detail screen.
|
||||
// (Expo Router stack keeps the previous screen in the DOM but hidden; check
|
||||
// only visible instances.)
|
||||
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("back from a section detail returns to the /settings list", async ({ page }) => {
|
||||
await openCompactSettingsRoot(page);
|
||||
|
||||
await page
|
||||
.getByTestId("settings-sidebar")
|
||||
.getByRole("button", { name: "About", exact: true })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/settings\/about$/);
|
||||
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await expectCompactSettingsRootList(page);
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("tapping a host entry pushes /settings/hosts/[serverId]", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openCompactSettingsRoot(page);
|
||||
|
||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||
);
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("back from a host detail returns to the /settings list", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await openCompactSettingsRoot(page);
|
||||
|
||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||
);
|
||||
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
});
|
||||
});
|
||||
172
packages/app/e2e/settings-toggle-tab-regression.spec.ts
Normal file
172
packages/app/e2e/settings-toggle-tab-regression.spec.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
connectArchiveTabDaemonClient,
|
||||
createIdleAgent,
|
||||
openWorkspaceWithAgents,
|
||||
} from "./helpers/archive-tab";
|
||||
import { getActiveTabTestId, waitForTabBar } from "./helpers/launcher";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function pressSettingsToggleShortcut(page: Page) {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await page.keyboard.press(`${modifier}+Comma`);
|
||||
}
|
||||
|
||||
async function expectSendBehavior(page: Page, expected: "interrupt" | "queue") {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const raw = await page.evaluate(() => localStorage.getItem("@paseo:app-settings"));
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
return (JSON.parse(raw) as { sendBehavior?: string }).sendBehavior ?? null;
|
||||
})
|
||||
.toBe(expected);
|
||||
}
|
||||
|
||||
async function openAgentRouteAndExpectFocused(input: {
|
||||
page: Page;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId: string;
|
||||
}) {
|
||||
const expectedActiveTabId = `workspace-tab-agent_${input.agentId}`;
|
||||
await input.page.goto(
|
||||
buildHostAgentDetailRoute(input.serverId, input.agentId, input.workspaceId),
|
||||
);
|
||||
await input.page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await waitForTabBar(input.page);
|
||||
await expect(
|
||||
input.page.getByTestId(expectedActiveTabId).filter({ visible: true }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await expect(getActiveTabTestId(input.page)).resolves.toBe(expectedActiveTabId);
|
||||
}
|
||||
|
||||
test.describe("Settings toggle tab regression", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("toggling settings after changing a setting returns to the same workspace tab", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const client = await connectArchiveTabDaemonClient();
|
||||
const repo = await createTempGitRepo("settings-toggle-tab-");
|
||||
const agentIds: string[] = [];
|
||||
|
||||
try {
|
||||
const firstAgent = await createIdleAgent(client, {
|
||||
cwd: repo.path,
|
||||
title: `settings-toggle-a-${Date.now()}`,
|
||||
});
|
||||
const secondAgent = await createIdleAgent(client, {
|
||||
cwd: repo.path,
|
||||
title: `settings-toggle-b-${Date.now()}`,
|
||||
});
|
||||
agentIds.push(firstAgent.id, secondAgent.id);
|
||||
|
||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||
await waitForTabBar(page);
|
||||
|
||||
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
|
||||
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await expect(page).toHaveURL(/\/settings\/general$/);
|
||||
|
||||
await page.getByRole("button", { name: "Queue", exact: true }).click();
|
||||
await expectSendBehavior(page, "queue");
|
||||
await page.getByRole("button", { name: "Interrupt", exact: true }).click();
|
||||
await expectSendBehavior(page, "interrupt");
|
||||
|
||||
await pressSettingsToggleShortcut(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, repo.path));
|
||||
await waitForTabBar(page);
|
||||
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||
|
||||
await page.reload();
|
||||
await waitForTabBar(page);
|
||||
await expect(page.getByTestId(expectedActiveTabId).filter({ visible: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||
} finally {
|
||||
for (const agentId of agentIds) {
|
||||
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("refresh after navigating between agent routes keeps the latest agent focused", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const client = await connectArchiveTabDaemonClient();
|
||||
const repo = await createTempGitRepo("agent-route-refresh-");
|
||||
const agentIds: string[] = [];
|
||||
|
||||
try {
|
||||
const firstAgent = await createIdleAgent(client, {
|
||||
cwd: repo.path,
|
||||
title: `agent-route-refresh-a-${Date.now()}`,
|
||||
});
|
||||
const secondAgent = await createIdleAgent(client, {
|
||||
cwd: repo.path,
|
||||
title: `agent-route-refresh-b-${Date.now()}`,
|
||||
});
|
||||
agentIds.push(firstAgent.id, secondAgent.id);
|
||||
|
||||
await openAgentRouteAndExpectFocused({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: repo.path,
|
||||
agentId: firstAgent.id,
|
||||
});
|
||||
await openAgentRouteAndExpectFocused({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: repo.path,
|
||||
agentId: secondAgent.id,
|
||||
});
|
||||
|
||||
const expectedActiveTabId = `workspace-tab-agent_${secondAgent.id}`;
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
await page.reload();
|
||||
await waitForTabBar(page);
|
||||
await expect(
|
||||
page.getByTestId(expectedActiveTabId).filter({ visible: true }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
await expect(getActiveTabTestId(page)).resolves.toBe(expectedActiveTabId);
|
||||
}
|
||||
} finally {
|
||||
for (const agentId of agentIds) {
|
||||
await archiveAgentFromDaemon(client, agentId).catch(() => undefined);
|
||||
}
|
||||
await client.close().catch(() => undefined);
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
182
packages/app/e2e/sidebar-workspace.spec.ts
Normal file
182
packages/app/e2e/sidebar-workspace.spec.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { expectWorkspaceHeader } from "./helpers/workspace-ui";
|
||||
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function getWorkspaceRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function setGitHubRemote(repoPath: string): void {
|
||||
execSync("git remote set-url origin https://github.com/test-owner/test-repo.git", {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
|
||||
async function createTempDirectory(prefix = "paseo-e2e-dir-") {
|
||||
const tempRoot = process.platform === "win32" ? tmpdir() : await realpath("/tmp");
|
||||
const dirPath = await mkdtemp(path.join(tempRoot, prefix));
|
||||
await writeFile(path.join(dirPath, "README.md"), "# Temp Directory\n");
|
||||
return {
|
||||
path: dirPath,
|
||||
cleanup: async () => {
|
||||
await rm(dirPath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function openProjectViaDaemon(
|
||||
client: Awaited<ReturnType<typeof connectWorkspaceSetupClient>>,
|
||||
cwd: string,
|
||||
): Promise<{ id: string; name: string }> {
|
||||
const result = await client.openProject(cwd);
|
||||
if (!result.workspace || result.error) {
|
||||
throw new Error(result.error ?? `Failed to open project ${cwd}`);
|
||||
}
|
||||
return {
|
||||
id: String(result.workspace.id),
|
||||
name: result.workspace.name,
|
||||
};
|
||||
}
|
||||
|
||||
async function openWorkspaceFromSidebar(
|
||||
page: import("@playwright/test").Page,
|
||||
workspaceId: string,
|
||||
) {
|
||||
const row = page.getByTestId(getWorkspaceRowTestId(workspaceId));
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
return row;
|
||||
}
|
||||
|
||||
async function waitForSidebarProject(page: import("@playwright/test").Page, projectName: string) {
|
||||
const row = page
|
||||
.getByRole("button", {
|
||||
name: new RegExp(escapeRegex(projectName), "i"),
|
||||
})
|
||||
.first();
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
return row;
|
||||
}
|
||||
|
||||
async function waitForSidebarWorkspace(page: import("@playwright/test").Page, workspaceId: string) {
|
||||
const row = page.getByTestId(getWorkspaceRowTestId(workspaceId));
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
return row;
|
||||
}
|
||||
|
||||
test.describe("Sidebar workspace list", () => {
|
||||
test("project with GitHub remote shows owner/repo name in sidebar", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("sidebar-remote-", { withRemote: true });
|
||||
|
||||
try {
|
||||
setGitHubRemote(repo.path);
|
||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, "test-owner/test-repo");
|
||||
await waitForSidebarWorkspace(page, workspace.id);
|
||||
|
||||
const projectRow = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: "test-owner/test-repo" })
|
||||
.first();
|
||||
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow).not.toContainText(path.basename(repo.path));
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("project shows workspace under it", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("sidebar-workspace-under-project-");
|
||||
|
||||
try {
|
||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||
await gotoAppShell(page);
|
||||
|
||||
await waitForSidebarProject(page, path.basename(repo.path));
|
||||
await waitForSidebarWorkspace(page, workspace.id);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("non-git project shows directory name", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const project = await createTempDirectory("sidebar-directory-");
|
||||
|
||||
try {
|
||||
await openProjectViaDaemon(client, project.path);
|
||||
await gotoAppShell(page);
|
||||
|
||||
const projectRow = await waitForSidebarProject(page, path.basename(project.path));
|
||||
await expect(projectRow).toContainText(path.basename(project.path));
|
||||
} finally {
|
||||
await client.close();
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace header shows correct title and subtitle", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("sidebar-header-", { withRemote: true });
|
||||
|
||||
try {
|
||||
setGitHubRemote(repo.path);
|
||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, "test-owner/test-repo");
|
||||
await waitForSidebarWorkspace(page, workspace.id);
|
||||
await openWorkspaceFromSidebar(page, workspace.id);
|
||||
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.name,
|
||||
subtitle: "test-owner/test-repo",
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("git project shows branch name in workspace row", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("sidebar-branch-");
|
||||
|
||||
try {
|
||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(repo.path));
|
||||
|
||||
expect(workspace.name).toBe("main");
|
||||
await expect(await waitForSidebarWorkspace(page, workspace.id)).toContainText("main");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
64
packages/app/e2e/startup-loading.spec.ts
Normal file
64
packages/app/e2e/startup-loading.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { test } from "./fixtures";
|
||||
import { startupScenario } from "./helpers/startup-dsl";
|
||||
|
||||
function getE2EDaemonPort(): string {
|
||||
const port = process.env.E2E_DAEMON_PORT;
|
||||
if (!port) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function getE2EServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
test.describe("Startup loading presentation", () => {
|
||||
test("mobile reconnect keeps connection recovery actions visible", async ({ page }) => {
|
||||
const startup = await startupScenario(page)
|
||||
.withMobileViewport()
|
||||
.withSavedHost({
|
||||
serverId: "srv_unreachable_mobile",
|
||||
label: "Dev",
|
||||
endpoint: "127.0.0.1:45678",
|
||||
})
|
||||
.openRoot();
|
||||
|
||||
await startup.expectsReconnectWelcome();
|
||||
await startup.expectsNoSavedHostStatus({ label: "Dev" });
|
||||
await startup.expectsNoLocalServerStartupCopy();
|
||||
});
|
||||
|
||||
test("desktop daemon bootstrap keeps the local server startup copy desktop-only", async ({
|
||||
page,
|
||||
}) => {
|
||||
const startup = await startupScenario(page)
|
||||
.withPendingDesktopDaemon()
|
||||
.withBlockedPort(getE2EDaemonPort())
|
||||
.openRoot();
|
||||
|
||||
await startup.expectsDesktopDaemonStartup();
|
||||
await startup.expectsSidebarHidden();
|
||||
await startup.expectsNoUndefinedRoute();
|
||||
});
|
||||
|
||||
test("host-route refresh does not render route chrome around the bootstrap splash", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getE2EServerId();
|
||||
const startup = await startupScenario(page)
|
||||
.withPendingDesktopDaemon()
|
||||
.withBlockedPort(getE2EDaemonPort())
|
||||
.openHostWorkspace({
|
||||
serverId,
|
||||
workspaceId: "workspace-1",
|
||||
});
|
||||
|
||||
await startup.expectsDesktopDaemonStartup();
|
||||
await startup.expectsSidebarHidden();
|
||||
});
|
||||
});
|
||||
543
packages/app/e2e/startup-wire-metrics.spec.ts
Normal file
543
packages/app/e2e/startup-wire-metrics.spec.ts
Normal file
@@ -0,0 +1,543 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { CDPSession, Page, TestInfo } from "@playwright/test";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
|
||||
type WireDirection = "sent" | "received";
|
||||
type WirePhase = "startup" | "workspace_clicks";
|
||||
|
||||
interface ParsedWireMessage {
|
||||
type: string | null;
|
||||
requestId: string | null;
|
||||
entryCount: number | null;
|
||||
hasMore: boolean | null;
|
||||
providerEntries: ProviderSnapshotWireEntry[] | null;
|
||||
}
|
||||
|
||||
type WireFrameRecord = ParsedWireMessage & {
|
||||
phase: WirePhase;
|
||||
direction: WireDirection;
|
||||
bytes: number;
|
||||
};
|
||||
|
||||
interface ProviderSnapshotWireEntry {
|
||||
provider: string;
|
||||
status: string | null;
|
||||
modelCount: number;
|
||||
modeCount: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
interface WebSocketFrameEvent {
|
||||
requestId: string;
|
||||
response: {
|
||||
opcode: number;
|
||||
payloadData: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface WireSummary {
|
||||
totalFrames: number;
|
||||
totalBytes: number;
|
||||
byDirection: Record<WireDirection, { frames: number; bytes: number }>;
|
||||
byPhase: Record<
|
||||
WirePhase,
|
||||
{
|
||||
frames: number;
|
||||
bytes: number;
|
||||
byType: Array<{ type: string; frames: number; bytes: number }>;
|
||||
rpcCounts: Array<{ requestType: string; count: number }>;
|
||||
rpcs: Array<{ requestType: string; requestId: string; responseType: string | null }>;
|
||||
}
|
||||
>;
|
||||
fetchPages: Array<{
|
||||
phase: WirePhase;
|
||||
type: string;
|
||||
requestId: string | null;
|
||||
entries: number | null;
|
||||
hasMore: boolean | null;
|
||||
bytes: number;
|
||||
}>;
|
||||
clickedWorkspaces: Array<{ testId: string; frames: number; bytes: number }>;
|
||||
providerSnapshots: Array<{
|
||||
phase: WirePhase;
|
||||
type: string;
|
||||
requestId: string | null;
|
||||
totalModels: number;
|
||||
totalModes: number;
|
||||
bytes: number;
|
||||
providers: ProviderSnapshotWireEntry[];
|
||||
}>;
|
||||
providerSnapshotTotals: Array<{
|
||||
phase: WirePhase;
|
||||
provider: string;
|
||||
frames: number;
|
||||
bytes: number;
|
||||
maxModels: number;
|
||||
maxModes: number;
|
||||
statuses: string[];
|
||||
}>;
|
||||
fork: {
|
||||
sourceHome: string | null;
|
||||
targetHome: string | null;
|
||||
copiedFiles: number | null;
|
||||
copiedBytes: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
function extractWorkspaceTestIds(elements: Element[]): string[] {
|
||||
const result: string[] = [];
|
||||
for (const element of elements.slice(0, 3)) {
|
||||
const value = element.getAttribute("data-testid");
|
||||
if (value) {
|
||||
result.push(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
class WireMonitor {
|
||||
private phase: WirePhase = "startup";
|
||||
private session: CDPSession | null = null;
|
||||
readonly records: WireFrameRecord[] = [];
|
||||
|
||||
async start(page: Page): Promise<void> {
|
||||
this.session = await page.context().newCDPSession(page);
|
||||
await this.session.send("Network.enable");
|
||||
this.session.on("Network.webSocketFrameSent", (event: WebSocketFrameEvent) => {
|
||||
this.record("sent", event);
|
||||
});
|
||||
this.session.on("Network.webSocketFrameReceived", (event: WebSocketFrameEvent) => {
|
||||
this.record("received", event);
|
||||
});
|
||||
}
|
||||
|
||||
setPhase(phase: WirePhase): void {
|
||||
this.phase = phase;
|
||||
}
|
||||
|
||||
hasCompletedStartupFetches(): boolean {
|
||||
return (
|
||||
this.records.some(
|
||||
(record) =>
|
||||
record.direction === "received" &&
|
||||
record.type === "fetch_agents_response" &&
|
||||
record.hasMore === false,
|
||||
) &&
|
||||
this.records.some(
|
||||
(record) =>
|
||||
record.direction === "received" &&
|
||||
record.type === "fetch_workspaces_response" &&
|
||||
record.hasMore === false,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
summarize(clickedWorkspaces: WireSummary["clickedWorkspaces"]): WireSummary {
|
||||
return {
|
||||
totalFrames: this.records.length,
|
||||
totalBytes: sumBytes(this.records),
|
||||
byDirection: {
|
||||
sent: summarizeDirection(this.records, "sent"),
|
||||
received: summarizeDirection(this.records, "received"),
|
||||
},
|
||||
byPhase: {
|
||||
startup: summarizePhase(this.records, "startup"),
|
||||
workspace_clicks: summarizePhase(this.records, "workspace_clicks"),
|
||||
},
|
||||
fetchPages: this.records
|
||||
.filter(
|
||||
(record) =>
|
||||
record.direction === "received" &&
|
||||
(record.type === "fetch_agents_response" ||
|
||||
record.type === "fetch_workspaces_response"),
|
||||
)
|
||||
.map((record) => ({
|
||||
phase: record.phase,
|
||||
type: record.type ?? "unknown",
|
||||
requestId: record.requestId,
|
||||
entries: record.entryCount,
|
||||
hasMore: record.hasMore,
|
||||
bytes: record.bytes,
|
||||
})),
|
||||
clickedWorkspaces,
|
||||
providerSnapshots: this.records
|
||||
.filter((record) => record.direction === "received" && record.providerEntries)
|
||||
.map((record) => ({
|
||||
phase: record.phase,
|
||||
type: record.type ?? "unknown",
|
||||
requestId: record.requestId,
|
||||
totalModels: sumProviderModels(record.providerEntries ?? []),
|
||||
totalModes: sumProviderModes(record.providerEntries ?? []),
|
||||
bytes: record.bytes,
|
||||
providers: record.providerEntries ?? [],
|
||||
})),
|
||||
providerSnapshotTotals: summarizeProviderSnapshots(this.records),
|
||||
fork: {
|
||||
sourceHome: process.env.E2E_FORK_SOURCE_PASEO_HOME ?? null,
|
||||
targetHome: process.env.E2E_FORK_TARGET_PASEO_HOME ?? null,
|
||||
copiedFiles: parseOptionalNumber(process.env.E2E_FORK_COPIED_FILES),
|
||||
copiedBytes: parseOptionalNumber(process.env.E2E_FORK_COPIED_BYTES),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private record(direction: WireDirection, event: WebSocketFrameEvent): void {
|
||||
if (event.response.opcode !== 1) {
|
||||
this.records.push({
|
||||
phase: this.phase,
|
||||
direction,
|
||||
bytes: Buffer.byteLength(event.response.payloadData),
|
||||
type: `opcode:${event.response.opcode}`,
|
||||
requestId: null,
|
||||
entryCount: null,
|
||||
hasMore: null,
|
||||
providerEntries: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.records.push({
|
||||
phase: this.phase,
|
||||
direction,
|
||||
bytes: Buffer.byteLength(event.response.payloadData, "utf8"),
|
||||
...parseWireMessage(event.response.payloadData),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function parseOptionalNumber(value: string | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseWireMessage(payloadData: string): ParsedWireMessage {
|
||||
try {
|
||||
const parsed = JSON.parse(payloadData) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return emptyParsedWireMessage();
|
||||
}
|
||||
const envelope = parsed as {
|
||||
type?: unknown;
|
||||
message?: unknown;
|
||||
};
|
||||
const unwrapped =
|
||||
envelope.type === "session" && envelope.message && typeof envelope.message === "object"
|
||||
? envelope.message
|
||||
: parsed;
|
||||
const message = unwrapped as {
|
||||
type?: unknown;
|
||||
requestId?: unknown;
|
||||
payload?: {
|
||||
requestId?: unknown;
|
||||
entries?: unknown;
|
||||
agents?: unknown;
|
||||
pageInfo?: { hasMore?: unknown };
|
||||
};
|
||||
};
|
||||
const entries = readMessageEntries(message.payload);
|
||||
const hasMore = readMessageHasMore(message.payload);
|
||||
const messageType = typeof message.type === "string" ? message.type : null;
|
||||
const providerEntries = isProviderSnapshotMessage(messageType)
|
||||
? parseProviderSnapshotEntries(message.payload)
|
||||
: null;
|
||||
return {
|
||||
type: messageType,
|
||||
requestId: readMessageRequestId(message),
|
||||
entryCount: entries ? entries.length : null,
|
||||
hasMore,
|
||||
providerEntries,
|
||||
};
|
||||
} catch {
|
||||
return emptyParsedWireMessage();
|
||||
}
|
||||
}
|
||||
|
||||
function readMessageRequestId(message: {
|
||||
requestId?: unknown;
|
||||
payload?: { requestId?: unknown };
|
||||
}): string | null {
|
||||
if (typeof message.requestId === "string") {
|
||||
return message.requestId;
|
||||
}
|
||||
if (typeof message.payload?.requestId === "string") {
|
||||
return message.payload.requestId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isProviderSnapshotMessage(messageType: string | null): boolean {
|
||||
return (
|
||||
messageType === "get_providers_snapshot_response" || messageType === "providers_snapshot_update"
|
||||
);
|
||||
}
|
||||
|
||||
function readMessageEntries(
|
||||
payload:
|
||||
| {
|
||||
entries?: unknown;
|
||||
agents?: unknown;
|
||||
}
|
||||
| undefined,
|
||||
): unknown[] | null {
|
||||
if (Array.isArray(payload?.entries)) {
|
||||
return payload.entries;
|
||||
}
|
||||
if (Array.isArray(payload?.agents)) {
|
||||
return payload.agents;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readMessageHasMore(
|
||||
payload: { pageInfo?: { hasMore?: unknown } } | undefined,
|
||||
): boolean | null {
|
||||
return typeof payload?.pageInfo?.hasMore === "boolean" ? payload.pageInfo.hasMore : null;
|
||||
}
|
||||
|
||||
function emptyParsedWireMessage(): ParsedWireMessage {
|
||||
return {
|
||||
type: null,
|
||||
requestId: null,
|
||||
entryCount: null,
|
||||
hasMore: null,
|
||||
providerEntries: null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseProviderSnapshotEntries(payload: unknown): ProviderSnapshotWireEntry[] | null {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return null;
|
||||
}
|
||||
const entries = (payload as { entries?: unknown }).entries;
|
||||
if (!Array.isArray(entries)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providerEntries: ProviderSnapshotWireEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
const provider = (entry as { provider?: unknown }).provider;
|
||||
if (typeof provider !== "string") {
|
||||
continue;
|
||||
}
|
||||
const models = (entry as { models?: unknown }).models;
|
||||
const modes = (entry as { modes?: unknown }).modes;
|
||||
const status = (entry as { status?: unknown }).status;
|
||||
providerEntries.push({
|
||||
provider,
|
||||
status: typeof status === "string" ? status : null,
|
||||
modelCount: Array.isArray(models) ? models.length : 0,
|
||||
modeCount: Array.isArray(modes) ? modes.length : 0,
|
||||
bytes: Buffer.byteLength(JSON.stringify(entry), "utf8"),
|
||||
});
|
||||
}
|
||||
|
||||
return providerEntries.length > 0 ? providerEntries : null;
|
||||
}
|
||||
|
||||
function sumBytes(records: WireFrameRecord[]): number {
|
||||
return records.reduce((sum, record) => sum + record.bytes, 0);
|
||||
}
|
||||
|
||||
function summarizeDirection(records: WireFrameRecord[], direction: WireDirection) {
|
||||
const selected = records.filter((record) => record.direction === direction);
|
||||
return {
|
||||
frames: selected.length,
|
||||
bytes: sumBytes(selected),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizePhase(records: WireFrameRecord[], phase: WirePhase) {
|
||||
const selected = records.filter((record) => record.phase === phase);
|
||||
return {
|
||||
frames: selected.length,
|
||||
bytes: sumBytes(selected),
|
||||
byType: summarizeByType(selected),
|
||||
rpcCounts: summarizeRpcCounts(selected),
|
||||
rpcs: summarizeRpcs(selected),
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeByType(records: WireFrameRecord[]): Array<{
|
||||
type: string;
|
||||
frames: number;
|
||||
bytes: number;
|
||||
}> {
|
||||
const byType = new Map<string, { frames: number; bytes: number }>();
|
||||
for (const record of records) {
|
||||
const type = record.type ?? "unknown";
|
||||
const current = byType.get(type) ?? { frames: 0, bytes: 0 };
|
||||
current.frames += 1;
|
||||
current.bytes += record.bytes;
|
||||
byType.set(type, current);
|
||||
}
|
||||
return [...byType.entries()]
|
||||
.map(([type, value]) => Object.assign({ type }, value))
|
||||
.sort((left, right) => right.bytes - left.bytes);
|
||||
}
|
||||
|
||||
function summarizeRpcs(records: WireFrameRecord[]): WireSummary["byPhase"][WirePhase]["rpcs"] {
|
||||
const responsesByRequestId = new Map<string, string>();
|
||||
for (const record of records) {
|
||||
if (record.direction !== "received" || !record.requestId) {
|
||||
continue;
|
||||
}
|
||||
responsesByRequestId.set(record.requestId, record.type ?? "unknown");
|
||||
}
|
||||
|
||||
return records
|
||||
.filter((record) => record.direction === "sent" && record.requestId && record.type)
|
||||
.map((record) => ({
|
||||
requestType: record.type ?? "unknown",
|
||||
requestId: record.requestId ?? "",
|
||||
responseType: responsesByRequestId.get(record.requestId ?? "") ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
function summarizeRpcCounts(
|
||||
records: WireFrameRecord[],
|
||||
): Array<{ requestType: string; count: number }> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const record of records) {
|
||||
if (record.direction !== "sent" || !record.type || !record.requestId) {
|
||||
continue;
|
||||
}
|
||||
counts.set(record.type, (counts.get(record.type) ?? 0) + 1);
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.map(([requestType, count]) => ({ requestType, count }))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.count - left.count || left.requestType.localeCompare(right.requestType),
|
||||
);
|
||||
}
|
||||
|
||||
function sumProviderModels(entries: ProviderSnapshotWireEntry[]): number {
|
||||
return entries.reduce((sum, entry) => sum + entry.modelCount, 0);
|
||||
}
|
||||
|
||||
function sumProviderModes(entries: ProviderSnapshotWireEntry[]): number {
|
||||
return entries.reduce((sum, entry) => sum + entry.modeCount, 0);
|
||||
}
|
||||
|
||||
function summarizeProviderSnapshots(
|
||||
records: WireFrameRecord[],
|
||||
): WireSummary["providerSnapshotTotals"] {
|
||||
const byProvider = new Map<
|
||||
string,
|
||||
{
|
||||
phase: WirePhase;
|
||||
provider: string;
|
||||
frames: number;
|
||||
bytes: number;
|
||||
maxModels: number;
|
||||
maxModes: number;
|
||||
statuses: Set<string>;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const record of records) {
|
||||
if (record.direction !== "received" || !record.providerEntries) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of record.providerEntries) {
|
||||
const key = `${record.phase}:${entry.provider}`;
|
||||
const current = byProvider.get(key) ?? {
|
||||
phase: record.phase,
|
||||
provider: entry.provider,
|
||||
frames: 0,
|
||||
bytes: 0,
|
||||
maxModels: 0,
|
||||
maxModes: 0,
|
||||
statuses: new Set<string>(),
|
||||
};
|
||||
current.frames += 1;
|
||||
current.bytes += entry.bytes;
|
||||
current.maxModels = Math.max(current.maxModels, entry.modelCount);
|
||||
current.maxModes = Math.max(current.maxModes, entry.modeCount);
|
||||
if (entry.status) {
|
||||
current.statuses.add(entry.status);
|
||||
}
|
||||
byProvider.set(key, current);
|
||||
}
|
||||
}
|
||||
|
||||
return [...byProvider.values()]
|
||||
.map((entry) => ({
|
||||
phase: entry.phase,
|
||||
provider: entry.provider,
|
||||
frames: entry.frames,
|
||||
bytes: entry.bytes,
|
||||
maxModels: entry.maxModels,
|
||||
maxModes: entry.maxModes,
|
||||
statuses: [...entry.statuses].sort(),
|
||||
}))
|
||||
.sort((left, right) => right.bytes - left.bytes);
|
||||
}
|
||||
|
||||
async function attachSummary(testInfo: TestInfo, summary: WireSummary): Promise<void> {
|
||||
await testInfo.attach("startup-wire-metrics.json", {
|
||||
body: JSON.stringify(summary, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("ad hoc startup wire metrics", () => {
|
||||
test.skip(
|
||||
process.env.E2E_WIRE_METRICS !== "1",
|
||||
"Set E2E_WIRE_METRICS=1 to run this ad hoc measurement.",
|
||||
);
|
||||
|
||||
test("measures startup hydration and workspace navigation websocket traffic", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const monitor = new WireMonitor();
|
||||
await monitor.start(page);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page, 120_000);
|
||||
await expect.poll(() => monitor.hasCompletedStartupFetches(), { timeout: 120_000 }).toBe(true);
|
||||
await page.waitForTimeout(1_000);
|
||||
|
||||
const workspaceTestIds = await page
|
||||
.locator('[data-testid^="sidebar-workspace-row-"]:visible')
|
||||
.evaluateAll(extractWorkspaceTestIds);
|
||||
|
||||
monitor.setPhase("workspace_clicks");
|
||||
const clickedWorkspaces: WireSummary["clickedWorkspaces"] = [];
|
||||
for (const testId of workspaceTestIds) {
|
||||
const row = page.getByTestId(testId);
|
||||
if (!(await row.isVisible().catch(() => false))) {
|
||||
continue;
|
||||
}
|
||||
const beforeFrames = monitor.records.length;
|
||||
const beforeBytes = sumBytes(monitor.records);
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
await page.waitForTimeout(1_000);
|
||||
clickedWorkspaces.push({
|
||||
testId,
|
||||
frames: monitor.records.length - beforeFrames,
|
||||
bytes: sumBytes(monitor.records) - beforeBytes,
|
||||
});
|
||||
}
|
||||
|
||||
const summary = monitor.summarize(clickedWorkspaces);
|
||||
await attachSummary(testInfo, summary);
|
||||
console.log("PASEO_STARTUP_WIRE_METRICS_BEGIN");
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
console.log("PASEO_STARTUP_WIRE_METRICS_END");
|
||||
|
||||
expect(summary.byPhase.startup.byType.length).toBeGreaterThan(0);
|
||||
expect(clickedWorkspaces.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
249
packages/app/e2e/terminal-alternate-screen.spec.ts
Normal file
249
packages/app/e2e/terminal-alternate-screen.spec.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl";
|
||||
import { getTabTestIds } from "./helpers/launcher";
|
||||
import {
|
||||
installTerminalRenderProbe,
|
||||
readTerminalRenderProbe,
|
||||
resetTerminalRenderProbe,
|
||||
startTerminalFrameSampling,
|
||||
summarizeTerminalRenderProbe,
|
||||
} from "./helpers/terminal-probes";
|
||||
import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf";
|
||||
|
||||
interface TerminalLayoutMetrics {
|
||||
visibleSurfaceCount: number;
|
||||
surfaceHeight: number;
|
||||
rowCount: number;
|
||||
rows: number | null;
|
||||
cols: number | null;
|
||||
renderedRowsHeight: number;
|
||||
tabIds: string[];
|
||||
}
|
||||
|
||||
async function readTerminalLayoutMetrics(page: Page): Promise<TerminalLayoutMetrics> {
|
||||
const tabIds = await getTabTestIds(page);
|
||||
return page.evaluate((currentTabIds) => {
|
||||
const visibleSurfaces = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-testid="terminal-surface"]'),
|
||||
).filter((candidate) => {
|
||||
const rect = candidate.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
});
|
||||
const surface = visibleSurfaces[0] ?? null;
|
||||
const surfaceRect = surface?.getBoundingClientRect() ?? null;
|
||||
// xterm.js exposes `.xterm-screen` regardless of renderer (DOM, canvas, WebGL),
|
||||
// so use it as the canonical "rendered surface" rect rather than `.xterm-rows`
|
||||
// which is only populated by the DOM renderer.
|
||||
const xtermScreen = surface?.querySelector<HTMLElement>(".xterm-screen") ?? null;
|
||||
const xtermScreenRect = xtermScreen?.getBoundingClientRect() ?? null;
|
||||
const term = (
|
||||
window as Window & {
|
||||
__paseoTerminal?: {
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
};
|
||||
}
|
||||
).__paseoTerminal;
|
||||
|
||||
return {
|
||||
visibleSurfaceCount: visibleSurfaces.length,
|
||||
surfaceHeight: surfaceRect?.height ?? 0,
|
||||
rowCount: typeof term?.rows === "number" ? term.rows : 0,
|
||||
rows: typeof term?.rows === "number" ? term.rows : null,
|
||||
cols: typeof term?.cols === "number" ? term.cols : null,
|
||||
renderedRowsHeight: xtermScreenRect?.height ?? 0,
|
||||
tabIds: currentTabIds,
|
||||
};
|
||||
}, tabIds);
|
||||
}
|
||||
|
||||
async function waitForAlternateScreenExit(page: Page, afterAlt: string, timeout: number) {
|
||||
let lastBufferText = "";
|
||||
let lastProbe = await readTerminalRenderProbe(page);
|
||||
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
lastBufferText = await getTerminalBufferText(page);
|
||||
lastProbe = await readTerminalRenderProbe(page);
|
||||
return (
|
||||
lastProbe.altEnterWrites > 0 &&
|
||||
lastProbe.altExitWrites > 0 &&
|
||||
lastBufferText.includes(afterAlt)
|
||||
);
|
||||
},
|
||||
{
|
||||
intervals: [50],
|
||||
message: `wait for alternate-screen exit and ${afterAlt} output`,
|
||||
timeout,
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Timed out waiting for alternate-screen exit: ${message}\n${JSON.stringify(
|
||||
{
|
||||
afterAlt,
|
||||
probe: summarizeTerminalRenderProbe(lastProbe),
|
||||
bufferTextTail: lastBufferText.slice(-500),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
return lastProbe;
|
||||
}
|
||||
|
||||
test.describe("Terminal alternate-screen transitions", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
let harness: TerminalE2EHarness;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-alt-" });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await harness?.cleanup();
|
||||
});
|
||||
|
||||
test("restores the normal screen after full-screen alternate buffer exit without remounting", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await installTerminalRenderProbe(page);
|
||||
|
||||
await withTerminalInApp(page, harness, { name: "alternate-screen" }, async () => {
|
||||
await harness.setupPrompt(page);
|
||||
|
||||
const terminal = harness.terminalSurface(page);
|
||||
const historyReady = `HISTORY_READY_${Date.now()}`;
|
||||
await terminal.pressSequentially(
|
||||
`for i in $(seq 1 80); do echo HISTORY_$i; done; echo ${historyReady}\n`,
|
||||
{ delay: 0 },
|
||||
);
|
||||
function hasHistoryReady(text: string): boolean {
|
||||
return text.includes(historyReady);
|
||||
}
|
||||
await waitForTerminalContent(page, hasHistoryReady, 10_000);
|
||||
|
||||
await resetTerminalRenderProbe(page);
|
||||
await page.waitForTimeout(500);
|
||||
const settledProbe = await readTerminalRenderProbe(page);
|
||||
expect(settledProbe.resetWrites, "terminal should be idle before alternate-screen act").toBe(
|
||||
0,
|
||||
);
|
||||
await resetTerminalRenderProbe(page);
|
||||
|
||||
const afterAlt = `AFTER_ALT_${Date.now()}`;
|
||||
await startTerminalFrameSampling(page);
|
||||
await terminal.pressSequentially(
|
||||
`printf '\\033[?1049h\\033[2J\\033[HALT_SCREEN_TOP\\n'; sleep 0.25; printf '\\033[?1049l'; echo ${afterAlt}\n`,
|
||||
{ delay: 0 },
|
||||
);
|
||||
const probe = await waitForAlternateScreenExit(page, afterAlt, 10_000);
|
||||
const probeSummary = summarizeTerminalRenderProbe(probe);
|
||||
|
||||
await testInfo.attach("alternate-screen-probe", {
|
||||
body: JSON.stringify({ summary: probeSummary, probe }, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
|
||||
expect(probe.setCount, "terminal instance should not be replaced after attach").toBe(0);
|
||||
expect(probe.unsetCount, "terminal instance should not be unset after attach").toBe(0);
|
||||
expect(
|
||||
probe.altEnterWrites,
|
||||
"test command should enter the alternate screen",
|
||||
).toBeGreaterThan(0);
|
||||
expect(probe.altExitWrites, "test command should exit the alternate screen").toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
expect(probe.resetWrites, "alternate-screen exit should not replay a snapshot reset").toBe(0);
|
||||
|
||||
const finalBufferText = await getTerminalBufferText(page);
|
||||
expect(finalBufferText).toContain(historyReady);
|
||||
expect(finalBufferText).toContain(afterAlt);
|
||||
|
||||
function isSuspiciousFrame(frame: (typeof probe.frames)[number]): boolean {
|
||||
return (
|
||||
frame.text.includes("$") &&
|
||||
!frame.text.includes(historyReady) &&
|
||||
!frame.text.includes(afterAlt) &&
|
||||
frame.nonEmptyRows <= 2 &&
|
||||
(frame.firstNonEmptyRow ?? Number.POSITIVE_INFINITY) <= 1
|
||||
);
|
||||
}
|
||||
const suspiciousFrames = probe.frames.filter(isSuspiciousFrame);
|
||||
|
||||
expect(
|
||||
suspiciousFrames,
|
||||
"normal-screen restore should not flash to a mostly blank prompt-at-top frame",
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test("opening vim in a new terminal fills the terminal surface", async ({ page }, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await installTerminalRenderProbe(page);
|
||||
|
||||
await withTerminalInApp(page, harness, { name: "vim-layout" }, async () => {
|
||||
await harness.setupPrompt(page);
|
||||
const terminalSurface = harness.terminalSurface(page).first();
|
||||
await expect(terminalSurface).toBeVisible({ timeout: 15_000 });
|
||||
await terminalSurface.click();
|
||||
|
||||
const beforeVim = await readTerminalLayoutMetrics(page);
|
||||
|
||||
await startTerminalFrameSampling(page, 2_000);
|
||||
await terminalSurface.pressSequentially("vim -Nu NONE -n\n", { delay: 0 });
|
||||
await page.waitForTimeout(1_500);
|
||||
|
||||
const probe = await readTerminalRenderProbe(page);
|
||||
const samples: TerminalLayoutMetrics[] = [];
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
samples.push(await readTerminalLayoutMetrics(page));
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
|
||||
await testInfo.attach("reused-terminal-layout-metrics", {
|
||||
body: JSON.stringify(
|
||||
{
|
||||
beforeVim,
|
||||
probe: summarizeTerminalRenderProbe(probe),
|
||||
samples,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
contentType: "application/json",
|
||||
});
|
||||
|
||||
const firstSample = samples[0];
|
||||
const finalSample = samples.at(-1);
|
||||
expect(firstSample, "expected an initial layout sample").toBeTruthy();
|
||||
expect(finalSample, "expected a final layout sample").toBeTruthy();
|
||||
|
||||
expect(
|
||||
finalSample?.visibleSurfaceCount ?? 0,
|
||||
"opening vim should leave exactly one visible terminal surface",
|
||||
).toBe(1);
|
||||
expect(
|
||||
finalSample?.renderedRowsHeight ?? 0,
|
||||
"vim should render rows that fill most of the terminal surface",
|
||||
).toBeGreaterThan((finalSample?.surfaceHeight ?? 0) * 0.75);
|
||||
expect(
|
||||
finalSample?.rowCount ?? 0,
|
||||
"vim should leave a substantial number of rendered rows",
|
||||
).toBeGreaterThan(Math.max(10, Math.floor((beforeVim.rowCount || 0) * 0.75)));
|
||||
});
|
||||
});
|
||||
});
|
||||
350
packages/app/e2e/terminal-keystroke-stress.spec.ts
Normal file
350
packages/app/e2e/terminal-keystroke-stress.spec.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { TerminalE2EHarness, type TerminalInstance } from "./helpers/terminal-dsl";
|
||||
import {
|
||||
installTerminalKeystrokeStressProbe,
|
||||
readTerminalKeystrokeStressReport,
|
||||
resetTerminalKeystrokeStressProbe,
|
||||
type LatencyStats,
|
||||
} from "./helpers/terminal-probes";
|
||||
import { waitForTerminalContent } from "./helpers/terminal-perf";
|
||||
|
||||
const INPUT_TEXT = buildStressText(600);
|
||||
const BIG_DIFF_BYTES = 256_000;
|
||||
const SMALL_AGENT_STREAM_UPDATES = 1000;
|
||||
const STRESS_TIMEOUT_MS = 15_000;
|
||||
const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1";
|
||||
const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip;
|
||||
|
||||
interface DaemonEchoReport {
|
||||
inputTextLength: number;
|
||||
inputFrameCount: number;
|
||||
outputEventCount: number;
|
||||
echoedBytes: number;
|
||||
sendToOutputMs: LatencyStats;
|
||||
firstSendAt: number;
|
||||
lastOutputAt: number;
|
||||
}
|
||||
|
||||
terminalPerfDescribe("Terminal keystroke stress", () => {
|
||||
let harness: TerminalE2EHarness;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-key-stress-" });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await harness?.cleanup();
|
||||
});
|
||||
|
||||
test("logs daemon-only and app keystroke echo latency under burst input", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(75_000);
|
||||
|
||||
const daemonTerminal = await harness.createTerminal({ name: "daemon-keystroke-baseline" });
|
||||
try {
|
||||
const daemonReport = await measureDaemonBurstEcho(harness, daemonTerminal, INPUT_TEXT);
|
||||
await attachJson(testInfo, "daemon-keystroke-baseline", daemonReport);
|
||||
console.log("[terminal-stress-daemon]", JSON.stringify(daemonReport));
|
||||
|
||||
expect(daemonReport.echoedBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||
} finally {
|
||||
await harness.killTerminal(daemonTerminal.id);
|
||||
}
|
||||
|
||||
await installTerminalKeystrokeStressProbe(page);
|
||||
const appBaselineReport = await measureAppBurstEcho({
|
||||
page,
|
||||
harness,
|
||||
terminalName: "app-keystroke-stress",
|
||||
});
|
||||
await attachJson(testInfo, "app-keystroke-stress", appBaselineReport);
|
||||
console.log("[terminal-stress-app]", JSON.stringify(appBaselineReport));
|
||||
|
||||
expect(appBaselineReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||
expect(appBaselineReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||
expect(appBaselineReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||
expect(appBaselineReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
|
||||
|
||||
const appSmallChunksReport = await measureAppBurstEcho({
|
||||
page,
|
||||
harness,
|
||||
terminalName: "app-keystroke-stress-small-agent-chunks",
|
||||
agentStreamUpdateCount: SMALL_AGENT_STREAM_UPDATES,
|
||||
});
|
||||
await attachJson(testInfo, "app-keystroke-stress-small-agent-chunks", appSmallChunksReport);
|
||||
console.log("[terminal-stress-app-small-agent-chunks]", JSON.stringify(appSmallChunksReport));
|
||||
|
||||
expect(appSmallChunksReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||
expect(appSmallChunksReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||
expect(appSmallChunksReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||
expect(appSmallChunksReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
|
||||
expect(appSmallChunksReport.agentStreamTextMessageCount).toBeGreaterThanOrEqual(
|
||||
SMALL_AGENT_STREAM_UPDATES,
|
||||
);
|
||||
|
||||
const appBigDiffReport = await measureAppBurstEcho({
|
||||
page,
|
||||
harness,
|
||||
terminalName: "app-keystroke-stress-big-diff",
|
||||
bigDiffBytes: BIG_DIFF_BYTES,
|
||||
});
|
||||
await attachJson(testInfo, "app-keystroke-stress-big-diff", appBigDiffReport);
|
||||
console.log("[terminal-stress-app-big-diff]", JSON.stringify(appBigDiffReport));
|
||||
|
||||
expect(appBigDiffReport.keydownCount).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||
expect(appBigDiffReport.inputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||
expect(appBigDiffReport.outputFramePayloadBytes).toBeGreaterThanOrEqual(INPUT_TEXT.length);
|
||||
expect(appBigDiffReport.keydownToXtermCommitMs?.count ?? 0).toBeGreaterThan(0);
|
||||
expect(appBigDiffReport.largeAgentStreamTextMessageCount).toBeGreaterThanOrEqual(1);
|
||||
expect(appBigDiffReport.largestAgentStreamTextMessageBytes).toBeGreaterThanOrEqual(
|
||||
BIG_DIFF_BYTES,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
async function measureAppBurstEcho(input: {
|
||||
page: Page;
|
||||
harness: TerminalE2EHarness;
|
||||
terminalName: string;
|
||||
bigDiffBytes?: number;
|
||||
agentStreamUpdateCount?: number;
|
||||
}) {
|
||||
const appTerminal = await input.harness.createTerminal({ name: input.terminalName });
|
||||
try {
|
||||
await input.harness.openTerminal(input.page, { terminalId: appTerminal.id });
|
||||
await input.harness.setupPrompt(input.page);
|
||||
|
||||
const agent =
|
||||
input.bigDiffBytes === undefined && input.agentStreamUpdateCount === undefined
|
||||
? null
|
||||
: await input.harness.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: input.harness.tempRepo.path,
|
||||
title: "Large WebSocket payload",
|
||||
modeId: "load-test",
|
||||
});
|
||||
|
||||
const bigDiffBytes = input.bigDiffBytes;
|
||||
const agentStreamUpdateCount = input.agentStreamUpdateCount;
|
||||
const startAgentLoad =
|
||||
agent === null
|
||||
? null
|
||||
: () => {
|
||||
if (bigDiffBytes !== undefined) {
|
||||
return emitLargeDiffAgentPayload(input.harness, {
|
||||
agentId: agent.id,
|
||||
bytes: bigDiffBytes,
|
||||
});
|
||||
}
|
||||
if (agentStreamUpdateCount !== undefined) {
|
||||
return emitRapidAgentStreamUpdates(input.harness, {
|
||||
agentId: agent.id,
|
||||
count: agentStreamUpdateCount,
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const terminal = input.harness.terminalSurface(input.page);
|
||||
await terminal.press("Control+c");
|
||||
await input.page.waitForTimeout(200);
|
||||
await resetTerminalKeystrokeStressProbe(input.page);
|
||||
|
||||
const activeAgentLoadPromise = startAgentLoad === null ? Promise.resolve() : startAgentLoad();
|
||||
|
||||
await terminal.pressSequentially(INPUT_TEXT, { delay: 0 });
|
||||
await waitForAppStressEcho(input.page, INPUT_TEXT);
|
||||
await waitForAppProbePayload(input.page, INPUT_TEXT.length);
|
||||
if (input.bigDiffBytes !== undefined) {
|
||||
await waitForLargeAgentStreamMessage(input.page, input.bigDiffBytes);
|
||||
}
|
||||
if (input.agentStreamUpdateCount !== undefined) {
|
||||
await waitForAgentStreamMessages(input.page, input.agentStreamUpdateCount);
|
||||
}
|
||||
await activeAgentLoadPromise;
|
||||
|
||||
return readTerminalKeystrokeStressReport(input.page, INPUT_TEXT);
|
||||
} finally {
|
||||
await input.harness.killTerminal(appTerminal.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function emitRapidAgentStreamUpdates(
|
||||
harness: TerminalE2EHarness,
|
||||
input: { agentId: string; count: number },
|
||||
): Promise<void> {
|
||||
await harness.client.sendAgentMessage(input.agentId, `emit ${input.count} agent stream updates`);
|
||||
}
|
||||
|
||||
async function emitLargeDiffAgentPayload(
|
||||
harness: TerminalE2EHarness,
|
||||
input: { agentId: string; bytes: number },
|
||||
): Promise<void> {
|
||||
await harness.client.sendAgentMessage(
|
||||
input.agentId,
|
||||
`emit ${input.bytes} byte large diff agent stream update`,
|
||||
);
|
||||
}
|
||||
|
||||
async function measureDaemonBurstEcho(
|
||||
harness: TerminalE2EHarness,
|
||||
terminal: TerminalInstance,
|
||||
inputText: string,
|
||||
): Promise<DaemonEchoReport> {
|
||||
await harness.client.subscribeTerminal(terminal.id);
|
||||
|
||||
const outputTimesByByte: number[] = [];
|
||||
let outputEventCount = 0;
|
||||
let echoedBytes = 0;
|
||||
const decoder = new TextDecoder();
|
||||
const unsubscribe = harness.client.onTerminalStreamEvent((event) => {
|
||||
if (event.terminalId !== terminal.id || event.type !== "output" || !event.data) {
|
||||
return;
|
||||
}
|
||||
outputEventCount += 1;
|
||||
const text = decoder.decode(event.data);
|
||||
const now = performance.now();
|
||||
const previousEchoedBytes = echoedBytes;
|
||||
echoedBytes += text.length;
|
||||
for (let index = previousEchoedBytes; index < echoedBytes; index += 1) {
|
||||
outputTimesByByte[index] = now;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
echoedBytes = 0;
|
||||
outputTimesByByte.length = 0;
|
||||
|
||||
const sendTimes: number[] = [];
|
||||
for (const char of inputText) {
|
||||
sendTimes.push(performance.now());
|
||||
harness.client.sendTerminalInput(terminal.id, {
|
||||
type: "input",
|
||||
data: char,
|
||||
});
|
||||
}
|
||||
|
||||
await waitForDaemonEchoBytes({
|
||||
getEchoedBytes: () => echoedBytes,
|
||||
expectedBytes: inputText.length,
|
||||
timeoutMs: STRESS_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
const latencies = sendTimes.map((sentAt, index) => outputTimesByByte[index]! - sentAt);
|
||||
return {
|
||||
inputTextLength: inputText.length,
|
||||
inputFrameCount: sendTimes.length,
|
||||
outputEventCount,
|
||||
echoedBytes,
|
||||
sendToOutputMs: summarizeLatency(latencies),
|
||||
firstSendAt: sendTimes[0] ?? 0,
|
||||
lastOutputAt: outputTimesByByte[inputText.length - 1] ?? 0,
|
||||
};
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForDaemonEchoBytes(input: {
|
||||
getEchoedBytes: () => number;
|
||||
expectedBytes: number;
|
||||
timeoutMs: number;
|
||||
}): Promise<void> {
|
||||
const deadline = Date.now() + input.timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (input.getEchoedBytes() >= input.expectedBytes) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for daemon echo bytes: ${input.getEchoedBytes()}/${input.expectedBytes}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForAppStressEcho(page: Page, text: string): Promise<void> {
|
||||
const tail = text.slice(-80);
|
||||
await waitForTerminalContent(page, (content) => content.includes(tail), STRESS_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
async function waitForAppProbePayload(page: Page, expectedBytes: number): Promise<void> {
|
||||
const deadline = Date.now() + STRESS_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
|
||||
if (report.outputFramePayloadBytes >= expectedBytes) {
|
||||
return;
|
||||
}
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForLargeAgentStreamMessage(page: Page, expectedBytes: number): Promise<void> {
|
||||
const deadline = Date.now() + STRESS_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
|
||||
if (report.largestAgentStreamTextMessageBytes >= expectedBytes) {
|
||||
return;
|
||||
}
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
|
||||
throw new Error(
|
||||
`Timed out waiting for large agent_stream message: largest=${report.largestAgentStreamTextMessageBytes}, expected=${expectedBytes}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForAgentStreamMessages(page: Page, expectedCount: number): Promise<void> {
|
||||
const deadline = Date.now() + STRESS_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
|
||||
if (report.agentStreamTextMessageCount >= expectedCount) {
|
||||
return;
|
||||
}
|
||||
await page.waitForTimeout(25);
|
||||
}
|
||||
const report = await readTerminalKeystrokeStressReport(page, INPUT_TEXT);
|
||||
throw new Error(
|
||||
`Timed out waiting for agent_stream messages: count=${report.agentStreamTextMessageCount}, expected=${expectedCount}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function attachJson(testInfo: TestInfo, name: string, value: unknown): Promise<void> {
|
||||
await testInfo.attach(name, {
|
||||
body: JSON.stringify(value, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
}
|
||||
|
||||
function buildStressText(length: number): string {
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
let output = "";
|
||||
while (output.length < length) {
|
||||
output += alphabet;
|
||||
}
|
||||
return output.slice(0, length);
|
||||
}
|
||||
|
||||
function summarizeLatency(values: number[]): LatencyStats {
|
||||
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))] ?? 0;
|
||||
};
|
||||
const total = values.reduce((sum, value) => sum + value, 0);
|
||||
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 round2(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
@@ -16,14 +16,21 @@ const LINE_COUNT = 50_000;
|
||||
const THROUGHPUT_BUDGET_MS = 30_000;
|
||||
const KEYSTROKE_SAMPLE_COUNT = 20;
|
||||
const KEYSTROKE_P95_BUDGET_MS = 150;
|
||||
const RUN_MANUAL_TERMINAL_PERF = process.env.PASEO_TERMINAL_PERF_E2E === "1";
|
||||
const terminalPerfDescribe = RUN_MANUAL_TERMINAL_PERF ? test.describe : test.describe.skip;
|
||||
|
||||
test.describe("Terminal wire performance", () => {
|
||||
terminalPerfDescribe("Terminal wire performance", () => {
|
||||
let client: TerminalPerfDaemonClient;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
let workspaceId: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("perf-");
|
||||
client = await connectTerminalClient();
|
||||
// Seed the workspace in the daemon so the app can resolve the path
|
||||
const seedResult = await client.openProject(tempRepo.path);
|
||||
if (!seedResult.workspace) throw new Error(seedResult.error ?? "Failed to seed workspace");
|
||||
workspaceId = seedResult.workspace.id;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
@@ -45,7 +52,7 @@ test.describe("Terminal wire performance", () => {
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
try {
|
||||
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
|
||||
await navigateToTerminal(page, { workspaceId, terminalId });
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
const sentinel = `PERF_DONE_${Date.now()}`;
|
||||
@@ -54,7 +61,11 @@ test.describe("Terminal wire performance", () => {
|
||||
|
||||
await terminal.pressSequentially(`seq 1 ${LINE_COUNT}; echo ${sentinel}\n`, { delay: 0 });
|
||||
|
||||
await waitForTerminalContent(page, (text) => text.includes(sentinel), THROUGHPUT_BUDGET_MS + 15_000);
|
||||
await waitForTerminalContent(
|
||||
page,
|
||||
(text) => text.includes(sentinel),
|
||||
THROUGHPUT_BUDGET_MS + 15_000,
|
||||
);
|
||||
|
||||
const elapsedMs = Date.now() - startMs;
|
||||
|
||||
@@ -81,9 +92,10 @@ test.describe("Terminal wire performance", () => {
|
||||
`[perf] Throughput: ${report.throughputMBps} MB/s — ${LINE_COUNT} lines in ${elapsedMs}ms`,
|
||||
);
|
||||
|
||||
expect(elapsedMs, `${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`).toBeLessThan(
|
||||
THROUGHPUT_BUDGET_MS,
|
||||
);
|
||||
expect(
|
||||
elapsedMs,
|
||||
`${LINE_COUNT} lines should render within ${THROUGHPUT_BUDGET_MS}ms`,
|
||||
).toBeLessThan(THROUGHPUT_BUDGET_MS);
|
||||
} finally {
|
||||
await client.killTerminal(terminalId).catch(() => {});
|
||||
}
|
||||
@@ -99,7 +111,7 @@ test.describe("Terminal wire performance", () => {
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
try {
|
||||
await navigateToTerminal(page, { cwd: tempRepo.path, terminalId });
|
||||
await navigateToTerminal(page, { workspaceId, terminalId });
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
// Ensure clean prompt state
|
||||
|
||||
129
packages/app/e2e/workspace-cwd.spec.ts
Normal file
129
packages/app/e2e/workspace-cwd.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
|
||||
import { setupDeterministicPrompt, waitForTerminalContent } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
openHomeWithProject,
|
||||
seedProjectForWorkspaceSetup,
|
||||
} from "./helpers/workspace-setup";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
/** Navigate to a workspace via sidebar row testID and wait for tab bar. */
|
||||
async function navigateToWorkspaceViaSidebar(
|
||||
page: import("@playwright/test").Page,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
const row = page.getByTestId(testId);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
await waitForTabBar(page);
|
||||
}
|
||||
|
||||
test.describe("Workspace cwd correctness", () => {
|
||||
test("main checkout workspace opens terminals in the project root", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("workspace-cwd-main-");
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
|
||||
const workspaceResult = await client.openProject(repo.path);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
|
||||
// Use sidebar navigation to avoid Expo Router hydration issues
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(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, `PWD_READY_${Date.now()}`);
|
||||
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
|
||||
|
||||
await waitForTerminalContent(page, (text) => text.includes(repo.path), 10_000);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("worktree workspace opens terminals in the worktree directory", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("workspace-cwd-worktree-");
|
||||
const resolvedTmp = realpathSync("/tmp");
|
||||
const worktreePath = path.join(
|
||||
resolvedTmp,
|
||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
const branchName = `workspace-cwd-${Date.now()}`;
|
||||
let worktreeCreated = false;
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
|
||||
execSync(
|
||||
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
|
||||
{
|
||||
cwd: repo.path,
|
||||
stdio: "ignore",
|
||||
},
|
||||
);
|
||||
worktreeCreated = true;
|
||||
|
||||
const workspaceResult = await client.openProject(worktreePath);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
|
||||
// Use sidebar navigation to avoid Expo Router hydration issues
|
||||
// with direct URL navigation to the 2nd+ workspace.
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(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, `PWD_READY_${Date.now()}`);
|
||||
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
|
||||
await waitForTerminalContent(page, (text) => text.includes(worktreePath), 10_000);
|
||||
} finally {
|
||||
if (worktreeCreated) {
|
||||
try {
|
||||
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||
cwd: repo.path,
|
||||
stdio: "ignore",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup so test failures preserve the original error.
|
||||
}
|
||||
}
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
195
packages/app/e2e/workspace-lifecycle.spec.ts
Normal file
195
packages/app/e2e/workspace-lifecycle.spec.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { waitForTabBar } from "./helpers/launcher";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
createAgentChatFromLauncher,
|
||||
createStandaloneTerminalFromLauncher,
|
||||
expectTerminalCwd,
|
||||
} from "./helpers/workspace-lifecycle";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
openHomeWithProject,
|
||||
seedProjectForWorkspaceSetup,
|
||||
} from "./helpers/workspace-setup";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
/** Navigate to a workspace via sidebar row testID and wait for the tab bar. */
|
||||
async function navigateToWorkspaceViaSidebar(
|
||||
page: import("@playwright/test").Page,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
const row = page.getByTestId(testId);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
await waitForTabBar(page);
|
||||
}
|
||||
|
||||
test.describe("Workspace lifecycle", () => {
|
||||
// The first test after a spec-file switch can intermittently fail because
|
||||
// the shared daemon still holds stale sessions from the previous spec.
|
||||
// One retry is enough for the daemon to stabilize.
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
test.describe("Main checkout", () => {
|
||||
test("creates an agent chat via New Chat", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-main-chat-");
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const workspaceResult = await client.openProject(repo.path);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
await createAgentChatFromLauncher(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("creates a terminal with correct CWD", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-main-shell-");
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const workspaceResult = await client.openProject(repo.path);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
await createStandaloneTerminalFromLauncher(page);
|
||||
await expectTerminalCwd(page, repo.path);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Worktree workspace", () => {
|
||||
test("creates an agent chat via New Chat", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-wt-chat-");
|
||||
const resolvedTmp = realpathSync("/tmp");
|
||||
const worktreePath = path.join(
|
||||
resolvedTmp,
|
||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
const branchName = `lifecycle-wt-chat-${Date.now()}`;
|
||||
let worktreeCreated = false;
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
|
||||
execSync(
|
||||
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
|
||||
{
|
||||
cwd: repo.path,
|
||||
stdio: "ignore",
|
||||
},
|
||||
);
|
||||
worktreeCreated = true;
|
||||
|
||||
const workspaceResult = await client.openProject(worktreePath);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
await createAgentChatFromLauncher(page);
|
||||
} finally {
|
||||
if (worktreeCreated) {
|
||||
try {
|
||||
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||
cwd: repo.path,
|
||||
stdio: "ignore",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup so test failures preserve the original error.
|
||||
}
|
||||
}
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("creates a terminal with correct CWD", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-wt-shell-");
|
||||
const resolvedTmp = realpathSync("/tmp");
|
||||
const worktreePath = path.join(
|
||||
resolvedTmp,
|
||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
const branchName = `lifecycle-wt-shell-${Date.now()}`;
|
||||
let worktreeCreated = false;
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
|
||||
execSync(
|
||||
`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`,
|
||||
{
|
||||
cwd: repo.path,
|
||||
stdio: "ignore",
|
||||
},
|
||||
);
|
||||
worktreeCreated = true;
|
||||
|
||||
const workspaceResult = await client.openProject(worktreePath);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
await createStandaloneTerminalFromLauncher(page);
|
||||
await expectTerminalCwd(page, worktreePath);
|
||||
} finally {
|
||||
if (worktreeCreated) {
|
||||
try {
|
||||
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||
cwd: repo.path,
|
||||
stdio: "ignore",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup so test failures preserve the original error.
|
||||
}
|
||||
}
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
219
packages/app/e2e/workspace-navigation-regression.spec.ts
Normal file
219
packages/app/e2e/workspace-navigation-regression.spec.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
connectArchiveTabDaemonClient,
|
||||
createIdleAgent,
|
||||
expectWorkspaceTabHidden,
|
||||
expectWorkspaceTabVisible,
|
||||
openWorkspaceWithAgents,
|
||||
} from "./helpers/archive-tab";
|
||||
import {
|
||||
archiveLocalWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
openProjectViaDaemon,
|
||||
} from "./helpers/new-workspace";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
getVisibleWorkspaceAgentTabIds,
|
||||
expectOnlyWorkspaceAgentTabsVisible,
|
||||
waitForWorkspaceTabsVisible,
|
||||
} from "./helpers/workspace-tabs";
|
||||
import {
|
||||
expectSidebarWorkspaceSelected,
|
||||
expectWorkspaceHeader,
|
||||
switchWorkspaceViaSidebar,
|
||||
waitForSidebarHydration,
|
||||
} from "./helpers/workspace-ui";
|
||||
|
||||
test.describe("Workspace navigation regression", () => {
|
||||
test.describe.configure({ timeout: 240_000 });
|
||||
|
||||
test("sidebar navigation and reload keep workspace selection and tabs aligned", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
|
||||
const workspaceClient = await connectNewWorkspaceDaemonClient();
|
||||
const archiveClient = await connectArchiveTabDaemonClient();
|
||||
const workspaceIds = new Set<string>();
|
||||
const agentIds: string[] = [];
|
||||
const firstRepo = await createTempGitRepo("workspace-nav-reg-a-");
|
||||
const secondRepo = await createTempGitRepo("workspace-nav-reg-b-");
|
||||
|
||||
try {
|
||||
const firstWorkspace = await openProjectViaDaemon(workspaceClient, firstRepo.path);
|
||||
const secondWorkspace = await openProjectViaDaemon(workspaceClient, secondRepo.path);
|
||||
workspaceIds.add(firstWorkspace.workspaceId);
|
||||
workspaceIds.add(secondWorkspace.workspaceId);
|
||||
|
||||
const firstAgent = await createIdleAgent(archiveClient, {
|
||||
cwd: firstRepo.path,
|
||||
title: `workspace-nav-a-${Date.now()}`,
|
||||
});
|
||||
const secondAgent = await createIdleAgent(archiveClient, {
|
||||
cwd: secondRepo.path,
|
||||
title: `workspace-nav-b-${Date.now()}`,
|
||||
});
|
||||
agentIds.push(firstAgent.id, secondAgent.id);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openWorkspaceWithAgents(page, [firstAgent, secondAgent]);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
});
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
selected: false,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: firstWorkspace.workspaceName,
|
||||
subtitle: firstWorkspace.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceTabVisible(page, firstAgent.id);
|
||||
await expectWorkspaceTabHidden(page, secondAgent.id);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
|
||||
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
||||
`workspace-tab-agent_${firstAgent.id}`,
|
||||
]);
|
||||
|
||||
const firstDeckEntry = page.getByTestId(
|
||||
`workspace-deck-entry-${serverId}:${firstWorkspace.workspaceId}`,
|
||||
);
|
||||
const secondDeckEntry = page.getByTestId(
|
||||
`workspace-deck-entry-${serverId}:${secondWorkspace.workspaceId}`,
|
||||
);
|
||||
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: secondWorkspace.workspaceId,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: secondWorkspace.workspaceId,
|
||||
});
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
selected: false,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: secondWorkspace.workspaceName,
|
||||
subtitle: secondWorkspace.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceTabVisible(page, secondAgent.id);
|
||||
await expectWorkspaceTabHidden(page, firstAgent.id);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
|
||||
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
||||
`workspace-tab-agent_${secondAgent.id}`,
|
||||
]);
|
||||
await expect(firstDeckEntry).toBeAttached();
|
||||
await expect(firstDeckEntry).toBeHidden();
|
||||
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||
|
||||
await page.evaluate(
|
||||
({ agentId, serverId: targetServerId }) => {
|
||||
globalThis.dispatchEvent(
|
||||
new CustomEvent("paseo:web-notification-click", {
|
||||
detail: {
|
||||
data: {
|
||||
serverId: targetServerId,
|
||||
agentId,
|
||||
reason: "finished",
|
||||
},
|
||||
},
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ agentId: secondAgent.id, serverId },
|
||||
);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(secondDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||
await expectWorkspaceTabVisible(page, secondAgent.id);
|
||||
await expectWorkspaceTabHidden(page, firstAgent.id);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [secondAgent.id]);
|
||||
await expect(firstDeckEntry).toBeAttached();
|
||||
await expect(firstDeckEntry).toBeHidden();
|
||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: firstWorkspace.workspaceId,
|
||||
});
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(firstDeckEntry).toBeVisible({ timeout: 30_000 });
|
||||
await expect(secondDeckEntry).toBeAttached();
|
||||
await expect(secondDeckEntry).toBeHidden();
|
||||
await expect(page.locator('[data-testid^="workspace-deck-entry-"]')).toHaveCount(2);
|
||||
|
||||
await page.reload();
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, firstWorkspace.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectSidebarWorkspaceSelected({
|
||||
page,
|
||||
serverId,
|
||||
workspaceId: firstWorkspace.workspaceId,
|
||||
});
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: firstWorkspace.workspaceName,
|
||||
subtitle: firstWorkspace.projectDisplayName,
|
||||
});
|
||||
await expectWorkspaceTabVisible(page, firstAgent.id);
|
||||
await expectWorkspaceTabHidden(page, secondAgent.id);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [firstAgent.id]);
|
||||
await expect(getVisibleWorkspaceAgentTabIds(page)).resolves.toEqual([
|
||||
`workspace-tab-agent_${firstAgent.id}`,
|
||||
]);
|
||||
} finally {
|
||||
for (const agentId of agentIds) {
|
||||
await archiveAgentFromDaemon(archiveClient, agentId).catch(() => undefined);
|
||||
}
|
||||
for (const workspaceId of workspaceIds) {
|
||||
await archiveLocalWorkspaceFromDaemon(workspaceClient, workspaceId).catch(() => undefined);
|
||||
}
|
||||
await archiveClient.close().catch(() => undefined);
|
||||
await workspaceClient.close().catch(() => undefined);
|
||||
await secondRepo.cleanup();
|
||||
await firstRepo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
106
packages/app/e2e/workspace-setup-runtime.spec.ts
Normal file
106
packages/app/e2e/workspace-setup-runtime.spec.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { expect, test } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { clickTerminal, waitForTabBar } from "./helpers/launcher";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
createWorkspaceThroughDaemon,
|
||||
findWorktreeWorkspaceForProject,
|
||||
openHomeWithProject,
|
||||
} from "./helpers/workspace-setup";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
async function navigateToWorkspaceViaSidebar(
|
||||
page: import("@playwright/test").Page,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
await waitForTabBar(page);
|
||||
}
|
||||
|
||||
test.describe("Workspace setup runtime authority", () => {
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
test("worktree workspace is created in its own directory", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("workspace-setup-chat-");
|
||||
|
||||
try {
|
||||
await client.openProject(repo.path);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: `setup-chat-${Date.now()}`,
|
||||
});
|
||||
const workspaceId = String(workspace.id);
|
||||
|
||||
const wsInfo = await findWorktreeWorkspaceForProject(client, repo.path);
|
||||
expect(wsInfo.workspaceDirectory).not.toBe(repo.path);
|
||||
expect(existsSync(wsInfo.workspaceDirectory)).toBe(true);
|
||||
|
||||
// Navigate to the workspace via sidebar
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("first terminal opens in the created workspace directory", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("workspace-setup-terminal-");
|
||||
|
||||
try {
|
||||
await client.openProject(repo.path);
|
||||
|
||||
// Create workspace via daemon API since the new workspace screen
|
||||
// no longer has a standalone terminal button
|
||||
const worktreeSlug = `setup-terminal-${Date.now()}`;
|
||||
const result = await client.createPaseoWorktree({
|
||||
cwd: repo.path,
|
||||
worktreeSlug,
|
||||
});
|
||||
if (!result.workspace || result.error) {
|
||||
throw new Error(result.error ?? "Failed to create workspace");
|
||||
}
|
||||
const workspaceDir = result.workspace.workspaceDirectory;
|
||||
const workspaceId = String(result.workspace.id);
|
||||
|
||||
// Navigate to the worktree workspace via sidebar click (direct URL
|
||||
// navigation for freshly created worktree workspaces can race with
|
||||
// Expo Router hydration, so we use the sidebar which is authoritative).
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspaceId);
|
||||
|
||||
await clickTerminal(page);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Verify terminal is listed under the worktree directory, not the original repo
|
||||
await expect
|
||||
.poll(async () => (await client.listTerminals(workspaceDir)).terminals.length > 0, {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe(true);
|
||||
expect((await client.listTerminals(repo.path)).terminals.length).toBe(0);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
371
packages/app/e2e/workspace-setup-streaming.spec.ts
Normal file
371
packages/app/e2e/workspace-setup-streaming.spec.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { waitForTerminalContent } from "./helpers/terminal-perf";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
createWorkspaceThroughDaemon,
|
||||
expectSetupPanel,
|
||||
openHomeWithProject,
|
||||
seedProjectForWorkspaceSetup,
|
||||
waitForWorkspaceSetupProgress,
|
||||
} from "./helpers/workspace-setup";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
interface WorkspaceScriptStarter {
|
||||
startWorkspaceScript(
|
||||
workspaceId: string,
|
||||
scriptName: string,
|
||||
): Promise<{
|
||||
workspaceId: string;
|
||||
scriptName: string;
|
||||
terminalId: string | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Click the sidebar row for a workspace (by ID) and wait for navigation. */
|
||||
async function navigateToWorkspaceViaSidebar(
|
||||
page: import("@playwright/test").Page,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const testId = `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
const row = page.getByTestId(testId);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("Workspace setup streaming", () => {
|
||||
test("opens the setup tab when a workspace is created from the sidebar", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-open-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: [
|
||||
"sh -c 'echo starting setup; for i in $(seq 1 30); do echo tick $i; sleep 1; done; echo setup complete'",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: `setup-open-${Date.now()}`,
|
||||
});
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
|
||||
await expectSetupPanel(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("runs setup through the sidebar and leaves the workspace usable", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-ui-flow-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: [
|
||||
"sh -c 'echo starting setup; sleep 1; echo loading dependencies; sleep 1; echo setup complete'",
|
||||
],
|
||||
},
|
||||
},
|
||||
files: [{ path: "src/index.ts", content: "export const ready = true;\n" }],
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
|
||||
// Wait for setup completion via daemon (setup snapshots are per-session,
|
||||
// so the browser session won't receive progress events).
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: `setup-ui-flow-${Date.now()}`,
|
||||
});
|
||||
await completed;
|
||||
|
||||
// Navigate to workspace and verify it's usable
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first().click();
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const explorerToggle = page.getByTestId("workspace-explorer-toggle").first();
|
||||
if ((await explorerToggle.getAttribute("aria-label")) === "Open explorer") {
|
||||
await explorerToggle.click();
|
||||
}
|
||||
await expect(explorerToggle).toHaveAttribute("aria-label", "Close explorer", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.getByTestId("explorer-tab-files").click();
|
||||
await expect(page.getByTestId("file-explorer-tree-scroll")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByText("README.md", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.getByText("src", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("streams running and completed setup snapshots for a successful setup", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-success-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["sh -c 'echo starting setup; sleep 2; echo setup complete'"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const initialRunning = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "running" && payload.detail.log === "",
|
||||
);
|
||||
const runningWithOutput = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "running" && payload.detail.log.includes("starting setup"),
|
||||
);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
);
|
||||
|
||||
await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "workspace-setup-success",
|
||||
});
|
||||
|
||||
const initialPayload = await initialRunning;
|
||||
const runningPayload = await runningWithOutput;
|
||||
const completedPayload = await completed;
|
||||
|
||||
expect(initialPayload.detail.log).toBe("");
|
||||
expect(runningPayload.detail.log).toContain("starting setup");
|
||||
expect(completedPayload.detail.log).toContain("setup complete");
|
||||
expect(completedPayload.error).toBeNull();
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("streams a failed setup snapshot when setup fails", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-failure-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["sh -c 'echo starting setup; sleep 2; echo setup failed 1>&2; exit 1'"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const failed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) => payload.status === "failed" && payload.detail.log.includes("setup failed"),
|
||||
);
|
||||
|
||||
await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "workspace-setup-failure",
|
||||
});
|
||||
|
||||
const failedPayload = await failed;
|
||||
expect(failedPayload.detail.log).toContain("starting setup");
|
||||
expect(failedPayload.detail.log).toContain("setup failed");
|
||||
expect(failedPayload.error).toMatch(/failed/i);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("emits a completed empty snapshot when no setup commands exist", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-none-");
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" &&
|
||||
payload.detail.commands.length === 0 &&
|
||||
payload.detail.log === "",
|
||||
);
|
||||
|
||||
await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "workspace-setup-none",
|
||||
});
|
||||
|
||||
const completedPayload = await completed;
|
||||
expect(completedPayload.error).toBeNull();
|
||||
expect(completedPayload.detail.commands).toEqual([]);
|
||||
expect(completedPayload.detail.log).toBe("");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("launches script terminals from the workspace scripts menu", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-svc-ui-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
|
||||
},
|
||||
scripts: {
|
||||
web: {
|
||||
command:
|
||||
"node -e \"const http = require('http'); const s = http.createServer((q,r) => r.end('ok')); s.listen(process.env.PORT || 3000, () => console.log('listening on ' + s.address().port))\"",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
|
||||
// Wait for setup completion via daemon (setup snapshots are per-session)
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
);
|
||||
const workspace = await createWorkspaceThroughDaemon(client, {
|
||||
cwd: repo.path,
|
||||
worktreeSlug: `setup-svc-${Date.now()}`,
|
||||
});
|
||||
await completed;
|
||||
|
||||
await openHomeWithProject(page, repo.path);
|
||||
await navigateToWorkspaceViaSidebar(page, workspace.id);
|
||||
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
|
||||
await expect(page.locator('[data-testid^="workspace-tab-terminal_"]')).toHaveCount(0);
|
||||
await page.getByTestId("workspace-scripts-button").click();
|
||||
await expect(page.getByTestId("workspace-scripts-menu")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("workspace-scripts-start-web").click();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]').first();
|
||||
await expect(terminalTab).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await terminalTab.click();
|
||||
|
||||
// Verify the terminal surface rendered
|
||||
const terminalSurface = page.getByTestId("terminal-surface").first();
|
||||
await expect(terminalSurface).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Wait for terminal to fully attach (loading overlay gone)
|
||||
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.click();
|
||||
|
||||
// Verify the terminal output contains "listening on" via xterm buffer API.
|
||||
// The .xterm-rows CSS selector is fragile; reading the buffer directly is reliable.
|
||||
await waitForTerminalContent(page, (text) => text.includes("listening on"), 60_000);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("launches workspace scripts through an explicit daemon request", async () => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("setup-scripts-", {
|
||||
paseoConfig: {
|
||||
worktree: {
|
||||
setup: ["sh -c 'echo bootstrapping; sleep 1; echo setup complete'"],
|
||||
},
|
||||
scripts: {
|
||||
editor: {
|
||||
command: "node -e \"console.log('editor ready'); setInterval(() => {}, 1000)\"",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const completed = waitForWorkspaceSetupProgress(
|
||||
client,
|
||||
(payload) =>
|
||||
payload.status === "completed" && payload.detail.log.includes("setup complete"),
|
||||
);
|
||||
|
||||
const result = await client.createPaseoWorktree({
|
||||
cwd: repo.path,
|
||||
worktreeSlug: "workspace-setup-scripts",
|
||||
});
|
||||
if (!result.workspace) {
|
||||
throw new Error(result.error ?? "Failed to create workspace");
|
||||
}
|
||||
const workspaceDir = result.workspace.workspaceDirectory;
|
||||
const workspaceId = String(result.workspace.id);
|
||||
|
||||
await completed;
|
||||
|
||||
const scriptClient = client as typeof client & WorkspaceScriptStarter;
|
||||
const startResult = await scriptClient.startWorkspaceScript(workspaceId, "editor");
|
||||
expect(startResult).toMatchObject({
|
||||
workspaceId,
|
||||
scriptName: "editor",
|
||||
terminalId: expect.any(String),
|
||||
error: null,
|
||||
});
|
||||
|
||||
const findEditorTerminal = (terminal: { name: string }) => terminal.name === "editor";
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const terminals = await client.listTerminals(workspaceDir);
|
||||
return terminals.terminals.find(findEditorTerminal) ?? null;
|
||||
})
|
||||
.toMatchObject({
|
||||
id: expect.any(String),
|
||||
name: "editor",
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -39,7 +39,8 @@
|
||||
"ascAppId": "6758887924"
|
||||
},
|
||||
"android": {
|
||||
"releaseStatus": "draft"
|
||||
"track": "production",
|
||||
"releaseStatus": "completed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
packages/app/fastlane/Appfile
Normal file
1
packages/app/fastlane/Appfile
Normal file
@@ -0,0 +1 @@
|
||||
app_identifier("sh.paseo")
|
||||
42
packages/app/fastlane/Fastfile
Normal file
42
packages/app/fastlane/Fastfile
Normal file
@@ -0,0 +1,42 @@
|
||||
default_platform(:ios)
|
||||
|
||||
APP_IDENTIFIER = "sh.paseo"
|
||||
|
||||
platform :ios do
|
||||
desc "Submit the latest TestFlight build to App Store Review"
|
||||
lane :submit_review do
|
||||
api_key = app_store_connect_api_key(
|
||||
key_id: ENV.fetch("ASC_KEY_ID"),
|
||||
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
|
||||
key_filepath: ENV.fetch("ASC_KEY_P8"),
|
||||
)
|
||||
|
||||
build_number = latest_testflight_build_number(
|
||||
api_key: api_key,
|
||||
app_identifier: APP_IDENTIFIER,
|
||||
)
|
||||
|
||||
wait_for_build_processing_to_be_complete(
|
||||
api_key: api_key,
|
||||
app_identifier: APP_IDENTIFIER,
|
||||
build_number: build_number.to_s,
|
||||
)
|
||||
|
||||
deliver(
|
||||
api_key: api_key,
|
||||
app_identifier: APP_IDENTIFIER,
|
||||
build_number: build_number.to_s,
|
||||
submit_for_review: true,
|
||||
automatic_release: true,
|
||||
force: true,
|
||||
skip_binary_upload: true,
|
||||
skip_screenshots: true,
|
||||
skip_metadata: true,
|
||||
precheck_include_in_app_purchases: false,
|
||||
submission_information: {
|
||||
add_id_info_uses_idfa: false,
|
||||
export_compliance_uses_encryption: false,
|
||||
},
|
||||
)
|
||||
end
|
||||
end
|
||||
1
packages/app/global.d.ts
vendored
Normal file
1
packages/app/global.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "*.css";
|
||||
71
packages/app/maestro/README.md
Normal file
71
packages/app/maestro/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Maestro Flows
|
||||
|
||||
This directory contains local mobile UI flows. Keep flows small enough that a
|
||||
failure screenshot proves the intended behavior, not just that the app launched.
|
||||
|
||||
## New Workspace Android Flow
|
||||
|
||||
Use these files when debugging or extending workspace creation on Android:
|
||||
|
||||
- `test-workspace-create-android-crash.sh` runs the full regression harness.
|
||||
- `workspace-create-android-crash.yaml` is the full Maestro flow used by the
|
||||
harness.
|
||||
- `record-workspace-create-android-focus.sh` records only the focused repro
|
||||
window after setup.
|
||||
- `workspace-create-android-ready-sidebar.yaml` stages the app with the Android
|
||||
sidebar open and a prepared project visible.
|
||||
- `workspace-create-android-create-focused.yaml` starts from that staged sidebar
|
||||
and performs the actual workspace creation.
|
||||
|
||||
The reusable pieces live in `flows/`:
|
||||
|
||||
- `flows/android-dev-client.yaml` handles Expo dev launcher/dev menu screens.
|
||||
- `flows/connect-direct-if-welcome.yaml` connects to the local daemon only when
|
||||
the welcome screen is visible.
|
||||
- `flows/open-prepared-project-sidebar.yaml` waits for the home screen, opens
|
||||
the compact Android sidebar, and waits for the prepared project.
|
||||
- `flows/new-workspace-open-from-sidebar.yaml` taps the project row's
|
||||
new-workspace action and waits for `/new`.
|
||||
- `flows/new-workspace-select-codex-gpt54.yaml` selects a real provider/model.
|
||||
- `flows/new-workspace-submit-and-assert-created.yaml` taps `Create` and proves
|
||||
the app landed on the created workspace.
|
||||
|
||||
Compose new workspace scenarios out of these primitives instead of copying the
|
||||
old full flow. The shell scripts render the top-level flows and every `flows/*.yaml`
|
||||
file into the same temp directory, so nested `runFlow: flows/...` paths keep
|
||||
working with `${PASEO_MAESTRO_*}` placeholders.
|
||||
|
||||
The flow is intentionally strict. It must:
|
||||
|
||||
1. Open a prepared project from the daemon.
|
||||
2. Tap the project row's new-workspace action.
|
||||
3. Select an actual provider/model before tapping `Create`.
|
||||
4. Tap `Create`.
|
||||
5. Assert the app lands on a workspace header and the draft composer.
|
||||
6. Assert `New workspace`, `Select a model`, and the Android redbox text are not
|
||||
visible.
|
||||
7. For the shell harness, grep logcat for `failed to insert view` and
|
||||
`specified child already has a parent`.
|
||||
|
||||
Do not weaken this flow to only wait for `message-input-root`. That can pass on
|
||||
the wrong route. The header assertion and the `New workspace` negative assertion
|
||||
are what prove the redirect actually completed.
|
||||
|
||||
The scripts assume a development build with package id `sh.paseo.debug`, an
|
||||
already-running local daemon on `127.0.0.1:6767`, and a connected Android device
|
||||
or emulator. They call `adb reverse tcp:6767 tcp:6767`; they do not restart the
|
||||
daemon.
|
||||
|
||||
```bash
|
||||
bash packages/app/maestro/test-workspace-create-android-crash.sh
|
||||
bash packages/app/maestro/record-workspace-create-android-focus.sh
|
||||
```
|
||||
|
||||
Optional environment:
|
||||
|
||||
```bash
|
||||
PASEO_MAESTRO_APP_ID=sh.paseo.debug
|
||||
PASEO_MAESTRO_DIRECT_ENDPOINT=127.0.0.1:6767
|
||||
PASEO_MAESTRO_DAEMON_WS_URL=ws://127.0.0.1:6767/ws
|
||||
PASEO_MAESTRO_PROJECT_PATH=/path/to/git/repo
|
||||
```
|
||||
41
packages/app/maestro/flows/android-dev-client.yaml
Normal file
41
packages/app/maestro/flows/android-dev-client.yaml
Normal file
@@ -0,0 +1,41 @@
|
||||
# NOTE: Render through the workspace-create shell scripts before running when
|
||||
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
|
||||
appId: ${PASEO_MAESTRO_APP_ID}
|
||||
---
|
||||
# Handle Expo dev launcher/dev menu screens when testing Android development
|
||||
# builds. Release builds no-op through these optional/conditional steps.
|
||||
|
||||
- tapOn:
|
||||
text: ".*(localhost|127\\.0\\.0\\.1|10\\.0\\.2\\.2|192\\.168\\.).*"
|
||||
optional: true
|
||||
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "DEVELOPMENT SERVERS"
|
||||
commands:
|
||||
# Expo dev launcher text matching is flaky on Android here; tap the first
|
||||
# development server row by coordinate as the fallback.
|
||||
- tapOn:
|
||||
point: "50%,8%"
|
||||
|
||||
- waitForAnimationToEnd
|
||||
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Continue"
|
||||
commands:
|
||||
- tapOn: "Continue"
|
||||
|
||||
- tapOn:
|
||||
text: "Continue"
|
||||
optional: true
|
||||
|
||||
- waitForAnimationToEnd
|
||||
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Performance monitor"
|
||||
commands:
|
||||
# Close the Expo dev menu if it opens as a bottom sheet.
|
||||
- tapOn:
|
||||
point: "89%,9%"
|
||||
30
packages/app/maestro/flows/connect-direct-if-welcome.yaml
Normal file
30
packages/app/maestro/flows/connect-direct-if-welcome.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
# NOTE: Render through the workspace-create shell scripts before running when
|
||||
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
|
||||
appId: ${PASEO_MAESTRO_APP_ID}
|
||||
---
|
||||
# Connect to the local daemon only when the app is on the welcome screen.
|
||||
|
||||
- runFlow:
|
||||
when:
|
||||
visible:
|
||||
id: "welcome-screen"
|
||||
commands:
|
||||
- tapOn:
|
||||
id: "welcome-direct-connection"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "add-host-modal"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn:
|
||||
id: "direct-host-input"
|
||||
- eraseText
|
||||
- inputText: ${PASEO_MAESTRO_DIRECT_ENDPOINT}
|
||||
|
||||
- assertVisible:
|
||||
id: "direct-host-input"
|
||||
text: ${PASEO_MAESTRO_DIRECT_ENDPOINT}
|
||||
|
||||
- tapOn:
|
||||
id: "direct-host-submit"
|
||||
@@ -24,4 +24,3 @@ appId: sh.paseo
|
||||
commands:
|
||||
- tapOn:
|
||||
point: "50%,20%"
|
||||
|
||||
|
||||
40
packages/app/maestro/flows/land-in-chat.yaml
Normal file
40
packages/app/maestro/flows/land-in-chat.yaml
Normal file
@@ -0,0 +1,40 @@
|
||||
# NOTE: Maestro requires a config section for every flow file, even when used
|
||||
# via `runFlow`. Keep this aligned with the dev build application id.
|
||||
appId: sh.paseo
|
||||
---
|
||||
- launchApp:
|
||||
clearState: true
|
||||
- runFlow: launch.yaml
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "welcome-screen"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn:
|
||||
id: "welcome-direct-connection"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "add-host-modal"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn:
|
||||
id: "direct-host-input"
|
||||
- eraseText
|
||||
- inputText: "127.0.0.1:6767"
|
||||
|
||||
- assertVisible:
|
||||
id: "direct-host-input"
|
||||
text: "127.0.0.1:6767"
|
||||
|
||||
- tapOn:
|
||||
id: "direct-host-submit"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "message-input-root"
|
||||
timeout: 60000
|
||||
|
||||
- assertVisible:
|
||||
id: "message-input-attach-button"
|
||||
@@ -0,0 +1,17 @@
|
||||
# NOTE: Render through the workspace-create shell scripts before running when
|
||||
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
|
||||
appId: ${PASEO_MAESTRO_APP_ID}
|
||||
---
|
||||
# Start from an open Android sidebar with ${PASEO_MAESTRO_PROJECT_NAME}
|
||||
# visible. Ends on the New Workspace screen.
|
||||
|
||||
- assertVisible:
|
||||
id: "sidebar-project-list"
|
||||
|
||||
- assertVisible: ${PASEO_MAESTRO_PROJECT_NAME}
|
||||
|
||||
- tapOn: "Create a new workspace for ${PASEO_MAESTRO_PROJECT_NAME}"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "New workspace"
|
||||
timeout: 30000
|
||||
@@ -0,0 +1,36 @@
|
||||
# NOTE: Render through the workspace-create shell scripts before running when
|
||||
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
|
||||
appId: ${PASEO_MAESTRO_APP_ID}
|
||||
---
|
||||
# Select a real provider/model on the New Workspace screen. Do not skip this
|
||||
# in redirect tests: without a selected model, Create only shows validation and
|
||||
# never exercises workspace creation.
|
||||
|
||||
- tapOn:
|
||||
id: "agent-preferences-button"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "agent-preferences-sheet"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn:
|
||||
id: "combined-model-selector"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Select model"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "Codex"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "GPT-5.4"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "GPT-5.4"
|
||||
|
||||
- tapOn: "Close"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "GPT-5.4"
|
||||
timeout: 10000
|
||||
@@ -0,0 +1,23 @@
|
||||
# NOTE: Render through the workspace-create shell scripts before running when
|
||||
# using ${PASEO_MAESTRO_APP_ID}. Maestro does not substitute environment vars.
|
||||
appId: ${PASEO_MAESTRO_APP_ID}
|
||||
---
|
||||
# Start on the New Workspace screen after model selection. Taps Create and
|
||||
# proves the redirect completed into a workspace draft-agent tab.
|
||||
|
||||
- tapOn: "Create"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "workspace-header-title"
|
||||
timeout: 60000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "message-input-root"
|
||||
timeout: 30000
|
||||
|
||||
- assertNotVisible: "New workspace"
|
||||
- assertNotVisible: "Select a model"
|
||||
- assertNotVisible: "There was a problem loading the project."
|
||||
- assertNotVisible: "The specified child already has a parent"
|
||||
@@ -0,0 +1,22 @@
|
||||
# NOTE: Render through the workspace-create shell scripts before running when
|
||||
# using ${PASEO_MAESTRO_*}. Maestro does not substitute environment vars.
|
||||
appId: ${PASEO_MAESTRO_APP_ID}
|
||||
---
|
||||
# Start from the connected home screen and leave the Android sidebar open with
|
||||
# the prepared project visible.
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "What shall we build today?"
|
||||
timeout: 60000
|
||||
|
||||
- tapOn:
|
||||
id: "menu-button"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "sidebar-project-list"
|
||||
timeout: 10000
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: ${PASEO_MAESTRO_PROJECT_NAME}
|
||||
timeout: 30000
|
||||
50
packages/app/maestro/image-picker-repro.yaml
Normal file
50
packages/app/maestro/image-picker-repro.yaml
Normal file
@@ -0,0 +1,50 @@
|
||||
appId: sh.paseo
|
||||
---
|
||||
- runFlow: flows/land-in-chat.yaml
|
||||
|
||||
- tapOn:
|
||||
id: "message-input-attach-button"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "message-input-attachment-menu"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn:
|
||||
id: "message-input-attachment-menu-item-image"
|
||||
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Allow Full Access"
|
||||
commands:
|
||||
- tapOn: "Allow Full Access"
|
||||
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Allow Access to All Photos"
|
||||
commands:
|
||||
- tapOn: "Allow Access to All Photos"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible: "Cancel"
|
||||
timeout: 30000
|
||||
|
||||
- tapOn: "Cancel"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "message-input-root"
|
||||
timeout: 10000
|
||||
|
||||
# If the dropdown Modal left an invisible backdrop mounted behind the picker,
|
||||
# this tap is swallowed and the attachment menu never reopens.
|
||||
- tapOn:
|
||||
id: "message-input-attach-button"
|
||||
|
||||
- extendedWaitUntil:
|
||||
visible:
|
||||
id: "message-input-attachment-menu"
|
||||
timeout: 10000
|
||||
|
||||
- tapOn:
|
||||
id: "message-input-attachment-menu-backdrop"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user