mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
644 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e0c9d0662 | ||
|
|
654ebadaf5 | ||
|
|
c2b4f3aa18 | ||
|
|
64e3a7152d | ||
|
|
daae71d1a8 | ||
|
|
c1453b25a7 | ||
|
|
c47f3190d8 | ||
|
|
0803d5ff41 | ||
|
|
7ed0ce97df | ||
|
|
fcd93a23ac | ||
|
|
ef3b8a42fd | ||
|
|
8b1bb90ce3 | ||
|
|
524aef5d75 | ||
|
|
69d36c76fe | ||
|
|
38f0560f9b | ||
|
|
25d6fc8515 | ||
|
|
d1eb976653 | ||
|
|
14d176a4e2 | ||
|
|
04fb0f9b82 | ||
|
|
cdcf3d8d55 | ||
|
|
cfc9665f6a | ||
|
|
1350167dda | ||
|
|
bb2f540f29 | ||
|
|
f861aa2e88 | ||
|
|
3585c80266 | ||
|
|
0ee76b2ea0 | ||
|
|
65ace20880 | ||
|
|
2e1b907c63 | ||
|
|
23502e474d | ||
|
|
b0a0cb4a99 | ||
|
|
342e92d0c7 | ||
|
|
0170eba233 | ||
|
|
b6103a59da | ||
|
|
24526a3b23 | ||
|
|
6d1aa1f415 | ||
|
|
4b973ae9ec | ||
|
|
e02e8426d4 | ||
|
|
e4188f5222 | ||
|
|
8262fb42af | ||
|
|
3176f844e7 | ||
|
|
8234ecb7ba | ||
|
|
adb9a57cfc | ||
|
|
93c14cb8ad | ||
|
|
a00152290f | ||
|
|
8e4cbf8ca6 | ||
|
|
0737c5c973 | ||
|
|
c4f9874e1f | ||
|
|
990bca71b7 | ||
|
|
f431ebee6d | ||
|
|
93189148f5 | ||
|
|
3baba543a4 | ||
|
|
f20393dbb7 | ||
|
|
3ac182cffc | ||
|
|
5cede0a7bb | ||
|
|
53c14d9855 | ||
|
|
0ea41378a4 | ||
|
|
00759e7994 | ||
|
|
e3eb333ddc | ||
|
|
a025f17a73 | ||
|
|
a4cb7431d8 | ||
|
|
5696cdb455 | ||
|
|
5a56835db3 | ||
|
|
698d549983 | ||
|
|
fa1b3e88f0 | ||
|
|
5d8dc800fc | ||
|
|
dbfd42da46 | ||
|
|
2894917a1c | ||
|
|
9c2d47ab34 | ||
|
|
d787aefa4c | ||
|
|
8307a0ca6f | ||
|
|
7aa49b2905 | ||
|
|
d594bce153 | ||
|
|
a630986d06 | ||
|
|
724a499413 | ||
|
|
8aa1530be1 | ||
|
|
1908ab8765 | ||
|
|
5ad6cff039 | ||
|
|
8a463c55c8 | ||
|
|
aecb300073 | ||
|
|
4911b627f2 | ||
|
|
a5b82d2a3b | ||
|
|
09bf981f13 | ||
|
|
3cf92ad6c5 | ||
|
|
153fa42a95 | ||
|
|
6d205f8853 | ||
|
|
5468089ac0 | ||
|
|
0ab41fbd9a | ||
|
|
ed6caa11c0 | ||
|
|
74c8942a28 | ||
|
|
3eb1ba7d73 | ||
|
|
1d2c8b1648 | ||
|
|
88914ccba6 | ||
|
|
f79101a1f0 | ||
|
|
b492c70825 | ||
|
|
3ace6c5602 | ||
|
|
be7979fb34 | ||
|
|
58e7fd0e3e | ||
|
|
81407c5ffc | ||
|
|
9c5f6007be | ||
|
|
fab721205d | ||
|
|
e74c7f9554 | ||
|
|
4231dbbba4 | ||
|
|
7e792e899a | ||
|
|
1d38aacf5c | ||
|
|
a3071b46a4 | ||
|
|
483790d8c9 | ||
|
|
bde0efc9b0 | ||
|
|
6f16900b8a | ||
|
|
c31ab074e9 | ||
|
|
bf69ebeddf | ||
|
|
22e014aaff | ||
|
|
91b05b4111 | ||
|
|
6e56ee9e32 | ||
|
|
48d4e3e408 | ||
|
|
30a8bffc24 | ||
|
|
d271597ee8 | ||
|
|
893dd6d1fe | ||
|
|
53faba64ca | ||
|
|
e5658654c0 | ||
|
|
f0730d5eac | ||
|
|
e312e0d6f3 | ||
|
|
9806a893ed | ||
|
|
5dd2afaac9 | ||
|
|
2252046f56 | ||
|
|
423956c6a0 | ||
|
|
19289286a6 | ||
|
|
e40ad0c00e | ||
|
|
1e68283565 | ||
|
|
8a2e5c786e | ||
|
|
178708a44e | ||
|
|
05eec04b10 | ||
|
|
b7ea5b4c9d | ||
|
|
655d05add8 | ||
|
|
f137705f1e | ||
|
|
6b35c0a640 | ||
|
|
a91dfb63c6 | ||
|
|
132e572d6e | ||
|
|
e966f70322 | ||
|
|
62780448dc | ||
|
|
5755ca77f8 | ||
|
|
ba724956df | ||
|
|
7fbbb44ad6 | ||
|
|
4c97ff8fa0 | ||
|
|
c0d2f20056 | ||
|
|
2a3cfc684b | ||
|
|
68c893f643 | ||
|
|
d43d30eeeb | ||
|
|
79dcbdc1c1 | ||
|
|
94bccf19ba | ||
|
|
83f205bd3d | ||
|
|
846c9b9da3 | ||
|
|
84f1dfc8cb | ||
|
|
33892f0698 | ||
|
|
0962529d48 | ||
|
|
008e4e846f | ||
|
|
789a559b31 | ||
|
|
f5f1ae7fa9 | ||
|
|
8e0ebfcaaa | ||
|
|
b151dfcfd5 | ||
|
|
de7bf2fb01 | ||
|
|
5f11f602fb | ||
|
|
369c5a4498 | ||
|
|
c46ff2e045 | ||
|
|
af10e64f82 | ||
|
|
db44a3e0e1 | ||
|
|
25c4cee01e | ||
|
|
3ec4e2c536 | ||
|
|
7dd9cbc506 | ||
|
|
6dc22483c5 | ||
|
|
a9f5b8ea4d | ||
|
|
1ce30bac05 | ||
|
|
754f5a1f0b | ||
|
|
698390bf1c | ||
|
|
63b24ec261 | ||
|
|
ad060e2f86 | ||
|
|
1e595ad9cb | ||
|
|
9fd93f8308 | ||
|
|
fac81f568a | ||
|
|
57de8c3807 | ||
|
|
0310fd3c3c | ||
|
|
a837d3e5d1 | ||
|
|
b5a78b7261 | ||
|
|
e630e205db | ||
|
|
8865f41d58 | ||
|
|
b94527fe1f | ||
|
|
dee8f485de | ||
|
|
28415b3542 | ||
|
|
3dd305082e | ||
|
|
cae3deb6e7 | ||
|
|
ea36f0879f | ||
|
|
3bca1a72e8 | ||
|
|
68d88f0928 | ||
|
|
8a69d66f8e | ||
|
|
c7872d968b | ||
|
|
8c4f5940d6 | ||
|
|
9b2b511abe | ||
|
|
e58ea31d6d | ||
|
|
c88f6fb2c2 | ||
|
|
a3a8527a1c | ||
|
|
f1b3e25344 | ||
|
|
18a1bdcf72 | ||
|
|
f933bf6b3a | ||
|
|
905a0985f9 | ||
|
|
ca913728d9 | ||
|
|
0d495a9625 | ||
|
|
3786cf3569 | ||
|
|
8a6bdb2d01 | ||
|
|
555d10f046 | ||
|
|
a1a5119bc5 | ||
|
|
4cf985c254 | ||
|
|
0e1c590a3b | ||
|
|
b7a8567092 | ||
|
|
ad9b149bf7 | ||
|
|
306601a0c3 | ||
|
|
da0e94fd18 | ||
|
|
84fb7e9e06 | ||
|
|
5dd6b030f2 | ||
|
|
bc7798af28 | ||
|
|
f238cbc20c | ||
|
|
41c5eea678 | ||
|
|
352e8fb6eb | ||
|
|
8d65f8bc96 | ||
|
|
2c561536e4 | ||
|
|
3743df09e6 | ||
|
|
0a2307d199 | ||
|
|
0f6641c8c0 | ||
|
|
fdecd75f94 | ||
|
|
b41cb72da0 | ||
|
|
cb96485035 | ||
|
|
ce822f989f | ||
|
|
e38d0e0fa9 | ||
|
|
bc32b16b02 | ||
|
|
b2cdbdaed8 | ||
|
|
9ef7230417 | ||
|
|
383b380d8a | ||
|
|
339ca2fc83 | ||
|
|
8ff63a6d71 | ||
|
|
aaabadb04b | ||
|
|
501dcf373b | ||
|
|
5a1c7f266c | ||
|
|
9bc6210823 | ||
|
|
8aa8f7a0cc | ||
|
|
63fe5dd0df | ||
|
|
1b9861846c | ||
|
|
68bce623c8 | ||
|
|
2072265b98 | ||
|
|
f7ef0e0b84 | ||
|
|
0419346d6a | ||
|
|
2866a12984 | ||
|
|
120f5d94a9 | ||
|
|
29ce6653fd | ||
|
|
028839b3cb | ||
|
|
82319f5805 | ||
|
|
69fc6fe754 | ||
|
|
1b2a28be47 | ||
|
|
665d9cedb5 | ||
|
|
0d0012959a | ||
|
|
77b92b58f7 | ||
|
|
d4ebf1815c | ||
|
|
2f638fe6eb | ||
|
|
b8154aa72b | ||
|
|
a2d8ce07d6 | ||
|
|
721f1ee8d3 | ||
|
|
b55aa042d4 | ||
|
|
1f7fc232b3 | ||
|
|
338a41991d | ||
|
|
ee44b536f5 | ||
|
|
efc9c2d345 | ||
|
|
c4e45af565 | ||
|
|
e27734b218 | ||
|
|
686a25fb65 | ||
|
|
0e3a78b308 | ||
|
|
edb0ba888a | ||
|
|
00e7ac9ec8 | ||
|
|
b3c272f720 | ||
|
|
bb5c3dae00 | ||
|
|
ed7944cf9d | ||
|
|
91d257e103 | ||
|
|
e379707a68 | ||
|
|
f6afe0d864 | ||
|
|
952b58c0eb | ||
|
|
9324a5b67a | ||
|
|
ec646db844 | ||
|
|
1812b14898 | ||
|
|
1c38ffe0a9 | ||
|
|
f4a4e0c25c | ||
|
|
a7dd0da2aa | ||
|
|
618b4a30b1 | ||
|
|
4b02daed8a | ||
|
|
667f441cc0 | ||
|
|
5762055213 | ||
|
|
675817f0b2 | ||
|
|
35c582913d | ||
|
|
e309d82b41 | ||
|
|
5ea68dcdfa | ||
|
|
01eb1ad512 | ||
|
|
39094078a9 | ||
|
|
160d861534 | ||
|
|
6cbe4e6a14 | ||
|
|
0d05ff7efd | ||
|
|
dbe24b609d | ||
|
|
bb6403e426 | ||
|
|
9bf36cdb56 | ||
|
|
1f79c4d039 | ||
|
|
5a74970d4a | ||
|
|
eed61d338c | ||
|
|
2f13c83c0e | ||
|
|
12e24f9620 | ||
|
|
37bb168204 | ||
|
|
57db997028 | ||
|
|
29d46c5d92 | ||
|
|
a72c7f6c4b | ||
|
|
1962225647 | ||
|
|
2d3c9101f3 | ||
|
|
f27d39fb63 | ||
|
|
5a3fc96b76 | ||
|
|
fbeda4510e | ||
|
|
7e255a5249 | ||
|
|
a19b780eca | ||
|
|
faa0d93cb8 | ||
|
|
2dd77aae04 | ||
|
|
2e8bced707 | ||
|
|
8296c797ff | ||
|
|
e1ab779fe0 | ||
|
|
eb7fa02ac8 | ||
|
|
44292b2b69 | ||
|
|
05c12a9c5c | ||
|
|
5c7a6397da | ||
|
|
f1e5640a26 | ||
|
|
512702fe46 | ||
|
|
ecc6a269bc | ||
|
|
7a0606952d | ||
|
|
68fae16740 | ||
|
|
bc329a2d85 | ||
|
|
e96cd16a0f | ||
|
|
316eeee9ca | ||
|
|
c3012ea520 | ||
|
|
e2ecae0e72 | ||
|
|
49fa72d70d | ||
|
|
c6534b3a16 | ||
|
|
15631b815b | ||
|
|
d30a3a72da | ||
|
|
6ed7dfc42a | ||
|
|
77dc80b86f | ||
|
|
2bd2e8bc46 | ||
|
|
81697f85b2 | ||
|
|
72293f7f4f | ||
|
|
65d14d4433 | ||
|
|
9eb1ddf29d | ||
|
|
d18bfe9047 | ||
|
|
c741185100 | ||
|
|
ea0bb81100 | ||
|
|
9d7d1e8500 | ||
|
|
16c27d7404 | ||
|
|
b8a3eefd47 | ||
|
|
d24087c10a | ||
|
|
15cab6014c | ||
|
|
47d236a299 | ||
|
|
b9cea49f10 | ||
|
|
ad88682426 | ||
|
|
1ddbdf54f1 | ||
|
|
51d9563352 | ||
|
|
045b373168 | ||
|
|
985ad52cce | ||
|
|
d198c68b9e | ||
|
|
751a07124f | ||
|
|
defb4f82f7 | ||
|
|
4570e65ce8 | ||
|
|
db0d63dd90 | ||
|
|
1a8fdcd388 | ||
|
|
417abed6a5 | ||
|
|
6afdeef84a | ||
|
|
77c82dfdbd | ||
|
|
29277c900d | ||
|
|
ed1943058a | ||
|
|
e0361ddd22 | ||
|
|
ce9474055e | ||
|
|
8f9b4c8828 | ||
|
|
af4e0de9ab | ||
|
|
3acc71b8ad | ||
|
|
0759932dad | ||
|
|
95f45e4e2b | ||
|
|
a5c2b97e1d | ||
|
|
d32462e9ee | ||
|
|
33262843a5 | ||
|
|
1cd02a0e1a | ||
|
|
40ab9e3f20 | ||
|
|
4141c76258 | ||
|
|
7f44323686 | ||
|
|
152b07b599 | ||
|
|
84f36d2e20 | ||
|
|
25d4c5023a | ||
|
|
3f5acfff31 | ||
|
|
b9940e285c | ||
|
|
9993c6c6c3 | ||
|
|
3b7971a463 | ||
|
|
d75d2d857d | ||
|
|
cab42985a5 | ||
|
|
ef892bd27d | ||
|
|
3014576c4c | ||
|
|
b8c77bf0e3 | ||
|
|
17073fe8ff | ||
|
|
6220b47073 | ||
|
|
bf7f8f686b | ||
|
|
93cd4734ce | ||
|
|
e4acd6cb7a | ||
|
|
ca11fc667b | ||
|
|
36e54a097e | ||
|
|
ecd3137d34 | ||
|
|
f881f9ae32 | ||
|
|
3f6b84899a | ||
|
|
5e64a1340c | ||
|
|
2d0ed004e2 | ||
|
|
183cda2b66 | ||
|
|
ed2a97fda8 | ||
|
|
2fed0f09bb | ||
|
|
478aa4b70e | ||
|
|
fd74abcdca | ||
|
|
444e265275 | ||
|
|
2ee9329663 | ||
|
|
b30aafc2bd | ||
|
|
90cb20ce79 | ||
|
|
bd6feac235 | ||
|
|
9f3256a254 | ||
|
|
4165dbe8e9 | ||
|
|
15e15b525f | ||
|
|
371c29fecd | ||
|
|
7d823fd3ec | ||
|
|
ab4525db5b | ||
|
|
73d27bd5fe | ||
|
|
6db5200890 | ||
|
|
9dd5c6e5f3 | ||
|
|
407c4ca554 | ||
|
|
47f26e99db | ||
|
|
263886fc1c | ||
|
|
bb5ac5b1d0 | ||
|
|
9c1c2cea76 | ||
|
|
c0037e56af | ||
|
|
4f16056578 | ||
|
|
a4bcc7b169 | ||
|
|
f17fb77013 | ||
|
|
0079f2a875 | ||
|
|
0abcd21cd3 | ||
|
|
5fb6e0e74d | ||
|
|
d88af28e93 | ||
|
|
73f35537f9 | ||
|
|
e5deb90986 | ||
|
|
d5ffc51f07 | ||
|
|
861917836a | ||
|
|
314322d43d | ||
|
|
cce70d3080 | ||
|
|
43f136deba | ||
|
|
c725352888 | ||
|
|
d1bda25a0b | ||
|
|
d241c24776 | ||
|
|
ca503c1560 | ||
|
|
c34fb26359 | ||
|
|
030e163fb8 | ||
|
|
8b09846f98 | ||
|
|
e3ef88c297 | ||
|
|
3eda7dd15b | ||
|
|
34df55a10a | ||
|
|
0229ef94f1 | ||
|
|
5b884aee0c | ||
|
|
8be2692b8a | ||
|
|
103601705d | ||
|
|
68ceac492c | ||
|
|
ed07452c80 | ||
|
|
c097678cdf | ||
|
|
0364e8dc5e | ||
|
|
650d3148e9 | ||
|
|
511580a47e | ||
|
|
0be9764194 | ||
|
|
c40e1f03db | ||
|
|
3a5d999006 | ||
|
|
832a2031bb | ||
|
|
1042270298 | ||
|
|
436bab0c8a | ||
|
|
4792cba794 | ||
|
|
39099a54b8 | ||
|
|
c9d842bd33 | ||
|
|
ee4d72c706 | ||
|
|
53e477bc00 | ||
|
|
58fc2bb434 | ||
|
|
6e75c72492 | ||
|
|
3ed5567c8a | ||
|
|
ad08868778 | ||
|
|
5b6e974248 | ||
|
|
04b04fe5ee | ||
|
|
9d5a47b1c3 | ||
|
|
5c90449707 | ||
|
|
e490bf1dab | ||
|
|
b131876e69 | ||
|
|
b7d2e6d3ec | ||
|
|
09ed46b997 | ||
|
|
a4d365c8f2 | ||
|
|
13538dd710 | ||
|
|
e60350be08 | ||
|
|
b0c36f2bab | ||
|
|
d708f099be | ||
|
|
cb2fe91998 | ||
|
|
d7820b7a84 | ||
|
|
fb0fbb79f6 | ||
|
|
c97c000d9c | ||
|
|
c4e4a28bc0 | ||
|
|
39e461b872 | ||
|
|
734e15e5a3 | ||
|
|
90bf6571d6 | ||
|
|
90fe71bd54 | ||
|
|
89a500cd3e | ||
|
|
a1ac402154 | ||
|
|
f7eac82593 | ||
|
|
4a9c2450b7 | ||
|
|
27f33be0e7 | ||
|
|
4c11f7c40b | ||
|
|
50405c3b6a | ||
|
|
8406559150 | ||
|
|
f92a296ce0 | ||
|
|
6786024333 | ||
|
|
56855dd6fd | ||
|
|
00a5b27586 | ||
|
|
73ed98c623 | ||
|
|
927309e867 | ||
|
|
2d2ee02ce3 | ||
|
|
4d4fbf7257 | ||
|
|
67c93dba49 | ||
|
|
2d9c7747fb | ||
|
|
4fa1db8d06 | ||
|
|
92be6c0cba | ||
|
|
4f0b264886 | ||
|
|
bad304c2d3 | ||
|
|
0785ee31f0 | ||
|
|
faf664d803 | ||
|
|
6fb2fe2283 | ||
|
|
cdce9a1235 | ||
|
|
285d4edf23 | ||
|
|
5c86956fef | ||
|
|
80bdb4d45b | ||
|
|
7558bd7947 | ||
|
|
16944da45b | ||
|
|
55ec13f153 | ||
|
|
c36f4cfee4 | ||
|
|
ac375f152d | ||
|
|
24bdd3c8ad | ||
|
|
7e8187ddc7 | ||
|
|
2e45650f22 | ||
|
|
6c319d05dd | ||
|
|
9eafab5a50 | ||
|
|
efff5b8454 | ||
|
|
2c2ded7492 | ||
|
|
7932e38d0d | ||
|
|
5e5fc97798 | ||
|
|
868479c493 | ||
|
|
0eacc98e17 | ||
|
|
ea4d6bb7ce | ||
|
|
40543128f7 | ||
|
|
eac392e366 | ||
|
|
5396811c58 | ||
|
|
11383fa356 | ||
|
|
322bb1d962 | ||
|
|
2402620d2f | ||
|
|
3ac3b71ad3 | ||
|
|
02e5b56408 | ||
|
|
60531b5e1b | ||
|
|
2415858d8f | ||
|
|
9f01b4b7a2 | ||
|
|
425aa20747 | ||
|
|
685a3d0ee2 | ||
|
|
53e1be4da6 | ||
|
|
eb6d19969d | ||
|
|
df5c59263e | ||
|
|
059d543c97 | ||
|
|
eb16829f51 | ||
|
|
b1d3867da9 | ||
|
|
006a79ed01 | ||
|
|
0d80394180 | ||
|
|
9721dadbe9 | ||
|
|
9bd1407ca6 | ||
|
|
3905b2e864 | ||
|
|
b92fb9392e | ||
|
|
5fc1fb1761 | ||
|
|
43ef9d77c1 | ||
|
|
1348cf908a | ||
|
|
b3d476ce49 | ||
|
|
53e064c59c | ||
|
|
4d102df2cf | ||
|
|
685e86cffc | ||
|
|
fedf155efd | ||
|
|
634a9f1e9a | ||
|
|
79dac91a7a | ||
|
|
b2af00b037 | ||
|
|
652793e00a | ||
|
|
5da0423e48 | ||
|
|
a978865f39 | ||
|
|
1955fa6371 | ||
|
|
5a63bc56f4 | ||
|
|
cb4051f4ea | ||
|
|
0a84c613f7 | ||
|
|
6bd5d4c410 | ||
|
|
90c7e591e8 | ||
|
|
fd30eafafc | ||
|
|
8d3946c36c | ||
|
|
75247efbd5 | ||
|
|
660b4d3cec | ||
|
|
c8274ba88a | ||
|
|
8b54d27cbc | ||
|
|
d359bffed5 | ||
|
|
b2c88e4312 | ||
|
|
ab9f07dd8a | ||
|
|
f974812dfc | ||
|
|
400934f19c | ||
|
|
82cf11aaae | ||
|
|
3bf8d483d3 | ||
|
|
84d822450e | ||
|
|
cccccca18e | ||
|
|
936deaa869 | ||
|
|
946152d820 | ||
|
|
44d13919d7 | ||
|
|
2b6d7d4ec2 | ||
|
|
f11aea3223 | ||
|
|
2b75df6132 | ||
|
|
b45f0a9ad1 | ||
|
|
4b260eb516 | ||
|
|
14c33aad0b | ||
|
|
e22b497a08 | ||
|
|
91865ba66e | ||
|
|
4d31cd4013 | ||
|
|
0775f59a6f | ||
|
|
5feaa7a101 | ||
|
|
4cd9e76bd2 | ||
|
|
78fe3e4df3 | ||
|
|
72e7c7e1ed | ||
|
|
b0c6631979 | ||
|
|
1e5e17f000 | ||
|
|
20de118373 | ||
|
|
fd4e26ca9b | ||
|
|
f89e5604d2 | ||
|
|
ca3e55813e | ||
|
|
3fad128d02 | ||
|
|
0cb9da17cd | ||
|
|
1571f002c7 | ||
|
|
84478a2dba | ||
|
|
93065b20f7 | ||
|
|
bb8762e122 |
11
.agents/skills/release-beta/SKILL.md
Normal file
11
.agents/skills/release-beta/SKILL.md
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: release-beta
|
||||
description: Cut a beta release of Paseo. Use when the user says "release beta", "cut a beta", "ship a beta", "beta release", or "/release-beta". Betas are silent release candidates — no changelog, no website move.
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Release beta
|
||||
|
||||
Read `docs/release.md` in the Paseo repo and follow the **Beta flow** section end-to-end. Run the **Beta release** completion checklist at the bottom of that doc.
|
||||
|
||||
Key rule the doc enforces — betas don't touch `CHANGELOG.md`. Don't draft release notes.
|
||||
11
.agents/skills/release-stable/SKILL.md
Normal file
11
.agents/skills/release-stable/SKILL.md
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: release-stable
|
||||
description: Cut a stable release of Paseo (fresh patch or promote from beta). Use when the user says "release stable", "ship stable", "promote", "release:patch", "release:promote", or "/release-stable".
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# Release stable
|
||||
|
||||
Read `docs/release.md` in the Paseo repo and follow the **Standard release (patch)** flow if cutting fresh, or the **Beta flow** promotion step if promoting an existing beta. Run the **Stable release (or promotion)** completion checklist at the bottom of that doc.
|
||||
|
||||
The doc covers the changelog (required for stable), the pre-release sanity check (required for stable), and the post-release babysit pattern. Don't skip steps.
|
||||
1
.claude/skills/release-beta
Symbolic link
1
.claude/skills/release-beta
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/release-beta
|
||||
1
.claude/skills/release-stable
Symbolic link
1
.claude/skills/release-stable
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/release-stable
|
||||
121
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
Normal file
121
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
Normal file
@@ -0,0 +1,121 @@
|
||||
name: Bug report
|
||||
description: Something is broken or doesn't behave the way it should.
|
||||
title: "bug: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Before opening, please:
|
||||
|
||||
- search existing issues for the same symptom
|
||||
- try to reproduce on the latest version
|
||||
- if it's a UI bug, capture a screenshot or short video. text descriptions of UI bugs almost always lose detail.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What's broken
|
||||
description: What happened, and what did you expect to happen instead?
|
||||
placeholder: |
|
||||
I tried to X, expected Y, got Z.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: The shortest sequence that triggers the bug. If you can't reproduce on demand, say so.
|
||||
placeholder: |
|
||||
1. Open the desktop app
|
||||
2. Pair a daemon
|
||||
3. Click X
|
||||
4. ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: surface
|
||||
attributes:
|
||||
label: Where did this happen
|
||||
description: The surface you saw the bug on. Pick the closest match.
|
||||
options:
|
||||
- iOS app
|
||||
- Android app
|
||||
- Web (browser)
|
||||
- Desktop (Electron)
|
||||
- CLI
|
||||
- Daemon
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: paseo-version
|
||||
attributes:
|
||||
label: Paseo version
|
||||
description: Settings → About in the app, or `paseo --version` from the CLI.
|
||||
placeholder: "0.1.71"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: os-version
|
||||
attributes:
|
||||
label: OS version
|
||||
description: Only relevant for desktop, CLI, or daemon issues. Skip for mobile or web.
|
||||
placeholder: "macOS 15.2, Windows 11, Ubuntu 24.04"
|
||||
|
||||
- type: dropdown
|
||||
id: provider
|
||||
attributes:
|
||||
label: Agent provider
|
||||
description: If the bug involves a specific agent provider, pick which one.
|
||||
options:
|
||||
- Not relevant
|
||||
- Claude Code
|
||||
- Codex
|
||||
- OpenCode
|
||||
- Custom provider
|
||||
|
||||
- type: textarea
|
||||
id: provider-details
|
||||
attributes:
|
||||
label: Provider configuration
|
||||
description: |
|
||||
If the bug involves an agent, what version are you on and what API are you using? Provider behavior changes a lot across versions and API backends.
|
||||
placeholder: |
|
||||
Claude Code v1.2.3 with Anthropic API
|
||||
Codex CLI v0.5.0 with OpenAI API
|
||||
OpenCode v0.3.1
|
||||
(or paste the relevant section of ~/.paseo/config.json for custom providers)
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs
|
||||
description: |
|
||||
Paste relevant log output. Strongly preferred for crashes and daemon issues.
|
||||
|
||||
- **Daemon log:** `~/.paseo/daemon.log` (override with `$PASEO_HOME`)
|
||||
- **Electron log (macOS):** `~/Library/Logs/Paseo/main.log`
|
||||
- **Electron log (Windows):** `%APPDATA%\Paseo\logs\main.log`
|
||||
- **Electron log (Linux):** `~/.config/Paseo/logs/main.log`
|
||||
|
||||
Paste the **full log around the time of the bug**, not a summary. If you used an AI to investigate, paste the raw log it read, not the AI's interpretation. AI summaries skew the signal and waste my time.
|
||||
render: text
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots or video
|
||||
description: |
|
||||
**Required for UI bugs.** Drag and drop directly into this field. Short videos beat screenshots for anything involving interaction or animation.
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
---
|
||||
|
||||
**A note on AI-assisted reports.** Using an agent to gather information (logs, repro steps, version checks) is fine and useful. Using an agent to *diagnose* the bug and then submitting only that diagnosis is not. Agents routinely correlate adjacent log lines as cause-and-effect when they aren't related, and once a report is filtered through an AI summary I lose the signal I need to actually fix the bug. Paste the raw inputs.
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discord
|
||||
url: https://discord.gg/jz8T2uahpH
|
||||
about: Quick questions, sharing a video of a bug, or anything that's better as a chat. A lot of issues start better here.
|
||||
43
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
43
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Feature request
|
||||
description: Propose a new feature or a change to existing behavior.
|
||||
title: "feat: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Paseo is opinionated and maintained by one person. Feature requests are welcome, but they get evaluated against product fit, not just usefulness, and the bar is whether the change keeps the product lean enough for one person to maintain.
|
||||
|
||||
Big ideas are better discussed in [Discord](https://discord.gg/jz8T2uahpH) first. And please don't open a feature request and a PR at the same time, get alignment on the idea before writing code.
|
||||
|
||||
- type: checkboxes
|
||||
id: prior-search
|
||||
attributes:
|
||||
label: Prior search
|
||||
options:
|
||||
- label: I searched existing issues and discussions, and this isn't already proposed.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: What's the problem
|
||||
description: What are you actually trying to do, and why is the current behavior in the way?
|
||||
placeholder: |
|
||||
When I'm doing X, I want to Y, but Paseo currently Z.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: What would solve it
|
||||
description: A rough sketch of the change. Mockups, screenshots from other apps, or a short video are very welcome, especially for UI proposals.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives you considered
|
||||
description: Optional. What else did you try, and why doesn't it work?
|
||||
43
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
43
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
<!--
|
||||
Please follow this template. The PR template applies whether you opened the PR via the web UI, `gh pr create`, or any other tool.
|
||||
|
||||
If you're fixing an objective bug or a small focused issue, this should be quick. Big PRs without a prior issue or design discussion are likely to be closed or scoped down. See CONTRIBUTING.md.
|
||||
-->
|
||||
|
||||
### Linked issue
|
||||
|
||||
Closes #
|
||||
|
||||
<!-- Bug fixes and behavior changes should reference an issue. Pure docs and refactors can skip this. -->
|
||||
|
||||
### Type of change
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature (with prior issue + design alignment)
|
||||
- [ ] Refactor / code improvement
|
||||
- [ ] Docs
|
||||
|
||||
### What does this PR do
|
||||
|
||||
<!-- A short description of the change in your own words. What was wrong, what you changed, why it works. If you can't explain this briefly, the PR is probably too big. -->
|
||||
|
||||
### How did you verify it
|
||||
|
||||
<!--
|
||||
This is the section I read most carefully. I need to see that *you* tested this, not that the diff looks plausible.
|
||||
|
||||
- For UI changes: a screenshot or short video on every affected platform (mobile, web, desktop). UI claims without visual proof are not enough.
|
||||
- For behavior changes: the actual steps you ran, and what you observed.
|
||||
- For bug fixes: how you reproduced the bug before, and confirmed it's fixed after.
|
||||
|
||||
AI-generated PR descriptions are fine in principle. AI-generated *verification claims* with no actual testing behind them are not, and they're easy to spot.
|
||||
-->
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] One focused change. Unrelated cleanups split out.
|
||||
- [ ] `npm run typecheck` passes
|
||||
- [ ] `npm run lint` passes
|
||||
- [ ] `npm run format` ran (Biome)
|
||||
- [ ] UI changes include screenshots or video for every affected platform
|
||||
- [ ] Tests added or updated where it made sense
|
||||
175
.github/workflows/ci.yml
vendored
175
.github/workflows/ci.yml
vendored
@@ -5,8 +5,18 @@ on:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
# CI does not use the CUDA execution provider, and the onnxruntime-node
|
||||
# postinstall download from NuGet is large enough to make npm ci flaky.
|
||||
ONNXRUNTIME_NODE_INSTALL: skip
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -19,7 +29,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Check formatting
|
||||
run: npx oxfmt --check .
|
||||
@@ -35,7 +45,13 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Lint lockfile
|
||||
run: npx --yes lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https --validate-integrity
|
||||
|
||||
- name: Verify dependency signatures
|
||||
run: npm audit signatures
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
@@ -51,22 +67,27 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- 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: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
- name: Typecheck all packages
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Verify public package contents
|
||||
run: |
|
||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol
|
||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/client
|
||||
npm pack --dry-run --ignore-scripts --workspace=@getpaseo/server
|
||||
|
||||
server-tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: server-tests (${{ matrix.os }})
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -81,62 +102,20 @@ jobs:
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @anthropic-ai/claude-code opencode-ai
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
- name: Build server dependencies
|
||||
run: npm run build:server-deps
|
||||
|
||||
- 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: "22"
|
||||
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.percent-escape.test.ts
|
||||
src/utils/spawn.test.ts
|
||||
src/utils/process-tree.test.ts
|
||||
src/utils/run-git-command.test.ts
|
||||
src/utils/checkout-git-rev-parse.test.ts
|
||||
src/terminal/worker-terminal-manager.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
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
|
||||
desktop-tests:
|
||||
strategy:
|
||||
@@ -153,16 +132,10 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- 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: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
- name: Run desktop tests
|
||||
run: npm run test --workspace=@getpaseo/desktop
|
||||
@@ -178,17 +151,42 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
- name: Build app dependencies
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Run app unit tests
|
||||
run: npm run test --workspace=@getpaseo/app
|
||||
|
||||
sdk-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build client dependencies
|
||||
run: npm run build:client
|
||||
|
||||
- name: Run protocol tests
|
||||
run: npm run test --workspace=@getpaseo/protocol
|
||||
|
||||
- name: Run client tests
|
||||
run: npm run test --workspace=@getpaseo/client
|
||||
|
||||
- name: Typecheck client examples
|
||||
run: npm run typecheck:examples --workspace=@getpaseo/client
|
||||
|
||||
playwright:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -200,22 +198,19 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
- name: Build app dependencies
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Build server dependency
|
||||
run: npm run build --workspace=@getpaseo/server
|
||||
- name: Build server stack
|
||||
run: npm run build:server
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @openai/codex@0.105.0 opencode-ai
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
run: npm run test:e2e --workspace=@getpaseo/app
|
||||
@@ -243,16 +238,21 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Build relay
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
run: npm run build:relay
|
||||
|
||||
- name: Run relay tests
|
||||
run: npm run test --workspace=@getpaseo/relay
|
||||
|
||||
cli-tests:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3]
|
||||
runs-on: ubuntu-latest
|
||||
name: cli-tests (shard ${{ matrix.shard }}/3)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -262,13 +262,10 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Install agent CLIs for provider tests
|
||||
run: npm install -g @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
run: npm install -g @anthropic-ai/claude-code @openai/codex@0.105.0 opencode-ai
|
||||
|
||||
- name: Run CLI tests
|
||||
run: npm run test --workspace=@getpaseo/cli
|
||||
@@ -276,3 +273,5 @@ jobs:
|
||||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0"
|
||||
PASEO_DICTATION_ENABLED: "0"
|
||||
PASEO_VOICE_MODE_ENABLED: "0"
|
||||
PASEO_CLI_TEST_SHARD: ${{ matrix.shard }}
|
||||
PASEO_CLI_TEST_SHARD_TOTAL: "3"
|
||||
|
||||
4
.github/workflows/deploy-app.yml
vendored
4
.github/workflows/deploy-app.yml
vendored
@@ -28,8 +28,8 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
- name: Build app dependencies
|
||||
run: npm run build:app-deps
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/app
|
||||
|
||||
2
.github/workflows/deploy-relay.yml
vendored
2
.github/workflows/deploy-relay.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --workspace=@getpaseo/relay --include-workspace-root
|
||||
run: npm ci --workspace=@getpaseo/relay --include-workspace-root
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/relay
|
||||
|
||||
3
.github/workflows/deploy-website.yml
vendored
3
.github/workflows/deploy-website.yml
vendored
@@ -5,6 +5,7 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "CHANGELOG.md"
|
||||
- "public-docs/**"
|
||||
- "packages/website/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
@@ -28,7 +29,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --workspace=@getpaseo/website --include-workspace-root
|
||||
run: npm ci --workspace=@getpaseo/website --include-workspace-root
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/website
|
||||
|
||||
28
.github/workflows/desktop-release.yml
vendored
28
.github/workflows/desktop-release.yml
vendored
@@ -40,7 +40,7 @@ on:
|
||||
rollout_hours:
|
||||
description: "Linear rollout duration in hours. Use 0 for instant rollout."
|
||||
required: false
|
||||
default: "24"
|
||||
default: "36"
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
@@ -51,7 +51,7 @@ env:
|
||||
SOURCE_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref_name }}
|
||||
CHECKOUT_REF: ${{ github.event_name == 'workflow_dispatch' && (github.event.inputs.checkout_ref || github.ref_name) || github.ref_name }}
|
||||
SHOULD_PUBLISH: ${{ github.event_name != 'workflow_dispatch' || github.event.inputs.publish != 'false' }}
|
||||
ROLLOUT_HOURS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.rollout_hours || '24' }}
|
||||
ROLLOUT_HOURS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.rollout_hours || '36' }}
|
||||
DESKTOP_PACKAGE_PATH: "packages/desktop"
|
||||
|
||||
jobs:
|
||||
@@ -183,6 +183,14 @@ jobs:
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-macos-${{ matrix.electron_arch }}
|
||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*.dmg
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload manifest artifact
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -273,6 +281,14 @@ jobs:
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-linux
|
||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload manifest artifact
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -360,6 +376,14 @@ jobs:
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber --repo "${{ github.repository }}"
|
||||
|
||||
- name: Upload desktop artifacts to workflow
|
||||
if: env.SHOULD_PUBLISH != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-windows
|
||||
path: ${{ env.DESKTOP_PACKAGE_PATH }}/release/*
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload manifest artifact
|
||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
45
.github/workflows/fix-nix-hash.yml
vendored
45
.github/workflows/fix-nix-hash.yml
vendored
@@ -1,45 +0,0 @@
|
||||
name: Fix Nix hash
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
pull_request:
|
||||
paths:
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
fix-nix-hash:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref || github.ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Fix lockfile and update hash
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Commit changes
|
||||
run: |
|
||||
git diff --quiet package-lock.json nix/package.nix && exit 0
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add package-lock.json nix/package.nix
|
||||
git commit -m "fix: update lockfile signatures and Nix hash"
|
||||
git push
|
||||
59
.github/workflows/nix-build.yml
vendored
59
.github/workflows/nix-build.yml
vendored
@@ -1,59 +0,0 @@
|
||||
name: Nix Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Verify lockfile is complete
|
||||
# npm silently omits resolved/integrity fields in workspace monorepos.
|
||||
# Nix needs them for offline builds. See https://github.com/npm/cli/issues/4460
|
||||
run: |
|
||||
node scripts/fix-lockfile.mjs package-lock.json
|
||||
git diff --exit-code package-lock.json || {
|
||||
echo "ERROR: package-lock.json has missing resolved/integrity fields."
|
||||
echo "This is a known npm bug: https://github.com/npm/cli/issues/4460"
|
||||
echo "Run 'node scripts/fix-lockfile.mjs' and commit the result."
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Check npmDepsHash is up to date
|
||||
run: ./scripts/update-nix.sh --check
|
||||
60
.github/workflows/nix-update-hash.yml
vendored
Normal file
60
.github/workflows/nix-update-hash.yml
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
name: Nix Update Hash
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix-update-hash.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update-hash:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.PASEO_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.PASEO_BOT_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Update lockfile + Nix hash if stale
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Commit hash/lockfile updates
|
||||
run: |
|
||||
git diff --quiet package-lock.json nix/npm-deps.hash && exit 0
|
||||
git config user.name "paseo-ai[bot]"
|
||||
git config user.email "266920839+paseo-ai[bot]@users.noreply.github.com"
|
||||
git add package-lock.json nix/npm-deps.hash
|
||||
git commit -m "fix: update lockfile signatures and Nix hash [skip ci]"
|
||||
git push
|
||||
101
.github/workflows/nix.yml
vendored
Normal file
101
.github/workflows/nix.yml
vendored
Normal file
@@ -0,0 +1,101 @@
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "nix/**"
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "packages/highlight/**"
|
||||
- "packages/protocol/**"
|
||||
- "packages/client/**"
|
||||
- "packages/server/**"
|
||||
- "packages/relay/**"
|
||||
- "packages/cli/**"
|
||||
- "scripts/update-nix.sh"
|
||||
- "scripts/fix-lockfile.mjs"
|
||||
- ".github/workflows/nix.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
nix_path: nixpkgs=channel:nixos-unstable
|
||||
|
||||
- name: Update lockfile + Nix hash if stale
|
||||
run: ./scripts/update-nix.sh
|
||||
|
||||
- name: Build Nix package
|
||||
run: nix build .#default -o result
|
||||
|
||||
- name: Smoke Nix daemon
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
export PASEO_HOME
|
||||
PASEO_HOME="$(mktemp -d)"
|
||||
export PASEO_LISTEN=127.0.0.1:6767
|
||||
|
||||
WRAPPER_LOG="$PASEO_HOME/paseo-server-wrapper.log"
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${DAEMON_PID:-}" ]] && kill -0 "$DAEMON_PID" 2>/dev/null; then
|
||||
kill "$DAEMON_PID"
|
||||
wait "$DAEMON_PID" || true
|
||||
fi
|
||||
rm -rf "$PASEO_HOME"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
./result/bin/paseo-server --no-relay >"$WRAPPER_LOG" 2>&1 &
|
||||
DAEMON_PID=$!
|
||||
|
||||
deadline=$((SECONDS + 30))
|
||||
while (( SECONDS < deadline )); do
|
||||
if STATUS_JSON="$(./result/bin/paseo daemon status --json)" \
|
||||
&& jq -e '.connectedDaemon == "reachable"' <<<"$STATUS_JSON" >/dev/null; then
|
||||
echo "$STATUS_JSON"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! kill -0 "$DAEMON_PID" 2>/dev/null; then
|
||||
echo "Nix daemon exited before becoming reachable."
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "::group::daemon.log"
|
||||
cat "$PASEO_HOME/daemon.log" 2>/dev/null || echo "<missing>"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::paseo-server stdout/stderr"
|
||||
cat "$WRAPPER_LOG" 2>/dev/null || echo "<missing>"
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::paseo daemon status"
|
||||
./result/bin/paseo daemon status || true
|
||||
echo "::endgroup::"
|
||||
|
||||
exit 1
|
||||
|
||||
- name: Build Nix desktop package
|
||||
run: nix build .#desktop -o result-desktop
|
||||
52
.github/workflows/server-ci.yml
vendored
52
.github/workflows/server-ci.yml
vendored
@@ -1,52 +0,0 @@
|
||||
name: Server CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/server/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".github/workflows/server-ci.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "packages/server/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- ".github/workflows/server-ci.yml"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
|
||||
- name: Fetch origin/main (worktree tests)
|
||||
run: git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
|
||||
- name: Install server dependencies
|
||||
run: npm install --workspace=@getpaseo/server --include-workspace-root
|
||||
|
||||
- name: Build highlight dependency
|
||||
run: npm run build --workspace=@getpaseo/highlight
|
||||
|
||||
- name: Build relay dependency
|
||||
run: npm run build --workspace=@getpaseo/relay
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck --workspace=@getpaseo/server
|
||||
|
||||
- name: Test
|
||||
run: npm run test --workspace=@getpaseo/server
|
||||
env:
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -6,6 +6,7 @@ build/
|
||||
dist/
|
||||
.next/
|
||||
out/
|
||||
result
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
@@ -13,6 +14,8 @@ out/
|
||||
.env.local
|
||||
.env.test.local
|
||||
.env*.local
|
||||
.dev.vars
|
||||
**/.dev.vars
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
@@ -64,6 +67,7 @@ CLAUDE.local.md
|
||||
.paseo/
|
||||
.wrangler/
|
||||
**/.wrangler/
|
||||
**/.tanstack/
|
||||
|
||||
# Local agent/tooling artifacts (do not commit)
|
||||
PLAN.md
|
||||
@@ -83,3 +87,4 @@ packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript
|
||||
/artifacts
|
||||
packages/desktop/.cache/
|
||||
packages/desktop/src-tauri/resources/managed-runtime/
|
||||
app.json
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"arrowParens": "always",
|
||||
"bracketSameLine": false,
|
||||
"bracketSpacing": true,
|
||||
"ignorePatterns": ["*.lock"]
|
||||
"ignorePatterns": ["*.lock", "**/*.gen.ts", "**/*.gen.tsx"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"options": {
|
||||
"typeAware": false
|
||||
},
|
||||
"plugins": ["react", "react-perf", "unicorn", "typescript", "oxc", "import", "promise"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
@@ -53,6 +56,7 @@
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/prefer-as-const": "error",
|
||||
"typescript/no-this-alias": "error",
|
||||
"typescript/no-unnecessary-type-assertion": "error",
|
||||
"typescript/consistent-type-definitions": ["error", "interface"],
|
||||
|
||||
"import/no-unassigned-import": [
|
||||
|
||||
1512
CHANGELOG.md
1512
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
67
CLAUDE.md
67
CLAUDE.md
@@ -2,7 +2,7 @@
|
||||
|
||||
Paseo is a mobile app for monitoring and controlling your local AI coding agents from anywhere. Your dev environment, in your pocket. Connects directly to your actual development environment — your code stays on your machine.
|
||||
|
||||
**Supported agents:** Claude Code, Codex, and OpenCode.
|
||||
**Supported agents:** Claude Code, Codex, GitHub Copilot, OpenCode, and Pi.
|
||||
|
||||
## Repository map
|
||||
|
||||
@@ -15,19 +15,35 @@ This is an npm workspace monorepo:
|
||||
- `packages/desktop` — Electron desktop wrapper
|
||||
- `packages/website` — Marketing site (paseo.sh)
|
||||
|
||||
## Documentation
|
||||
## Docs
|
||||
|
||||
| Doc | What's in it |
|
||||
| ---------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/design.md](docs/design.md) | How to design features before implementation |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
`docs/` is the source of truth for system-level and process-level knowledge. **"The docs", "check the docs", or "check the X docs" always mean this directory — not the web.** Look here before fetching anything online; the docs capture gotchas and conventions you cannot derive from the code or external sources.
|
||||
|
||||
At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`.
|
||||
|
||||
| Doc | What's in it |
|
||||
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going |
|
||||
| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow |
|
||||
| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track |
|
||||
| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations |
|
||||
| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms |
|
||||
| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization |
|
||||
| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons |
|
||||
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
|
||||
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
|
||||
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
|
||||
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
|
||||
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
|
||||
| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries |
|
||||
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
|
||||
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
|
||||
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
|
||||
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
|
||||
| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness |
|
||||
| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows |
|
||||
| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist |
|
||||
| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth |
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -55,19 +71,28 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
|
||||
- Never re-run a test suite that another agent already ran and reported green — trust the result.
|
||||
- For full suite verification, push to CI and check GitHub Actions instead.
|
||||
- **Always run typecheck and lint after every change.**
|
||||
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace (especially CLI depending on server/daemon types), rebuild the owning package first so `dist` declarations are current:
|
||||
- `npm run build:daemon` — rebuild highlight, relay, server, and CLI when daemon/server/CLI types may be stale.
|
||||
- **Build workspace packages before diagnosing cross-package type errors.** This repo consumes generated declarations across workspaces. If typecheck fails in a package that depends on another workspace, rebuild the owning stack first so `dist` declarations are current:
|
||||
- `npm run build:client` — rebuild protocol and client declarations.
|
||||
- `npm run build:server` — rebuild highlight, relay, protocol, client, server, and CLI when server/CLI types may be stale.
|
||||
- Do not patch inferred callback parameters or add local duplicate types just to silence stale declaration errors.
|
||||
- **Run `npm run format` before committing.** This repo uses Biome for formatting. Do not manually fix formatting — let the formatter handle it.
|
||||
- **Always use npm scripts for linting and formatting.** Do not run tools directly with `npx eslint`, `npx oxfmt`, `npx oxlint`, or package-local binaries. For targeted checks, pass file paths through the npm script:
|
||||
- `npm run lint -- packages/app/src/components/message.tsx`
|
||||
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
|
||||
- **NEVER make breaking changes to WebSocket or message schemas.** The primary compatibility path is old mobile app clients talking to newly updated daemons. Users update desktop and daemon first, then keep running the old app for a while. Every schema change MUST be backward-compatible for old clients against new daemons:
|
||||
- New fields: always `.optional()` with a sensible default or `.transform()` fallback.
|
||||
- Never change a field from optional to required.
|
||||
- Never remove a field — deprecate it (keep accepting it, stop sending it).
|
||||
- Never narrow a field's type (e.g. `string` → `enum`, `nullable` → non-null).
|
||||
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
||||
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
|
||||
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
|
||||
- New fields: `.optional()` with a sensible default or `.transform()` fallback.
|
||||
- Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null).
|
||||
- Removed fields stay accepted (we stop sending them, not stop reading them).
|
||||
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
|
||||
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
|
||||
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
|
||||
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
|
||||
- **Capability flags live in `server_info.features.*`** with a single `// COMPAT(featureName): added in v0.1.X, drop the gate when floor >= v0.1.X` comment marking the cleanup site.
|
||||
- Existing functionality keeps working across versions — that's the protocol contract doing its job. New-feature degradation is not the goal.
|
||||
- **New RPCs use dotted namespaces with direction suffixes.** Follow [docs/rpc-namespacing.md](docs/rpc-namespacing.md): `domain.provider.operation.request` pairs with `domain.provider.operation.response`. Existing flat RPC names will migrate over time; don't add new ones.
|
||||
|
||||
- **All back-compat shims are tagged and dated for cleanup.** Every shim that exists for old-client/old-daemon support carries a `COMPAT(name)` comment with the version it was added in and a target removal date (typically 6 months out). One grep — `rg "COMPAT\("` — should produce the full list of cleanup work. Don't bury back-compat in untagged `??`-fallbacks or optional-chain tunnels — that's how it stops being deletable.
|
||||
|
||||
## Platform gating
|
||||
|
||||
|
||||
178
CONTRIBUTING.md
178
CONTRIBUTING.md
@@ -1,168 +1,32 @@
|
||||
# Contributing to Paseo
|
||||
|
||||
Thanks for taking the time to contribute.
|
||||
Paseo is an opinionated product maintained by one person.
|
||||
|
||||
## How this project works
|
||||
I read every issue and PR myself, and I am selective about what contributions I accept.
|
||||
|
||||
Paseo is a BDFL project. Product direction, scope, and what ships are the maintainer's call.
|
||||
Good ideas still need to fit the shape of the product: a PR can be technically correct and still not belong in Paseo.
|
||||
|
||||
This means:
|
||||
Core product, design, architecture, and workflow changes are not accepted.
|
||||
|
||||
- PRs submitted without prior discussion will likely be rejected, heavily modified, or scoped down.
|
||||
- The maintainer may rewrite, split, cherry-pick from, or close any PR at their discretion.
|
||||
- There is no obligation to merge a PR as-submitted, regardless of code quality.
|
||||
Follow these rules if you want your PR to be merged:
|
||||
|
||||
This is not meant to discourage contributions. It is meant to set clear expectations so nobody wastes their time.
|
||||
- Keep it to one focused change
|
||||
- Link to an issue
|
||||
- Explain the problem you're solving
|
||||
- Include repro steps if it's a bug
|
||||
- Include QA/testing evidence
|
||||
- UI changes need screenshots or video for every affected platform: iOS, Android, desktop, and web
|
||||
- If you only tested one platform, say that clearly
|
||||
|
||||
## How to contribute
|
||||
Your PR will be closed if you do any of these:
|
||||
|
||||
1. **Open an issue first.** Describe the problem or improvement. Get a thumbs up before writing code.
|
||||
2. **Keep it small.** One bug, one flow, one focused change.
|
||||
3. **Open a PR** once there is alignment on scope.
|
||||
- Bundle unrelated changes
|
||||
- Fail basic checks like typecheck, formatting or linting
|
||||
- Make product, design, or architecture changes without prior discussion
|
||||
- Submit no evidence of testing
|
||||
- Skip the linked issue
|
||||
- Clearly fully AI-generated PR
|
||||
|
||||
If you want to propose a direction change, start a conversation.
|
||||
## AI assistance
|
||||
|
||||
## Before you start
|
||||
|
||||
Please read these first:
|
||||
|
||||
- [README.md](README.md)
|
||||
- [docs/architecture.md](docs/architecture.md)
|
||||
- [docs/development.md](docs/development.md)
|
||||
- [docs/coding-standards.md](docs/coding-standards.md)
|
||||
- [docs/testing.md](docs/testing.md)
|
||||
- [CLAUDE.md](CLAUDE.md)
|
||||
|
||||
## What is most helpful
|
||||
|
||||
The most useful contributions right now are:
|
||||
|
||||
- bug fixes
|
||||
- windows and linux specific fixes
|
||||
- regression fixes
|
||||
- doc improvements
|
||||
- packaging / platform fixes
|
||||
- focused UX improvements that fit the existing product direction
|
||||
- tests that lock down important behavior
|
||||
|
||||
## Scope expectations
|
||||
|
||||
Please keep PRs narrow.
|
||||
|
||||
Good:
|
||||
|
||||
- fix one bug
|
||||
- improve one flow
|
||||
- add one focused panel or command
|
||||
- tighten one piece of UI
|
||||
|
||||
Bad:
|
||||
|
||||
- combine multiple product ideas in one PR
|
||||
- bundle unrelated refactors with a feature
|
||||
- sneak in roadmap decisions
|
||||
|
||||
If a contribution contains multiple ideas, split it up.
|
||||
|
||||
## Product fit matters
|
||||
|
||||
Paseo is an opinionated product.
|
||||
|
||||
When reviewing contributions, the bar is not just:
|
||||
|
||||
- is this useful?
|
||||
- is this well implemented?
|
||||
|
||||
It is also:
|
||||
|
||||
- does this fit Paseo?
|
||||
- does this add product surface that will be hard to maintain?
|
||||
- does the value justify the maintenance surface it adds?
|
||||
- does this solve a common need or over-serve an edge case?
|
||||
- does this preserve the product's current direction?
|
||||
|
||||
## Development setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js matching `.tool-versions`
|
||||
- npm workspaces
|
||||
|
||||
### Start local development
|
||||
|
||||
```bash
|
||||
# runs both daemon and expo app
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
npm run dev:server
|
||||
npm run dev:app
|
||||
npm run dev:desktop
|
||||
npm run dev:website
|
||||
npm run cli -- ls -a -g
|
||||
```
|
||||
|
||||
Read [docs/development.md](docs/development.md) for build-sync gotchas, local state, ports, and daemon details.
|
||||
|
||||
## Multi-platform testing
|
||||
|
||||
Paseo ships to mobile (iOS/Android), web, and desktop (Electron). Every UI change must be tested on mobile and web at minimum, and desktop if relevant. Things that look fine on one surface regularly break on another.
|
||||
|
||||
Common checks:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run test --workspaces --if-present
|
||||
```
|
||||
|
||||
Important rules:
|
||||
|
||||
- always run `npm run typecheck` after changes
|
||||
- tests should be deterministic
|
||||
- prefer real dependencies over mocks when possible
|
||||
- do not make breaking WebSocket / protocol changes
|
||||
- app and daemon versions in the wild lag each other, so compatibility matters
|
||||
|
||||
If you touch protocol or shared client/server behavior, read the compatibility notes in [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
## Coding standards
|
||||
|
||||
Paseo has explicit standards. Follow them.
|
||||
|
||||
The full guide lives in [docs/coding-standards.md](docs/coding-standards.md).
|
||||
|
||||
## PR checklist
|
||||
|
||||
Before opening a PR, make sure:
|
||||
|
||||
- there was prior discussion and alignment on scope (issue or conversation)
|
||||
- the change is focused, one idea per PR
|
||||
- the PR description explains what changed and why
|
||||
- **UI changes include screenshots or videos** for every affected platform (mobile, web, desktop)
|
||||
- UI changes have been tested on mobile and web at minimum
|
||||
- typecheck passes
|
||||
- tests pass, or you clearly explain what could not be run
|
||||
- relevant docs were updated if needed
|
||||
|
||||
## Communication
|
||||
|
||||
If you are unsure whether something fits, ask first.
|
||||
|
||||
That is especially true for:
|
||||
|
||||
- new core UX
|
||||
- naming / terminology changes
|
||||
- new extension points
|
||||
- new orchestration models
|
||||
- anything that would be hard to remove later
|
||||
|
||||
Early alignment saves everyone time.
|
||||
|
||||
## Forks are fine
|
||||
|
||||
If you want to explore a different product direction, a fork is completely fine.
|
||||
|
||||
Paseo is open source on purpose. Not every idea needs to land in the main repo to be valuable.
|
||||
AI in the loop is fine. The bar is whether _you_ tested the change and can explain why it works. A confident wall of AI prose with no evidence of testing is a red flag and will get closed.
|
||||
|
||||
85
README.md
85
README.md
@@ -17,9 +17,12 @@
|
||||
<a href="https://discord.gg/jz8T2uahpH">
|
||||
<img src="https://img.shields.io/badge/Discord-555?logo=discord" alt="Discord">
|
||||
</a>
|
||||
<a href="https://www.reddit.com/r/PaseoAI/">
|
||||
<img src="https://img.shields.io/badge/Reddit-555?logo=reddit" alt="Reddit">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">One interface for all your Claude Code, Codex and OpenCode agents.</p>
|
||||
<p align="center">One interface for Claude Code, Codex, Copilot, OpenCode, and Pi agents.</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://paseo.sh/hero-mockup.png" alt="Paseo app screenshot" width="100%">
|
||||
@@ -34,7 +37,7 @@
|
||||
Run agents in parallel on your own machines. Ship from your phone or your desk.
|
||||
|
||||
- **Self-hosted:** Agents run on your machine with your full dev environment. Use your tools, your configs, and your skills.
|
||||
- **Multi-provider:** Claude Code, Codex, and OpenCode through the same interface. Pick the right model for each job.
|
||||
- **Multi-provider:** Claude Code, Codex, Copilot, OpenCode, and Pi through the same interface. Pick the right model for each job.
|
||||
- **Voice control:** Dictate tasks or talk through problems in voice mode. Hands-free when you need it.
|
||||
- **Cross-device:** iOS, Android, desktop, web, and CLI. Start work at your desk, check in from your phone, script it from the terminal.
|
||||
- **Privacy-first:** Paseo doesn't have any telemetry, tracking, or forced log-ins.
|
||||
@@ -49,7 +52,9 @@ You need at least one agent CLI installed and configured with your credentials:
|
||||
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
||||
- [Codex](https://github.com/openai/codex)
|
||||
- [GitHub Copilot](https://github.com/features/copilot/cli/)
|
||||
- [OpenCode](https://github.com/anomalyco/opencode)
|
||||
- [Pi](https://pi.dev)
|
||||
|
||||
### Desktop app (recommended)
|
||||
|
||||
@@ -91,9 +96,9 @@ paseo --host workstation.local:6767 run "run the full test suite"
|
||||
|
||||
See the [full CLI reference](https://paseo.sh/docs/cli) for more.
|
||||
|
||||
## Orchestration skills (Unstable)
|
||||
## Skills
|
||||
|
||||
Experimental skills that teach agents how to use the Paseo CLI to orchestrate other agents. I am updating these very frequently as I learn new things, expect changes without notice, might be coupled to my own setup, use at your own risk.
|
||||
Skills teach your agent to use Paseo to orchestrate other agents.
|
||||
|
||||
```bash
|
||||
npx skills add getpaseo/paseo
|
||||
@@ -101,18 +106,10 @@ npx skills add getpaseo/paseo
|
||||
|
||||
Then use them in any agent conversation:
|
||||
|
||||
```bash
|
||||
# Use handoff when you discuss something with an agent but want another one to implement.
|
||||
# I use this to plan with Claude and then handoff to Codex to implement.
|
||||
/paseo-handoff hand off the authentication fix to codex 5.4 in a worktree
|
||||
|
||||
# Use loops when you have clear acceptance criteria (aka Ralph loops).
|
||||
/paseo-loop loop a codex agent to fix the backend tests, use sonnet to verify, max 10 iterations
|
||||
|
||||
# Orchestrator teaches the agent how to create teams and manage them via a chat room.
|
||||
# Very opinionated and expects both Codex and Claude to work.
|
||||
/paseo-orchestrator spin up a team to implement the database refactor, use chat to coordinate. use claude to plan and codex to implement and review
|
||||
```
|
||||
- `/paseo-handoff` — hand off work between agents. I use this to plan with Claude and then handoff to Codex to implement.
|
||||
- `/paseo-loop` — loop an agent against clear acceptance criteria (aka Ralph loops), optionally with a verifier.
|
||||
- `/paseo-advisor` — spin up a single agent as an advisor for a second opinion, without delegating the work itself.
|
||||
- `/paseo-committee` — form a committee of two contrasting agents to step back, do root cause analysis, and produce a plan.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -137,13 +134,65 @@ npm run dev:app
|
||||
npm run dev:desktop
|
||||
npm run dev:website
|
||||
|
||||
# build the daemon
|
||||
npm run build:daemon
|
||||
# build the server stack
|
||||
npm run build:server
|
||||
|
||||
# repo-wide checks
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## Community
|
||||
|
||||
- [paseo-relay](https://github.com/zenghongtu/paseo-relay) — self-hosted relay in Go
|
||||
|
||||
### Self-hosted relay TLS
|
||||
|
||||
Self-hosted relays use `ws://` unless TLS is opted in. For a relay behind nginx on 443, start the daemon with:
|
||||
|
||||
```bash
|
||||
PASEO_RELAY_ENDPOINT=127.0.0.1:8080 \
|
||||
PASEO_RELAY_PUBLIC_ENDPOINT=relay.example.com:443 \
|
||||
PASEO_RELAY_USE_TLS=true \
|
||||
paseo daemon start
|
||||
```
|
||||
|
||||
Equivalent config:
|
||||
|
||||
```json
|
||||
{
|
||||
"daemon": {
|
||||
"relay": {
|
||||
"enabled": true,
|
||||
"endpoint": "127.0.0.1:8080",
|
||||
"publicEndpoint": "relay.example.com:443",
|
||||
"useTls": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Minimal nginx WebSocket proxy:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name relay.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/relay.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/relay.example.com/privkey.pem;
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://star-history.com/#getpaseo/paseo&Date">
|
||||
<picture>
|
||||
|
||||
23
SECURITY.md
23
SECURITY.md
@@ -19,18 +19,19 @@ The relay is designed to be untrusted. All traffic between your phone and daemon
|
||||
|
||||
### How it works
|
||||
|
||||
1. The daemon generates a persistent ECDH keypair and stores it locally
|
||||
2. When you scan the QR code or click the pairing link, your phone receives the daemon's public key
|
||||
3. Your phone sends a handshake message with its own public key. The daemon will not accept any commands until this handshake completes.
|
||||
4. Both sides perform an ECDH key exchange to derive a shared secret. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl box).
|
||||
1. The daemon generates a persistent Curve25519 keypair on first run and stores it at `$PASEO_HOME/daemon-keypair.json` with mode `0600`
|
||||
2. The pairing URL (rendered as a QR code or opened directly) carries the daemon's public key in its URL fragment (`https://app.paseo.sh/#offer=...`). Fragments are not sent to the web server, so `app.paseo.sh` never sees the key.
|
||||
3. When the phone connects via the relay, it generates a fresh ephemeral Curve25519 keypair and sends an `e2ee_hello` message containing its public key. The daemon will not process any application messages until this handshake completes.
|
||||
4. Both sides perform a Curve25519 ECDH key exchange to derive a shared key. All subsequent messages are encrypted with XSalsa20-Poly1305 (NaCl `box`). The wire format is `[24-byte nonce][ciphertext]`, base64-encoded as a WebSocket text frame.
|
||||
|
||||
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
|
||||
The relay sees only: IP addresses, timing, message sizes, session IDs, and the plaintext `e2ee_hello` / `e2ee_ready` handshake frames (which contain only public keys). It cannot read message contents, forge messages, or derive encryption keys from observing the handshake.
|
||||
|
||||
### Why the relay can't attack you
|
||||
|
||||
The daemon requires a valid cryptographic handshake before processing any commands. A compromised relay cannot:
|
||||
|
||||
- **Send commands** — Without your phone's private key, it cannot complete the handshake
|
||||
- **Impersonate the daemon to your phone** — Without the daemon's secret key, it cannot derive the shared key, so any traffic it injects fails authenticated decryption on the phone
|
||||
- **Send commands as you** — The daemon only accepts traffic that decrypts and authenticates under a shared key derived with its own secret key. The phone's keypair is ephemeral per connection, so there is no persistent phone-side secret to steal; protection comes from the daemon's secret key never leaving the daemon.
|
||||
- **Read your traffic** — All messages are encrypted with XSalsa20-Poly1305 (NaCl box) after the handshake
|
||||
- **Forge messages** — NaCl box provides authenticated encryption; tampered messages are rejected
|
||||
- **Replay old messages across sessions** — Each session derives fresh encryption keys, so ciphertext from one session cannot be replayed into another session. Within a live session, replay protection is not yet implemented; the protocol uses random nonces and does not track nonce reuse or message counters.
|
||||
@@ -41,11 +42,13 @@ The QR code or pairing link is the trust anchor. It contains the daemon's public
|
||||
|
||||
## Local daemon trust boundary
|
||||
|
||||
By default, the daemon binds to `127.0.0.1`. The local control plane is trusted by network reachability, not by an additional authentication token.
|
||||
By default, the daemon binds to `127.0.0.1`. With no password configured, the local control plane is trusted by network reachability — anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
|
||||
|
||||
Anything that can reach the daemon socket can control the daemon. This is the same security model Docker documents for its daemon: the security boundary is access to the socket or listening address.
|
||||
The daemon also supports an optional shared-secret password (set via `auth.password` in `config.json` or the `PASEO_PASSWORD` env var; stored bcrypt-hashed). When configured, every HTTP request must carry `Authorization: Bearer <password>` and every WebSocket upgrade must include a `Sec-WebSocket-Protocol: paseo.bearer.<password>` subprotocol. Browser WebSocket cannot set custom headers, which is why the token rides in the subprotocol. Health (`GET /api/health`) and CORS preflight (`OPTIONS`) are exempt. The password is intended for direct-TCP exposure (e.g. `tcp://host:port?ssl=true&password=...`); it is **not** a substitute for the relay's E2E encryption when traversing untrusted networks.
|
||||
|
||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access.
|
||||
Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary.
|
||||
|
||||
If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case.
|
||||
|
||||
For remote access, use the relay connection. It is the supported path for reaching the daemon off-machine, and it adds end-to-end encryption plus a pairing handshake before commands are accepted.
|
||||
|
||||
@@ -55,7 +58,7 @@ Host header validation and CORS origin checks are defense-in-depth controls for
|
||||
|
||||
CORS is not a complete security boundary. It controls which browser origins can make requests, but does not prevent a malicious website from resolving its domain to your local machine (DNS rebinding).
|
||||
|
||||
Paseo validates the `Host` header on incoming requests against configured hostnames. Requests with unrecognized hosts are rejected.
|
||||
Paseo validates the `Host` header on every HTTP request and every WebSocket upgrade against an allowlist (Vite-style semantics). By default, only `localhost`, `*.localhost`, and any literal IP address (IPv4 or IPv6) are accepted. Additional hostnames can be configured via `hostnames` in `config.json` or the `PASEO_HOSTNAMES` env var (comma-separated; entries beginning with `.` match a domain and its subdomains; the value `true` disables the allowlist entirely). Requests with unrecognized hosts are rejected with `403 Host not allowed`.
|
||||
|
||||
## Agent authentication
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# Ad-hoc daemon testing
|
||||
|
||||
Spin up an isolated daemon programmatically without touching the main daemon on port 6767.
|
||||
Spin up an isolated in-process daemon test harness without touching the main daemon on port 6767.
|
||||
|
||||
This is for test code only. Executable daemon processes must start through
|
||||
`scripts/supervisor-entrypoint.ts` or `dist/scripts/supervisor-entrypoint.js`;
|
||||
do not use `createPaseoDaemon` as a product launch path.
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -44,7 +48,7 @@ const port = target!.type === "tcp" ? target!.port : null;
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${port}/ws`,
|
||||
appVersion: "0.1.54", // see gotcha #1
|
||||
appVersion: "0.1.70", // see gotcha #1
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||
@@ -74,7 +78,7 @@ import { DaemonClient } from "./test-utils/daemon-client.js";
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.54",
|
||||
appVersion: "0.1.70",
|
||||
});
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "test" } });
|
||||
@@ -85,7 +89,7 @@ await client.close();
|
||||
await daemon.close(); // stops daemon + cleans up temp dirs
|
||||
```
|
||||
|
||||
The test helper does **not** expose `providerOverrides`. Use `createPaseoDaemon` directly when you need it (see quick start above).
|
||||
The test helper does **not** expose `providerOverrides`. In test harnesses, use `createPaseoDaemon` directly when you need it (see quick start above).
|
||||
|
||||
## Common client methods
|
||||
|
||||
@@ -112,7 +116,7 @@ Always pass `appVersion`:
|
||||
```typescript
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${port}/ws`,
|
||||
appVersion: "0.1.54",
|
||||
appVersion: "0.1.70",
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
109
docs/agent-lifecycle.md
Normal file
109
docs/agent-lifecycle.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# Agent lifecycle
|
||||
|
||||
How an agent is created, runs, becomes a subagent, gets archived, and disappears from the UI. The model spans the daemon (lifecycle, archive) and the client (tabs, the subagents track).
|
||||
|
||||
## States
|
||||
|
||||
```
|
||||
initializing → idle → running → idle (or error → closed)
|
||||
↑ │
|
||||
└────────┘ (agent completes a turn, awaits next prompt)
|
||||
```
|
||||
|
||||
Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, `error`, or `closed`. State transitions persist to disk and stream to subscribed clients via WebSocket.
|
||||
|
||||
## Relationships
|
||||
|
||||
Agents can launch other agents via the `create_agent` MCP tool. When they do, the daemon stamps the new agent with a label `paseo.parent-agent-id` pointing back at the caller (`packages/server/src/server/agent/mcp-server.ts:804`). The client surfaces that as `agent.parentAgentId`.
|
||||
|
||||
There is exactly one relationship type today: `parentAgentId`. The daemon does not distinguish between:
|
||||
|
||||
- **Subagents** — children that exist as part of the parent's work (e.g. orchestration tasks the parent delegates and waits on)
|
||||
- **Detached agents** — children launched to take over from the parent (e.g. handoffs, fire-and-forget delegations)
|
||||
|
||||
Both look the same in storage. This is an accepted limitation — see [Limitations](#limitations).
|
||||
|
||||
## Archive
|
||||
|
||||
Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client.
|
||||
|
||||
`create_agent_request` can opt an agent into `autoArchive`. In that mode the daemon archives the agent after the first terminal turn event (`turn_completed`, `turn_failed`, or `turn_canceled`). If the same request created a Paseo worktree through its `worktree` field, auto-archive archives that worktree too, which removes the agent records inside the worktree.
|
||||
|
||||
Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`):
|
||||
|
||||
1. Snapshot the current session into the registry
|
||||
2. Set `archivedAt` and normalize `lastStatus` away from `running`/`initializing`
|
||||
3. Notify subscribers
|
||||
4. Close the runtime (kills the process if still running)
|
||||
5. **Cascade-archive children** — any agent whose `paseo.parent-agent-id` label matches the archived agent gets archived too, recursively
|
||||
|
||||
Cascade is what keeps subagent fleets from outliving their orchestrator.
|
||||
|
||||
## Tabs vs archive
|
||||
|
||||
These are two distinct concepts that used to be conflated:
|
||||
|
||||
| Concept | Scope | Triggers |
|
||||
| -------------------------- | ---------- | -------------------------- |
|
||||
| **Tab** (workspace layout) | Per-client | User opens/closes a view |
|
||||
| **Archive** (lifecycle) | Global | Explicit lifecycle gesture |
|
||||
|
||||
Closing a tab on a **root agent** still archives — the tab is the agent's home, so closing it means "I'm done with this agent." A confirm dialog protects against archiving a running agent by accident.
|
||||
|
||||
Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The agent stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`).
|
||||
|
||||
The asymmetry is intentional: a subagent's home is the parent's track, not the tab. Tabs are ephemeral viewing slots; the track is the persistent record of the parent's children.
|
||||
|
||||
## The subagents track
|
||||
|
||||
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`). Membership rule (`packages/app/src/subagents/select.ts`):
|
||||
|
||||
```
|
||||
parentAgentId === thisAgent.id AND !archivedAt
|
||||
```
|
||||
|
||||
Archived subagents disappear from the track, by design. To remove a subagent from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. That same archive shows the subagent leave the track on every connected client.
|
||||
|
||||
## Why this shape
|
||||
|
||||
The decision was to **decouple "close tab" from "archive" only for subagents**, rather than universally:
|
||||
|
||||
- **Closing a tab on a root agent still archives** — preserves the existing UX users are trained on
|
||||
- **Closing a tab on a subagent is layout-only** — fixes the lossy "click to read, close to dismiss view, lose the row" flow
|
||||
- **Archive button on track rows** — gives subagents an explicit lifecycle gesture in their home surface
|
||||
- **Cascade archive on parent** — keeps subagents from leaking when the parent is archived
|
||||
|
||||
We considered universal decoupling (no tab close ever archives, archive is always explicit) but rejected it: it changes a behavior root-agent users rely on.
|
||||
|
||||
## Limitations
|
||||
|
||||
### Detached agents are cascade-archived
|
||||
|
||||
The daemon can't tell a "subagent" apart from a "detached agent" — both carry `paseo.parent-agent-id`. So when you archive an agent that previously launched a detached child (e.g. via `/paseo-handoff`), cascade will archive the detached child too, even though semantically it should outlive the originator.
|
||||
|
||||
Until a richer relation model lands (e.g. a `relation: "subagent" | "detached"` field on creation, or a separate channel for handoff launches), this trade-off stands. Workaround: don't archive an agent whose work was handed off, or unarchive the detached child afterward.
|
||||
|
||||
### Subagent accumulation under long-lived parents
|
||||
|
||||
A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem.
|
||||
|
||||
### Cross-client tab dismissal
|
||||
|
||||
Closing a subagent's tab on one client doesn't affect other clients' layouts. This is the expected behavior of decoupled tabs and is consistent with how layouts have always worked. Archive remains the global gesture for cross-client cleanup.
|
||||
|
||||
## Storage
|
||||
|
||||
```
|
||||
$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json
|
||||
```
|
||||
|
||||
Each agent is a single JSON file. Fields relevant to this doc:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --------------------------------- | ------------- | ------------------------------------------------------------- |
|
||||
| `id` | `string` | Stable identifier |
|
||||
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
|
||||
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` MCP tool |
|
||||
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
|
||||
|
||||
See [`docs/data-model.md`](./data-model.md) for the full agent record.
|
||||
@@ -27,17 +27,21 @@ Or from `packages/app`:
|
||||
|
||||
```bash
|
||||
# Debug
|
||||
APP_VARIANT=development npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=development npx expo run:android --variant=debug
|
||||
npx cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive
|
||||
npx cross-env APP_VARIANT=development expo run:android --variant=debug
|
||||
|
||||
# Release
|
||||
APP_VARIANT=production npx expo prebuild --platform android --non-interactive
|
||||
APP_VARIANT=production npx expo run:android --variant=release
|
||||
npx cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive
|
||||
npx cross-env APP_VARIANT=production expo run:android --variant=release
|
||||
|
||||
# Clear generated Android project
|
||||
rm -rf android
|
||||
```
|
||||
|
||||
### React version lockstep
|
||||
|
||||
Keep `react` and `react-dom` pinned to the React version embedded by the current `react-native` release. React Native `0.81.x` embeds `react-native-renderer` `19.1.0`, so `packages/app` must use React `19.1.0`. Bumping React to a newer patch can build successfully but crash at JS startup on Android with `Incompatible React versions`, leaving the app on the native splash screen.
|
||||
|
||||
## Screenshots
|
||||
|
||||
```bash
|
||||
@@ -48,22 +52,28 @@ adb exec-out screencap -p > screenshot.png
|
||||
|
||||
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)
|
||||
- The EAS GitHub app on Expo servers (iOS + Android production builds + store submit). There is no workflow file in this repo for it.
|
||||
- `.github/workflows/android-apk-release.yml` on GitHub Actions (APK asset on GitHub Release).
|
||||
|
||||
iOS auto-submits to App Store review via a Fastlane lane after EAS uploads to TestFlight. Android auto-submits to the Play Store via EAS-managed credentials.
|
||||
|
||||
Beta tags like `v0.1.1-beta.1` only trigger the GitHub APK workflow. They publish a GitHub prerelease APK for testing and do not submit to the stores.
|
||||
|
||||
`android-v*` tags also trigger only the GitHub APK workflow — useful when you want to ship an APK without going through stores. The GitHub APK workflow supports `workflow_dispatch` with an existing `tag` input so you can rebuild without cutting a new tag.
|
||||
|
||||
### Useful commands
|
||||
|
||||
```bash
|
||||
cd packages/app
|
||||
|
||||
# List recent workflow runs
|
||||
npx eas workflow:runs --workflow release-mobile.yml --limit 10
|
||||
# Recent builds
|
||||
npx eas build:list --limit 10 --non-interactive --json | jq '.[] | {platform, status, appVersion, gitCommitHash}'
|
||||
|
||||
# Inspect a run
|
||||
npx eas workflow:view <run-id>
|
||||
|
||||
# Stream logs for a failed job
|
||||
npx eas workflow:logs <job-id> --non-interactive --all-steps
|
||||
# Inspect a build (the printed `Logs` URL opens the build's Expo dashboard page,
|
||||
# which has a Submissions section showing the auto-submit to the Play Store).
|
||||
npx eas build:view <build-id>
|
||||
```
|
||||
|
||||
The Play Console (Internal testing → Production tracks) is the final confirmation that the binary reached the store.
|
||||
|
||||
See [docs/release.md](release.md) for the full mobile-build babysitting flow.
|
||||
|
||||
@@ -22,13 +22,13 @@ Your code never leaves your machine. Paseo is local-first.
|
||||
│ (Node.js) │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌────────────┼────────────┐
|
||||
│ │ │
|
||||
┌─────▼─────┐ ┌───▼────┐ ┌────▼─────┐
|
||||
│ Claude │ │ Codex │ │ OpenCode │
|
||||
│ Agent │ │ Agent │ │ Agent │
|
||||
│ SDK │ │ Server │ │ │
|
||||
└───────────┘ └────────┘ └──────────┘
|
||||
┌────────────┼────────────┬────────────┬────────────┐
|
||||
│ │ │ │ │
|
||||
┌─────▼─────┐ ┌───▼────┐ ┌──────▼─────┐ ┌────▼─────┐ ┌────▼────┐
|
||||
│ Claude │ │ Codex │ │ Copilot │ │ OpenCode │ │ Pi │
|
||||
│ Agent │ │ Agent │ │ Agent │ │ Agent │ │ Agent │
|
||||
│ SDK │ │ Server │ │ ACP │ │ │ │ │
|
||||
└───────────┘ └────────┘ └────────────┘ └──────────┘ └─────────┘
|
||||
```
|
||||
|
||||
## Components at a glance
|
||||
@@ -51,39 +51,64 @@ The heart of Paseo. A Node.js process that:
|
||||
- Exposes an MCP server for agent-to-agent control
|
||||
- Optionally connects outbound to a relay for remote access
|
||||
|
||||
All paths are under `packages/server/src/`.
|
||||
|
||||
**Key modules:**
|
||||
|
||||
| Module | Responsibility |
|
||||
| ------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `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 |
|
||||
| ------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
|
||||
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
|
||||
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
|
||||
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
|
||||
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
|
||||
| `server/agent/mcp-server.ts` | MCP server for sub-agent creation, permissions, timeouts |
|
||||
| `server/agent/providers/` | Provider adapters (see "Agent providers" below) |
|
||||
| `server/relay-transport.ts` | Outbound relay connection with E2E encryption |
|
||||
| `server/schedule/` | Cron-based scheduled agents |
|
||||
| `server/loop-service.ts` | Looping agent runs that retry until an exit condition |
|
||||
| `server/chat/` | Chat rooms for agent-to-agent and human-to-agent messaging |
|
||||
|
||||
### `packages/protocol` — Wire schemas and shared protocol types
|
||||
|
||||
The source of truth for WebSocket messages, binary frame codecs, endpoint parsing,
|
||||
agent timeline types, provider config schemas, and other values shared by daemon
|
||||
and clients. Server, app, CLI, and `@getpaseo/client` all depend on this package;
|
||||
it does not depend on the server.
|
||||
|
||||
### `packages/client` — Daemon client library and SDK facade
|
||||
|
||||
Owns the low-level daemon WebSocket driver plus the higher-level `PaseoClient`
|
||||
facade. App and CLI may import the low-level driver from
|
||||
`@getpaseo/client/internal/daemon-client` during migration, while new SDK-shaped
|
||||
code imports from `@getpaseo/client`.
|
||||
|
||||
### `packages/app` — Mobile + web client (Expo)
|
||||
|
||||
Cross-platform React Native app that connects to one or more daemons.
|
||||
|
||||
- Expo Router navigation (`/h/[serverId]/agents`, etc.)
|
||||
- `DaemonRegistryContext` manages saved daemon connections
|
||||
- Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.)
|
||||
- `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
|
||||
- `SessionContext` wraps the daemon client for the active session
|
||||
- `Stream` model handles timeline with compaction, gap detection, sequence-based deduplication
|
||||
- Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/`
|
||||
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
|
||||
- Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete.
|
||||
- Voice features: dictation (STT) and voice agent (realtime)
|
||||
|
||||
### `packages/cli` — Command-line client
|
||||
|
||||
Commander.js CLI with Docker-style commands:
|
||||
Commander.js CLI with Docker-style commands. Common agent operations are also exposed at the top level (e.g. `paseo ls`, `paseo run`).
|
||||
|
||||
- `paseo agent ls/run/stop/logs/inspect/wait/send/attach`
|
||||
- `paseo daemon start/stop/restart/status/pair`
|
||||
- `paseo agent ls/run/import/attach/logs/stop/delete/send/inspect/wait/archive/reload/update/mode`
|
||||
- `paseo daemon start/stop/restart/status/pair/set-password`
|
||||
- `paseo chat ls/create/inspect/post/read/wait/delete`
|
||||
- `paseo terminal ls/create/capture/send-keys/kill`
|
||||
- `paseo loop run/ls/inspect/logs/stop`
|
||||
- `paseo schedule create/ls/inspect/update/pause/resume/run-once/logs/delete`
|
||||
- `paseo permit allow/deny/ls`
|
||||
- `paseo provider ls/models`
|
||||
- `paseo worktree ls/archive`
|
||||
- `paseo worktree create/ls/archive`
|
||||
- `paseo speech …`
|
||||
|
||||
Communicates with the daemon via the same WebSocket protocol as the app.
|
||||
|
||||
@@ -91,10 +116,11 @@ Communicates with the daemon via the same WebSocket protocol as the app.
|
||||
|
||||
Enables remote access when the daemon is behind a firewall.
|
||||
|
||||
- ECDH key exchange + AES-256-GCM encryption
|
||||
- Curve25519 ECDH key exchange + XSalsa20-Poly1305 (NaCl `box`) encryption
|
||||
- Relay server is zero-knowledge — it routes encrypted bytes, cannot read content
|
||||
- Client and daemon channels with identical API (`createClientChannel`, `createDaemonChannel`)
|
||||
- Pairing via QR code transfers the daemon's public key to the client
|
||||
- Self-hosted relays opt into TLS with `daemon.relay.useTls` or `PASEO_RELAY_USE_TLS=true`; the public (client-facing) TLS setting can be overridden independently via `daemon.relay.publicUseTls` or `PASEO_RELAY_PUBLIC_USE_TLS`
|
||||
|
||||
See [SECURITY.md](../SECURITY.md) for the full threat model.
|
||||
|
||||
@@ -112,30 +138,52 @@ TanStack Router + Cloudflare Workers. Serves paseo.sh.
|
||||
|
||||
## WebSocket protocol
|
||||
|
||||
All clients speak the same binary-multiplexed WebSocket protocol.
|
||||
All clients speak the same WebSocket protocol over a single connection that mixes JSON text frames and a small binary framing for terminal streams. Schemas live in `packages/protocol/src/messages.ts`.
|
||||
|
||||
**Handshake:**
|
||||
|
||||
```
|
||||
Client → Server: WSHelloMessage { id, clientId, version, timestamp }
|
||||
Server → Client: WSWelcomeMessage { clientId, daemonVersion, sessionId, capabilities }
|
||||
Client → Server: WSHelloMessage {
|
||||
type: "hello",
|
||||
clientId,
|
||||
clientType: "mobile" | "browser" | "cli" | "mcp",
|
||||
protocolVersion,
|
||||
appVersion?,
|
||||
capabilities?: { voice?, pushNotifications?, ... },
|
||||
}
|
||||
Server → Client: status message with payload { status: "server_info",
|
||||
serverId, hostname, version, capabilities?, features }
|
||||
```
|
||||
|
||||
**Message types:**
|
||||
There is no dedicated welcome message; the server emits a `status` session message after accepting the hello, then begins streaming. The session stores client capabilities from the hello and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
|
||||
|
||||
**Top-level WS envelopes** are `hello`, `recording_state`, `ping`/`pong`, and `session` (which wraps the rich union of session messages).
|
||||
|
||||
Client liveness checks use the top-level JSON `ping`/`pong` envelope, not a session RPC and not RFC6455 protocol ping. The app runs through browser and React Native WebSocket APIs, which do not expose protocol ping, so this envelope is the portable way to test the direct or relay data path. Session RPC timeouts are operation failures and must not be treated as proof that the socket is dead.
|
||||
|
||||
New session RPCs use dotted names with `.request` and `.response` suffixes, such as `checkout.github.set_auto_merge.request` and `checkout.github.set_auto_merge.response`. See [rpc-namespacing.md](rpc-namespacing.md) for the convention and migration rules for older flat RPC names.
|
||||
|
||||
**Notable session message types:**
|
||||
|
||||
- `agent_update` — Agent state changed (status, title, labels)
|
||||
- `agent_stream` — New timeline event from a running agent
|
||||
- `workspace_update` — Workspace state changed
|
||||
- `agent_permission_request` — Agent needs user approval for a tool call
|
||||
- Command-response pairs for fetch, list, create, etc.
|
||||
- `workspace_update`, `script_status_update`, `workspace_setup_progress` — Workspace state
|
||||
- `agent_permission_request` / `agent_permission_resolved` — Tool-call permission flow
|
||||
- `agent_deleted`, `agent_archived`, `agent_status`, `agent_list`
|
||||
- `checkout_status_update`, `checkout_diff_update`, and the full `checkout_*` request/response set for git operations
|
||||
- Terminal subscribe/input/capture commands
|
||||
- Voice/dictation streaming events (`dictation_stream_*`, `assistant_chunk`, `audio_output`, `transcription_result`)
|
||||
- Request/response pairs for fetch, list, create, etc., correlated by `requestId`; failures use `rpc_error`
|
||||
|
||||
**Binary multiplexing:**
|
||||
**Binary frames (terminal stream protocol):**
|
||||
|
||||
Terminal I/O and agent streaming share the same connection via `BinaryMuxFrame`:
|
||||
Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStreamFrame` in `shared/binary-frames/terminal.ts`. The layout is:
|
||||
|
||||
- Channel 0: control messages
|
||||
- Channel 1: terminal data
|
||||
- 1-byte channel ID + 1-byte flags + variable payload
|
||||
- 1-byte opcode: `Output (0x01)`, `Input (0x02)`, `Resize (0x03)`, `Snapshot (0x04)`
|
||||
- 1-byte slot: terminal slot id
|
||||
- variable payload: bytes for output/input, JSON-encoded `{ rows, cols }` for resize, terminal snapshot for snapshot
|
||||
|
||||
There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams.
|
||||
|
||||
### Compatibility rules
|
||||
|
||||
@@ -154,26 +202,46 @@ Example: adding a new enum value
|
||||
|
||||
## Agent lifecycle
|
||||
|
||||
The lifecycle states are defined in `shared/agent-lifecycle.ts`:
|
||||
|
||||
```
|
||||
initializing → idle → running → idle (or error → closed)
|
||||
↑ │
|
||||
└────────┘ (agent completes a turn, awaits next prompt)
|
||||
initializing → idle ⇄ running
|
||||
↓ ↓ ↓
|
||||
error
|
||||
↓
|
||||
closed
|
||||
```
|
||||
|
||||
- **AgentManager** tracks up to 200 timeline items per agent
|
||||
- Timeline is append-only with epochs (each run starts a new epoch)
|
||||
- Events stream to all subscribed clients in real time
|
||||
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json`
|
||||
- `initializing` — provider session is being created
|
||||
- `idle` — has a live session, awaiting the next prompt
|
||||
- `running` — provider is currently producing a turn
|
||||
- `error` — last attempt failed; session is still attached
|
||||
- `closed` — terminal state, no live session
|
||||
|
||||
`ManagedAgent` is a discriminated union over those lifecycle tags. Notes:
|
||||
|
||||
- **AgentManager** is the source of truth for agent state and broadcasts updates to all subscribers
|
||||
- Timeline is append-only with epochs (each run starts a new epoch). Storage uses sequence numbers for client-side dedup; the default fetch page is 200 items
|
||||
- Timeline row `timestamp` values are canonical daemon-owned timestamps. Providers may supply original replay timestamps, but clients must not guess timestamp trust or hide time UI based on local clock heuristics.
|
||||
- Events stream to connected clients in real time; correctness is backed by authoritative timeline fetches and paged-to-completion catch-up.
|
||||
- Agent state persists to `$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json` (timeline rows live alongside the record)
|
||||
|
||||
## Agent providers
|
||||
|
||||
Each provider implements a common `AgentClient` interface:
|
||||
Each provider implements the `AgentClient` interface in `agent/agent-sdk-types.ts`. Provider implementations live in `agent/providers/`.
|
||||
|
||||
| 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 |
|
||||
The built-in, user-facing providers are Claude Code, Codex, Copilot, OpenCode, and Pi. Additional adapters exist in the same directory for ACP-compatible agents and internal use:
|
||||
|
||||
| Provider | Wraps | Session format |
|
||||
| ------------------ | ------------------------------------ | -------------------------------------------------- |
|
||||
| Claude (`claude/`) | Anthropic Agent SDK | `~/.claude/projects/{cwd}/{session-id}.jsonl` |
|
||||
| Codex | Codex AppServer (`codex-app-server`) | `~/.codex/sessions/{date}/rollout-{ts}-{id}.jsonl` |
|
||||
| Copilot | GitHub Copilot via ACP | Provider-managed |
|
||||
| OpenCode | OpenCode server / CLI | Provider-managed |
|
||||
| Cursor | ACP wrapper (`acp-agent`) | Provider-managed |
|
||||
| Generic ACP | ACP wrapper | Provider-managed |
|
||||
| Pi | Local Pi RPC process | Provider-managed |
|
||||
| Mock load test | In-process fake | In-memory |
|
||||
|
||||
All providers:
|
||||
|
||||
@@ -194,12 +262,21 @@ All providers:
|
||||
|
||||
## Storage
|
||||
|
||||
`$PASEO_HOME` defaults to `~/.paseo`. The most important files:
|
||||
|
||||
```
|
||||
$PASEO_HOME/
|
||||
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent state + config
|
||||
├── agents/{cwd-with-dashes}/{agent-id}.json # Agent record + persisted timeline rows
|
||||
├── projects/projects.json # Project registry
|
||||
├── projects/workspaces.json # Workspace registry
|
||||
└── daemon.log # Daemon trace logs
|
||||
├── chat/ # Chat rooms
|
||||
├── schedules/ # Scheduled-agent definitions and runs
|
||||
├── loops/ # Loop runs and logs
|
||||
├── config.json # Daemon config (mutable)
|
||||
├── daemon-keypair.json # Daemon identity for relay/E2EE
|
||||
├── push-tokens.json # Mobile push tokens
|
||||
├── paseo.sock / paseo.pid # Local IPC socket and pidfile
|
||||
└── daemon.log # Daemon trace logs (rotated)
|
||||
```
|
||||
|
||||
## Deployment models
|
||||
|
||||
@@ -1,188 +1,98 @@
|
||||
# Coding Standards
|
||||
|
||||
These standards apply to all code changes: features, bug fixes, refactors, and performance work.
|
||||
The core instinct: AI-generated code hedges — it covers every case, layers over instead of cutting in, scatters uncertainty everywhere, wraps in case. A senior engineer commits — to a shape, a boundary, a name, a happy path, a type — and lets everything else fall into place. Every rule below catches a different form of indecision.
|
||||
|
||||
For testing rules, see [testing.md](testing.md).
|
||||
|
||||
## Core principles
|
||||
|
||||
- **Zero complexity budget** — justify every abstraction with specific benefits
|
||||
- **Fully typed TypeScript** — no `any`, no untyped boundaries
|
||||
- **YAGNI** — build features and abstractions only when needed
|
||||
- **Functional and declarative** over object-oriented
|
||||
- **`interface`** over `type` when possible
|
||||
- **`function` declarations** over arrow function assignments
|
||||
- **Single-purpose functions** — one function, one job
|
||||
- **Design for edge cases through types** rather than explicit handling
|
||||
- **Don't catch errors** unless there's a strong reason to
|
||||
- **No index.ts barrel files** that only re-export — they create unnecessary indirection
|
||||
- **No "while I'm at it" improvements** — stay focused on the task
|
||||
- **Zero complexity budget** — every abstraction must justify itself with a specific, current benefit.
|
||||
- **YAGNI** — build features and abstractions only when needed. A function called once is indirection, not abstraction.
|
||||
- **No "while I'm at it" cleanups** — make the change you came for. Drive-by edits hide in the diff.
|
||||
- **Functional and declarative** over object-oriented.
|
||||
- **`function` declarations** over arrow function assignments.
|
||||
- **`interface`** over `type` when both work.
|
||||
- **No `index.ts` barrel files** that only re-export — they create indirection and circular-dep risk. Import from the source.
|
||||
|
||||
## Type hygiene
|
||||
## Comments and noise
|
||||
|
||||
### Infer from schemas
|
||||
- Delete any comment where removing it loses zero information. Comments explain _why_, not _what_.
|
||||
- No tutorial comments explaining language features (`// Use destructuring to...`).
|
||||
- No decorative section dividers (`// ===== Helpers =====`). Use files and modules to organize, not ASCII art.
|
||||
- No hedging comments (`// might need to revisit`, `// should work for most cases`). If you're unsure, investigate.
|
||||
- No commented-out code. Git remembers.
|
||||
- No `console.log` / `debugger` left behind. No `TODO: implement` stubs — if it needs to exist, write it.
|
||||
|
||||
Never hand-write a TypeScript type that can be inferred from a Zod schema.
|
||||
## Confidence: commit to a shape
|
||||
|
||||
```typescript
|
||||
// Bad: duplicate type that can drift
|
||||
const schema = z.object({ procedure: z.string(), args: z.record(z.unknown()) });
|
||||
type RPCArgs = { procedure: string; args: Record<string, unknown> };
|
||||
- Validate at boundaries (network, IPC, user input, file I/O), trust types internally. After the parse, the value is what its type says.
|
||||
- Every `?.` and `??` past the validation boundary is unconfident code — either the boundary should resolve it, or the type should reflect reality.
|
||||
- No defensive checks for conditions the type system already rules out (`if (!agent) return` on a non-nullable parameter).
|
||||
- No `try/catch` "just in case." If you can't say what you're catching and why, don't catch.
|
||||
- Optionality is a design decision, not a migration shortcut. Distinct valid states → discriminated union. Intentionally empty → explicit `null`. Keep optionality at real boundaries.
|
||||
|
||||
// Good: infer from schema
|
||||
type RPCArgs = z.infer<typeof schema>;
|
||||
```
|
||||
## Types
|
||||
|
||||
### Named types over inline
|
||||
- No `any`. No `as` casts to bypass errors. No `@ts-ignore` / `@ts-expect-error`. Narrow with `if` / schema validation; let the compiler check harder, not less.
|
||||
- If a Zod schema exists, the TypeScript type is `z.infer<typeof schema>`. Never hand-write a parallel type.
|
||||
- One canonical type per concept. Layer-specific views are `Pick` / `Omit`, not duplicated fields.
|
||||
- Name multi-property object shapes — no inline `Array<{ ... }>` or `Promise<{ ... }>` in signatures, returns, or generic args.
|
||||
- Use string literal unions, not raw `string`, when the value is one of a known set. Catches typos at compile time.
|
||||
- Object parameters past the obvious-name threshold: 3+ args, any boolean arg, any optional arg → object. `(thing, true, false, true)` is unreadable at the call site.
|
||||
- Make impossible states impossible — discriminated unions over `{ isLoading; error?; data? }` bags.
|
||||
|
||||
No complex inline types in public function signatures.
|
||||
## Errors
|
||||
|
||||
```typescript
|
||||
// Bad
|
||||
function enqueueJob(input: { userId: string; priority: "low" | "normal" | "high" }) {}
|
||||
- Throw typed error classes that carry the fields a caller would want to read. Plain `Error("Provider X not found")` collapses structured info into a string.
|
||||
- Catch blocks branch on `instanceof` for what they can handle; rethrow the rest. No `catch (e) { return null }`.
|
||||
- Separate user-facing copy from log/debug strings — don't make one string serve telemetry, logs, and the UI.
|
||||
- Fail explicitly. If the caller asked for X and X isn't available, throw — don't silently substitute Y.
|
||||
|
||||
// Good
|
||||
interface EnqueueJobInput {
|
||||
userId: string;
|
||||
priority: "low" | "normal" | "high";
|
||||
}
|
||||
function enqueueJob(input: EnqueueJobInput) {}
|
||||
```
|
||||
## Density
|
||||
|
||||
### Object parameters
|
||||
- Nested ternaries are forbidden. A single ternary is fine only when both branches are a single identifier or trivial access (`x ? a : b`).
|
||||
- Boolean expressions with 2+ clauses or mixed concerns → name the conditions.
|
||||
- Object literals assemble pre-computed values; don't pack branching and lookups into property positions.
|
||||
- Operations wrapping operations (`Object.fromEntries(arr.filter(...).map(...))`, `Math.max(...xs.map(...))`) → break into named intermediates.
|
||||
- Max 3 levels of nesting (callbacks, JSX, control flow). Above that, extract.
|
||||
|
||||
If a function needs more than one argument, use a single object parameter.
|
||||
## Structure and modules
|
||||
|
||||
```typescript
|
||||
// Bad: positional args
|
||||
function createToolCall(provider: string, toolName: string, payload: unknown) {}
|
||||
- A directory is a module, not a namespace. One intentional public surface; internal files stay internal.
|
||||
- Path is part of the name — prefer `provider/registry.ts` over `provider/provider-registry.ts`. If the filename has to do double duty, deepen the path.
|
||||
- Filenames ending in `-utils`, `-helpers`, `-manager`, `-handler`, `-controller`, `-formatter`, `-builder` are a smell — the path didn't carry enough domain.
|
||||
- Boundary returns answer the caller's question (`getActiveAgents()`), not "here's my storage" (`getAgents().filter(...)` repeated everywhere).
|
||||
- One adapter means a hypothetical seam; two adapters means a real one. Don't define a port until something actually varies across it.
|
||||
- Pass-through modules fail the deletion test — if removing the module makes callers go straight to what they wanted, delete it.
|
||||
- Centralize policy. The same discriminator (`plan`, `provider`, `kind`, `status`) branched in 3+ files → policy table, not another `else if` per case.
|
||||
- New features get a home before implementation. A feature smeared across 5 shared files is the same slop as a flat-peer namespace.
|
||||
- Don't drop new files at the nearest root just because placement is unclear — say so and ask.
|
||||
|
||||
// Good: object param
|
||||
interface CreateToolCallInput {
|
||||
provider: string;
|
||||
toolName: string;
|
||||
payload: unknown;
|
||||
}
|
||||
function createToolCall(input: CreateToolCallInput) {}
|
||||
```
|
||||
## Refactoring is a bolt-on test
|
||||
|
||||
### One canonical type per concept
|
||||
- A change should look like a thoughtful edit to existing code, not a new layer next to it. New coordinator wrapping a coordinator, new flag bypassing the normal path, new helper duplicating an existing selector — stop and reshape instead.
|
||||
- Refactors preserve behavior by default. No removing features to simplify code without explicit approval.
|
||||
- Have a verification plan _before_ refactoring — name the invariants, confirm a test holds them, write one if not. See [testing.md](testing.md).
|
||||
- Migrate all callers and remove old paths in the same refactor. No fallback behavior unless explicitly designed.
|
||||
|
||||
Don't redefine the same concept in different layer-specific shapes (`RpcX`, `DbX`, `UiX`). Keep one canonical type and add explicit layer wrappers that reference it.
|
||||
## React
|
||||
|
||||
```typescript
|
||||
// Bad: duplicated fields across layers
|
||||
type RpcToolCall = { toolName: string; args: Record<string, unknown>; requestId: string };
|
||||
type DbToolCall = { toolName: string; args: Record<string, unknown>; id: string; createdAt: Date };
|
||||
- `useEffect` is for synchronizing with external systems (DOM, network, timers, subscriptions). Not for transforming React state. Derived state → compute in render or `useMemo`.
|
||||
- No effect cascades — chains of effects setting state that triggers more effects almost always want React Query or a reducer.
|
||||
- `useRef` is for DOM refs and non-rendering identities (timer IDs, AbortController, latest-callback caches). If the value affects what renders next, it's state — model it explicitly with `useReducer` and a discriminated union.
|
||||
- Server state goes through React Query. Manual `useState` + `useEffect` + `isLoading` + `error` for fetched data is always worse.
|
||||
- Components render and dispatch — they don't compute transitions. Two-plus interacting `useState`s → extract a reducer.
|
||||
- Never define components inside other components. Module-scope only.
|
||||
- Subscribe narrowly: select primitives from stores, pass `status` not `agent`, use `useShallow` / deep-equal when returning derived arrays/objects.
|
||||
- Stable references for props that cross `memo` boundaries or feed dependency arrays. Static literals at module scope `as const`; derived with `useMemo`; handlers with `useCallback` only when there's a memoized beneficiary.
|
||||
- Use stable ids for `key`, never array index for reorderable/filterable lists.
|
||||
- Context for stable values (theme, auth). Store with selectors for state that changes.
|
||||
|
||||
// Good: canonical type + wrappers
|
||||
type ToolCall = { toolName: string; args: Record<string, unknown> };
|
||||
type ToolCallRequest = { requestId: string; toolCall: ToolCall };
|
||||
type ToolCallRecord = { id: string; createdAt: Date; toolCall: ToolCall };
|
||||
```
|
||||
## Naming
|
||||
|
||||
## Make impossible states impossible
|
||||
|
||||
Use discriminated unions instead of bags of booleans and optionals.
|
||||
|
||||
```typescript
|
||||
// Bad
|
||||
interface FetchState {
|
||||
isLoading: boolean;
|
||||
error?: Error;
|
||||
data?: Data;
|
||||
}
|
||||
|
||||
// Good
|
||||
type FetchState =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: Error }
|
||||
| { status: "success"; data: Data };
|
||||
```
|
||||
|
||||
## Optionality is a design decision
|
||||
|
||||
Don't mark fields optional to avoid migrations. Decide deliberately:
|
||||
|
||||
1. Is optionality actually needed?
|
||||
2. If there are distinct valid states → discriminated union
|
||||
3. If value can be intentionally empty → explicit `null`
|
||||
4. Keep optionality at real boundaries (external input), then resolve it
|
||||
|
||||
## Validate at boundaries, trust internally
|
||||
|
||||
Parse external data once at the boundary with schema validation. Then use typed values everywhere else.
|
||||
|
||||
```typescript
|
||||
// Bad: optional chaining because shape is unclear
|
||||
const value = response?.data?.items?.[0]?.name;
|
||||
|
||||
// Good: validate at boundary, trust the types
|
||||
const parsed = responseSchema.parse(rawResponse);
|
||||
const value = parsed.data.items[0].name;
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
- **Fail explicitly** — if caller requests X and X is unavailable, throw rather than silently returning Y
|
||||
- **Use typed domain errors** — not plain `Error`. Carry structured metadata for handling, logging, and user messaging
|
||||
- **Preserve error semantics** — don't collapse meaningful typed errors into generic `Error`
|
||||
|
||||
```typescript
|
||||
class TimeoutError extends Error {
|
||||
constructor(
|
||||
public readonly operation: string,
|
||||
public readonly waitedMs: number,
|
||||
) {
|
||||
super(`${operation} timed out after ${waitedMs}ms`);
|
||||
this.name = "TimeoutError";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Keep logic density low
|
||||
|
||||
Avoid packing branching, lookup, and transformation into single dense expressions.
|
||||
|
||||
```typescript
|
||||
// Bad: nested ternaries + inline lookups
|
||||
const billing = shouldUseLegacy(account)
|
||||
? getLegacy(account)
|
||||
: buildBilling(
|
||||
account,
|
||||
rates.find((r) => r.region === account.region),
|
||||
);
|
||||
|
||||
// Good: named steps, then assemble
|
||||
const rate = rates.find((r) => r.region === account.region);
|
||||
if (!rate) throw new MissingRateError(account.region);
|
||||
const billing = shouldUseLegacy(account) ? getLegacy(account) : buildBilling(account, rate);
|
||||
```
|
||||
|
||||
## Centralize policy
|
||||
|
||||
When the same discriminator (`plan`, `provider`, `kind`, `status`) is checked across multiple files, centralize it into a policy model. A new case should require editing one place, not many.
|
||||
|
||||
## React: keep components dumb
|
||||
|
||||
- Components render state and dispatch events — they don't compute transitions
|
||||
- If a component has more than two interacting `useState` calls, extract a state machine or reducer
|
||||
- `useRef` for mutable coordination state (flags, timers) is a smell — model states explicitly
|
||||
- Never mirror a source of truth into local state; derive from it
|
||||
- Test state logic as pure functions without rendering
|
||||
|
||||
## File organization
|
||||
|
||||
- Organize by domain first (`providers/claude/`), not by technical type (`tool-parsers/`)
|
||||
- Name files after the main export (`create-toolcall.ts`)
|
||||
- Use `index.ts` as an entrypoint, not a dumping ground
|
||||
- Collocate tests with implementation (`thing.ts` + `thing.test.ts`)
|
||||
|
||||
## Refactoring contract
|
||||
|
||||
Refactoring is structure work, not feature work.
|
||||
|
||||
- Preserve behavior by default, especially user-facing behavior
|
||||
- Do not remove features to simplify code without explicit approval
|
||||
- Have a verification strategy before you start
|
||||
- Fully migrate callers and remove old paths in the same refactor
|
||||
- No fallback behavior by default — prefer explicit error over silent degradation
|
||||
- Names describe meaning, not mechanics. `submitForm` over `handleOnClickButtonSubmit`. `running` over `filteredArrayOfRunningAgents`.
|
||||
- The right length is the shortest unambiguous in context. Inside `AgentManager`, methods are `start`, `stop`, `list`.
|
||||
- Match the surrounding code's vocabulary. If the codebase uses `getX`, don't introduce `fetchX` / `retrieveX` for the same shape.
|
||||
- Don't leak implementation into names — `getAgent`, not `queryPostgresForAgent`. If swapping the impl would force a rename, the name is wrong.
|
||||
- Booleans read as yes/no questions: `isX`, `hasX`, `canX`. Avoid negative booleans (`isNotConnected`).
|
||||
- `data`, `result`, `info`, `manager`, `temp` are smells — say what the thing _is_.
|
||||
|
||||
@@ -24,6 +24,7 @@ Provider IDs must be lowercase alphanumeric with hyphens (`/^[a-z][a-z0-9-]*$/`)
|
||||
- [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)
|
||||
- [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint)
|
||||
- [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)
|
||||
@@ -59,6 +60,8 @@ Required fields for custom providers:
|
||||
- `extends` — which built-in provider to inherit from (or `"acp"`)
|
||||
- `label` — display name in the UI
|
||||
|
||||
See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint) below for the dedicated Codex example.
|
||||
|
||||
---
|
||||
|
||||
## Z.AI (Zhipu) coding plan
|
||||
@@ -83,6 +86,7 @@ Required fields for custom providers:
|
||||
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
|
||||
"API_TIMEOUT_MS": "3000000"
|
||||
},
|
||||
"disallowedTools": ["WebSearch"],
|
||||
"models": [
|
||||
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
|
||||
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
|
||||
@@ -136,6 +140,7 @@ Required fields for custom providers:
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<your-coding-plan-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
|
||||
},
|
||||
"disallowedTools": ["WebSearch"],
|
||||
"models": [
|
||||
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
|
||||
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" },
|
||||
@@ -180,6 +185,62 @@ For pay-as-you-go, use `ANTHROPIC_API_KEY` with a standard Model Studio key (`sk
|
||||
|
||||
---
|
||||
|
||||
## Codex with a custom OpenAI-compatible endpoint
|
||||
|
||||
Codex talks to OpenAI's Responses API by default. Custom providers that extend `"codex"` can point Codex at any OpenAI-compatible endpoint (OpenRouter, LiteLLM, vLLM, llama.cpp server, an internal gateway, etc.) by setting `OPENAI_BASE_URL` and `OPENAI_API_KEY` in the provider `env`.
|
||||
|
||||
Paseo passes those variables through to the Codex app-server process **and** maps them into Codex's thread config under `model_provider` / `model_providers`, because Codex reads provider routing from config rather than from `OPENAI_BASE_URL` alone.
|
||||
|
||||
### Setup
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"providers": {
|
||||
"my-codex": {
|
||||
"extends": "codex",
|
||||
"label": "My Codex",
|
||||
"description": "Codex via custom OpenAI-compatible endpoint",
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "sk-...",
|
||||
"OPENAI_BASE_URL": "https://custom-relay.example.com"
|
||||
},
|
||||
"models": [{ "id": "custom-model", "label": "Custom Model", "isDefault": true }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### What Paseo wires up
|
||||
|
||||
Under the hood, for each custom Codex provider Paseo injects this into Codex's config:
|
||||
|
||||
```toml
|
||||
model_provider = "my-codex"
|
||||
|
||||
[model_providers.my-codex]
|
||||
name = "My Codex"
|
||||
base_url = "https://custom-relay.example.com/v1"
|
||||
wire_api = "responses"
|
||||
env_key = "OPENAI_API_KEY"
|
||||
requires_openai_auth = false
|
||||
```
|
||||
|
||||
- `base_url` — taken from `OPENAI_BASE_URL`. If it does not already end in `/v1`, Paseo appends `/v1`. Trailing slashes are stripped.
|
||||
- `wire_api` — always `"responses"` (OpenAI Responses API protocol).
|
||||
- `env_key` — set to `"OPENAI_API_KEY"` when that env var is present and non-empty, so Codex reads the key from the same env var Paseo passes through.
|
||||
- `requires_openai_auth` — forced to `false` when `OPENAI_API_KEY` is provided, so Codex skips its built-in OpenAI login flow.
|
||||
|
||||
### Notes
|
||||
|
||||
- The endpoint must speak the OpenAI **Responses API**, not just chat completions. Many gateways (OpenRouter, LiteLLM) support both — pick the Responses-compatible route.
|
||||
- Set `models` explicitly. Custom endpoints expose their own model IDs (`anthropic/claude-opus-4-7`, `qwen/qwen3-coder`, `local/llama`, etc.), and Paseo does not discover them automatically for Codex.
|
||||
- To run multiple endpoints side-by-side, define multiple entries that each extend `"codex"` with different IDs, labels, and env. Each appears as its own provider in the app.
|
||||
- If you only want to override the binary (e.g. a nightly Codex build) without changing the endpoint, omit `OPENAI_BASE_URL` and use `command` instead — see [Custom binary for a provider](#custom-binary-for-a-provider).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -313,6 +374,8 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st
|
||||
|
||||
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
|
||||
|
||||
Paseo also ships an in-app ACP provider catalog for common agents, including Cursor, DeepAgents, DeepSeek TUI, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
|
||||
|
||||
### Adding a generic ACP provider
|
||||
|
||||
Set `extends: "acp"` and provide a `command`:
|
||||
@@ -340,11 +403,19 @@ Required fields for ACP providers:
|
||||
- `label`
|
||||
- `command` — the command to spawn the agent process (must support ACP over stdio)
|
||||
|
||||
### Generic ACP diagnostics
|
||||
|
||||
Paseo diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status.
|
||||
|
||||
For package-runner commands such as `npx -y @google/gemini-cli --acp`, the version probe keeps the package spec and runs `npx -y @google/gemini-cli --version`. This diagnoses the actual agent package instead of only proving that `npx` exists.
|
||||
|
||||
ACP probes use short timeouts and browser-suppression environment variables so agents that enter an auth/browser flow fail as a diagnostic error instead of hanging the provider screen.
|
||||
|
||||
### Example: Google Gemini CLI
|
||||
|
||||
[Gemini CLI](https://github.com/google-gemini/gemini-cli) supports ACP via the `--acp` flag.
|
||||
|
||||
1. Install: `npm install -g @anthropic-ai/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)
|
||||
1. Install: `npm install -g @google/gemini-cli` or see [Gemini CLI docs](https://github.com/google-gemini/gemini-cli)
|
||||
2. Authenticate with Google (Gemini CLI handles its own auth)
|
||||
3. Add to config.json:
|
||||
|
||||
@@ -502,6 +573,12 @@ Each entry in the `models` array:
|
||||
| `description` | `string` | No | Short description |
|
||||
| `isDefault` | `boolean` | No | Mark as the default thinking option |
|
||||
|
||||
### Claude settings.json model discovery
|
||||
|
||||
The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`.
|
||||
|
||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. `agents.providers.claude.models` is still supported and is additive for the built-in Claude provider; duplicate IDs are de-duplicated.
|
||||
|
||||
### Gotcha: `extends: "claude"` with third-party endpoints
|
||||
|
||||
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
|
||||
@@ -550,6 +627,7 @@ A config.json with multiple custom providers:
|
||||
"ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
|
||||
"API_TIMEOUT_MS": "3000000"
|
||||
},
|
||||
"disallowedTools": ["WebSearch"],
|
||||
"models": [
|
||||
{ "id": "glm-4.5-air", "label": "GLM 4.5 Air" },
|
||||
{ "id": "glm-5-turbo", "label": "GLM 5 Turbo", "isDefault": true },
|
||||
@@ -564,6 +642,7 @@ A config.json with multiple custom providers:
|
||||
"ANTHROPIC_AUTH_TOKEN": "sk-sp-<coding-plan-key>",
|
||||
"ANTHROPIC_BASE_URL": "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"
|
||||
},
|
||||
"disallowedTools": ["WebSearch"],
|
||||
"models": [
|
||||
{ "id": "qwen3.5-plus", "label": "Qwen 3.5 Plus", "isDefault": true },
|
||||
{ "id": "qwen3-coder-next", "label": "Qwen 3 Coder Next" }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Data Model
|
||||
|
||||
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas and written atomically (write to temp file, then rename). There are no migrations — schemas use optional fields with defaults for forward compatibility.
|
||||
Paseo uses **file-based JSON persistence** instead of a traditional database. All data is validated at runtime with Zod schemas. Most stores write atomically (write to temp file, then rename); a few still use plain `writeFile` — see each section. There is no schema-versioning/migration framework — schemas rely on optional fields with defaults for forward compatibility, with a small amount of inline normalization in `persisted-config.ts` for legacy provider/speech entries.
|
||||
|
||||
All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||
|
||||
@@ -11,8 +11,12 @@ All server-side stores live under `$PASEO_HOME` (defaults to `~/.paseo`).
|
||||
```
|
||||
$PASEO_HOME/
|
||||
├── config.json # Daemon configuration
|
||||
├── server-id # Stable daemon identifier (plain text, "srv_<base64url>")
|
||||
├── daemon-keypair.json # E2EE keypair for relay (mode 0600)
|
||||
├── paseo.pid # Daemon PID lock file
|
||||
├── daemon.log # Default log file (path configurable)
|
||||
├── agents/
|
||||
│ └── {project-dir}/
|
||||
│ └── {sanitized-cwd}/
|
||||
│ └── {agentId}.json # One file per agent
|
||||
├── schedules/
|
||||
│ └── {scheduleId}.json # One file per schedule
|
||||
@@ -26,6 +30,8 @@ $PASEO_HOME/
|
||||
└── push-tokens.json # Expo push notification tokens
|
||||
```
|
||||
|
||||
The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` by stripping the filesystem root and replacing path separators with `-` (Windows drive letters become a `C-` style prefix). Atomic writes (temp file + rename): agent records, chat, project/workspace registries, push tokens. Non-atomic (plain `writeFile`): `config.json`, `schedules/*.json`, `loops/loops.json`, `server-id`, `daemon-keypair.json`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Agent Record
|
||||
@@ -34,28 +40,29 @@ $PASEO_HOME/
|
||||
|
||||
Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
|
||||
| Field | Type | Description |
|
||||
| -------------------- | ---------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `id` | `string` | UUID, primary key |
|
||||
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
|
||||
| `cwd` | `string` | Working directory the agent operates in |
|
||||
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
|
||||
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
|
||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||
| `title` | `string?` | User-visible title |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`) |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
||||
| `lastModeId` | `string?` | Last active mode ID |
|
||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
|
||||
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
|
||||
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
|
||||
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
||||
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
||||
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
||||
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
|
||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||
| Field | Type | Description |
|
||||
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | `string` | UUID, primary key |
|
||||
| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) |
|
||||
| `cwd` | `string` | Working directory the agent operates in |
|
||||
| `createdAt` | `string` (ISO 8601) | Creation timestamp |
|
||||
| `updatedAt` | `string` (ISO 8601) | Last update timestamp |
|
||||
| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp |
|
||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||
| `title` | `string?` | User-visible title |
|
||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` |
|
||||
| `lastModeId` | `string?` | Last active mode ID |
|
||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||
| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) |
|
||||
| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) |
|
||||
| `persistence` | `PersistenceHandle?` | Handle for resuming sessions |
|
||||
| `lastError` | `string?` (nullable) | Last error message, if any |
|
||||
| `requiresAttention` | `boolean?` | Whether the agent needs user attention |
|
||||
| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed |
|
||||
| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged |
|
||||
| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) |
|
||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||
|
||||
### Nested: SerializableConfig
|
||||
|
||||
@@ -114,7 +121,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
||||
| `description` | `string?` |
|
||||
| `tooltip` | `string?` |
|
||||
| `icon` | `string?` |
|
||||
| `value` | `string?` |
|
||||
| `value` | `string \| null` |
|
||||
| `options` | `AgentSelectOption[]` |
|
||||
|
||||
---
|
||||
@@ -130,10 +137,12 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
version: 1,
|
||||
daemon: {
|
||||
listen: "127.0.0.1:6767",
|
||||
hostnames: true | string[],
|
||||
mcp: { enabled: boolean },
|
||||
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
|
||||
mcp: { enabled: boolean, injectIntoAgents: boolean },
|
||||
appendSystemPrompt: string, // appended to supported provider system/developer prompts
|
||||
cors: { allowedOrigins: string[] },
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string }
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean },
|
||||
auth: { password: string } // bcrypt hash, optional
|
||||
},
|
||||
app: {
|
||||
baseUrl: string
|
||||
@@ -143,16 +152,17 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
local: { modelsDir: string }
|
||||
},
|
||||
agents: {
|
||||
providers: {
|
||||
[provider: string]: {
|
||||
command: { mode: "default" } | { mode: "append", args: string[] } | { mode: "replace", argv: string[] },
|
||||
env: Record<string, string>
|
||||
}
|
||||
// ProviderOverrideSchema; legacy entries with `command: { mode, ... }` are migrated to the
|
||||
// current shape on load via `migrateProviderSettings`. Custom provider IDs must declare
|
||||
// `extends` (one of the built-ins or `"acp"`) and `label`. See `provider-launch-config.ts`.
|
||||
providers: Record<providerId, ProviderOverride>,
|
||||
metadataGeneration: {
|
||||
providers: [{ provider, model?, thinkingOptionId? }]
|
||||
}
|
||||
},
|
||||
features: {
|
||||
dictation: { enabled, stt: { provider, model, confidenceThreshold } },
|
||||
voiceMode: { enabled, llm, stt, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||
dictation: { enabled, stt: { provider, model, language, confidenceThreshold } },
|
||||
voiceMode: { enabled, llm, stt: { provider, model, language }, turnDetection, tts: { provider, model, voice, speakerId, speed } }
|
||||
},
|
||||
log: {
|
||||
level, format,
|
||||
@@ -164,13 +174,17 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
|
||||
All fields are optional with sensible defaults.
|
||||
|
||||
`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available.
|
||||
|
||||
Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model.
|
||||
|
||||
---
|
||||
|
||||
## 3. Schedule
|
||||
|
||||
**Path:** `$PASEO_HOME/schedules/{id}.json`
|
||||
|
||||
One file per schedule. ID is 8 hex characters.
|
||||
One file per schedule. ID is 8 hex characters. Writes are direct (not atomic).
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------- | ------------------------------------- | -------------------------------- |
|
||||
@@ -255,7 +269,7 @@ Single file containing all rooms and messages.
|
||||
|
||||
**Path:** `$PASEO_HOME/loops/loops.json`
|
||||
|
||||
Single file containing an array of all loop records.
|
||||
Single file containing an array of all loop records. Writes are direct (not atomic) and serialized through an in-memory queue. On daemon startup any record with `status: "running"` is recovered as `"stopped"` with an interruption log entry.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------------------- | --------------------------------------------------- | ------------------------------------------ |
|
||||
@@ -265,10 +279,12 @@ Single file containing an array of all loop records.
|
||||
| `cwd` | `string` | Working directory |
|
||||
| `provider` | `string` | Default provider |
|
||||
| `model` | `string?` | Default model |
|
||||
| `modeId` | `string?` | Default mode ID |
|
||||
| `workerProvider` | `string?` | Override provider for workers |
|
||||
| `workerModel` | `string?` | Override model for workers |
|
||||
| `verifierProvider` | `string?` | Override provider for verifiers |
|
||||
| `verifierModel` | `string?` | Override model for verifiers |
|
||||
| `verifierModeId` | `string?` | Override mode ID for verifiers |
|
||||
| `verifyPrompt` | `string?` | LLM verification prompt |
|
||||
| `verifyChecks` | `string[]` | Shell commands to run as checks |
|
||||
| `archive` | `boolean` | Whether to archive worker agents after use |
|
||||
@@ -344,15 +360,20 @@ Single file containing an array of all loop records.
|
||||
|
||||
Array of project records.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | -------------------- | ------------------------------ |
|
||||
| `projectId` | `string` | Primary key |
|
||||
| `rootPath` | `string` | Filesystem root of the project |
|
||||
| `kind` | `"git" \| "non_git"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||
| Field | Type | Description |
|
||||
| ------------- | --------------------------- | ---------------------------------------- |
|
||||
| `projectId` | `string` | Primary key |
|
||||
| `rootPath` | `string` | Filesystem root of the project |
|
||||
| `kind` | `"git" \| "non_git"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete timestamp; required nullable |
|
||||
|
||||
Active git projects are unique by normalized `rootPath`. Startup reconciliation repairs older bad
|
||||
states by moving workspaces from duplicate path-keyed projects onto the canonical project,
|
||||
preferring remote-keyed project IDs such as `remote:github.com/owner/repo`, then archiving the
|
||||
emptied duplicate.
|
||||
|
||||
---
|
||||
|
||||
@@ -362,16 +383,16 @@ Array of project records.
|
||||
|
||||
Array of workspace records. A workspace is a specific working directory within a project.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------------- | ----------------------------------------------- | ----------------------- |
|
||||
| `workspaceId` | `string` | Primary key |
|
||||
| `projectId` | `string` | FK to Project.projectId |
|
||||
| `cwd` | `string` | Filesystem path |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp |
|
||||
| Field | Type | Description |
|
||||
| ------------- | ----------------------------------------------- | ------------------------------ |
|
||||
| `workspaceId` | `string` | Primary key |
|
||||
| `projectId` | `string` | FK to Project.projectId |
|
||||
| `cwd` | `string` | Filesystem path |
|
||||
| `kind` | `"local_checkout" \| "worktree" \| "directory"` | |
|
||||
| `displayName` | `string` | |
|
||||
| `createdAt` | `string` (ISO 8601) | |
|
||||
| `updatedAt` | `string` (ISO 8601) | |
|
||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||
|
||||
---
|
||||
|
||||
@@ -385,7 +406,20 @@ Array of workspace records. A workspace is a specific working directory within a
|
||||
}
|
||||
```
|
||||
|
||||
Simple set of Expo push notification tokens. No schema validation — just an array of strings.
|
||||
Simple set of Expo push notification tokens. Loaded with permissive parsing (filters non-string entries). Persisted with atomic temp-file rename.
|
||||
|
||||
---
|
||||
|
||||
## 9. Daemon meta files
|
||||
|
||||
These small files are not validated as full Zod schemas but are persisted under `$PASEO_HOME` for daemon identity and runtime coordination.
|
||||
|
||||
| Path | Format | Notes |
|
||||
| --------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `server-id` | Plain text, e.g. `srv_<base64url>` | Stable per-`$PASEO_HOME` daemon ID. Overridable via `PASEO_SERVER_ID` env. |
|
||||
| `daemon-keypair.json` | `{ v: 2, publicKeyB64, secretKeyB64 }` (libsodium box keypair) | E2EE relay identity. Written with mode `0600`. Regenerated if file is unreadable. |
|
||||
| `paseo.pid` | JSON `{ pid, startedAt, ... }` | PID lock; prevents two daemons sharing one `$PASEO_HOME`. |
|
||||
| `daemon.log` | Pino log output | Default location; path/rotation configurable via `log.file` in `config.json`. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
# Design system
|
||||
|
||||
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` |
|
||||
268
docs/design.md
268
docs/design.md
@@ -1,73 +1,247 @@
|
||||
# Designing Features
|
||||
# Design
|
||||
|
||||
How to think through a feature before writing code.
|
||||
Tokens — every color, font size, weight, spacing step, radius, icon size — live in `packages/app/src/styles/theme.ts`.
|
||||
|
||||
## Start from the user
|
||||
---
|
||||
|
||||
Even for backend work, start from the user's perspective:
|
||||
## 1. Character
|
||||
|
||||
- What problem does this solve?
|
||||
- What triggers it? User action, schedule, event?
|
||||
- What does success look like from the user's perspective?
|
||||
- What data does it need? Where does that data come from?
|
||||
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.
|
||||
|
||||
## Map existing code
|
||||
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_.
|
||||
|
||||
Before designing anything new, understand what exists:
|
||||
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.
|
||||
|
||||
- Where does similar functionality live?
|
||||
- What patterns does the codebase already use?
|
||||
- What layers exist? (See [architecture.md](./architecture.md))
|
||||
- What types and data shapes are already defined?
|
||||
---
|
||||
|
||||
New features rarely mean only new code. Usually they require modifying existing interfaces, extending existing types, or refactoring to accommodate the new functionality. Identify what needs to change, not just what needs to be added.
|
||||
## 2. Component reuse
|
||||
|
||||
## Define verification before implementation
|
||||
A semantic element used in three or more places is a primitive. One of a kind is a screen.
|
||||
|
||||
Before designing the solution, define how you'll know it works:
|
||||
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`.
|
||||
|
||||
- What tests will prove this feature is correct?
|
||||
- At what layer? Unit, integration, E2E?
|
||||
- What's the simplest way to verify the core behavior?
|
||||
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`).
|
||||
|
||||
If you can't define verification, you don't understand the feature well enough yet.
|
||||
Before adding a new component, read `components/ui/`. The primitive usually exists.
|
||||
|
||||
## Design the shape
|
||||
---
|
||||
|
||||
### Data
|
||||
## 3. Hierarchy
|
||||
|
||||
- What types are needed?
|
||||
- Use discriminated unions — make impossible states impossible
|
||||
- One canonical type per concept (see [coding-standards.md](./coding-standards.md))
|
||||
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`.
|
||||
|
||||
### Layers
|
||||
Weight has three tiers, applied by role:
|
||||
|
||||
- What belongs in each layer?
|
||||
- Where are the boundaries?
|
||||
- What does each layer expose to the layer above?
|
||||
- **Screen titles** — the title at the top of a screen — use `<ScreenTitle>` (`packages/app/src/components/headers/screen-title.tsx`), which renders `fontSize.base` at weight `400` on compact and `300` on desktop. Top-of-screen titles are lighter on desktop, not heavier. The workspace screen header follows the same rule (`packages/app/src/screens/workspace/workspace-screen.tsx`).
|
||||
- **Structural labels** use `fontWeight.medium`. This applies to section labels above a stack of rows (`packages/app/src/components/agent-list.tsx:519-523`, `packages/app/src/components/keyboard-shortcuts-dialog.tsx:63-67`), form field labels above an input inside a modal (`packages/app/src/components/add-host-modal.tsx:19-23`, `packages/app/src/components/pair-link-modal.tsx:24-28`), the title at the top of a modal/sheet/dialog (`packages/app/src/components/adaptive-modal-sheet.tsx:90-94`, `packages/app/src/components/ui/combobox.tsx:1607-1611`, `packages/app/src/components/welcome-screen.tsx:48-53`), action button labels in tight components such as the sidebar callout actions (`packages/app/src/components/sidebar-callout.tsx:218-221`), and inline data emphasis on dense metadata rows (`packages/app/src/components/git-diff-pane.tsx:2322-2327`, `packages/app/src/components/file-explorer-pane.tsx:1115-1122`).
|
||||
- **Content** uses `fontWeight.normal`. This applies to settings rows (`packages/app/src/styles/settings.ts`), sidebar primary list-item titles (`packages/app/src/components/sidebar-workspace-list.tsx:2680-2686`, `packages/app/src/components/agent-list.tsx:572-578`), `<Button>` text (`packages/app/src/components/ui/button.tsx:80-84`), `<StatusBadge>` text (`packages/app/src/components/ui/status-badge.tsx:56-60`), and `<SidebarCallout>` titles (`packages/app/src/components/sidebar-callout.tsx:175-180`).
|
||||
|
||||
### Interactions
|
||||
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.
|
||||
|
||||
- How does data flow through the system?
|
||||
- What triggers what?
|
||||
- Where do side effects happen?
|
||||
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.
|
||||
|
||||
### Refactoring
|
||||
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.
|
||||
|
||||
- What existing code needs to change?
|
||||
- Is existing code testable enough? If not, that's part of the plan.
|
||||
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.
|
||||
|
||||
## Create a concrete plan
|
||||
---
|
||||
|
||||
Once the design is clear:
|
||||
## 4. Buttons
|
||||
|
||||
1. **Acceptance criteria** — specific, verifiable outcomes (not "should work well" but "returns X when given Y")
|
||||
2. **Ordered steps** — what to build first (usually: types, then lowest layer, then up)
|
||||
3. **What to refactor** before adding new code
|
||||
4. **How to verify** each step
|
||||
The button is `<Button>` (`packages/app/src/components/ui/button.tsx`). It has five variants. Each has one job.
|
||||
|
||||
## Principles
|
||||
`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.
|
||||
|
||||
- **Fit, don't force** — new code should fit existing patterns, or refactor first
|
||||
- **Simple** — the best design is the simplest one that works
|
||||
- **Verify early** — define how to test before designing the implementation
|
||||
`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: `xs` for ultra-tight inline triggers. `sm` for any button sitting in a row. `md` is the page default. `lg` is reserved for large standalone CTAs.
|
||||
|
||||
A `<Pressable>` wrapping a `<Text>` is a sixth variant. It is wrong. `<Button>` accepts `style`, `textStyle`, `leftIcon`, `disabled`, `size`, and `variant`.
|
||||
|
||||
---
|
||||
|
||||
## 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`, `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 worktree setup, Rosetta install, and desktop update available — register through `useSidebarCallouts()` and render in the left sidebar via `<SidebarCallout>` (`packages/app/src/components/sidebar-callout.tsx`). The chrome (top-border-only, full-width action buttons) is tuned for that ~280px column. Canonical sources: `packages/app/src/components/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx`. Never import `<SidebarCallout>` into a page — that's what `<Alert>` is for.
|
||||
|
||||
Imperative errors are `Alert.alert("Error", "Unable to ...")` (the React Native `Alert` API, not this component) for failures that interrupt the flow and have no place on the page.
|
||||
|
||||
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, and future destructive actions are confirmed. Worktree archive is confirmed only when git runtime reports uncommitted changes or unpushed commits; clean pushed worktrees archive immediately.
|
||||
- Bespoke status pills. `<StatusBadge>` is the pill primitive.
|
||||
- Raw `Modal` for a focused task. `<AdaptiveModalSheet>` is the modal primitive.
|
||||
- Importing `ActivityIndicator` directly. `<LoadingSpinner>` is the loading primitive.
|
||||
|
||||
---
|
||||
|
||||
## 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/worktree-setup-callout-source.tsx`, `packages/app/src/desktop/updates/rosetta-callout-source.tsx`, `packages/app/src/desktop/updates/update-callout-source.tsx` |
|
||||
| Searchable picker | `packages/app/src/components/ui/combobox.tsx`, `packages/app/src/components/branch-switcher.tsx` |
|
||||
| Trigger-anchored menu | `packages/app/src/components/ui/dropdown-menu.tsx` (used in `sidebar-workspace-list.tsx`, theme picker) |
|
||||
| Right-click / long-press menu | `packages/app/src/components/ui/context-menu.tsx` (used in `sidebar-workspace-list.tsx`) |
|
||||
| Headers (back, screen, menu) | `packages/app/src/components/headers/back-header.tsx`, `screen-header.tsx`, `menu-header.tsx` |
|
||||
|
||||
@@ -11,54 +11,64 @@
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The dev script automatically picks an available port. Both the server and Expo app run in a Tmux session — see `CLAUDE.local.md` for system-specific session details.
|
||||
`scripts/dev.sh` runs the daemon and Expo together via `concurrently`, fronted by [`portless`](https://www.npmjs.com/package/portless) so each service is reachable at a stable name like `https://daemon.localhost` / `https://app.localhost` instead of a fixed port. The underlying TCP ports are ephemeral — never hardcode them. (Windows uses `scripts/dev.ps1`, which still binds the daemon to `localhost:6767` directly.)
|
||||
|
||||
### Running alongside the main checkout
|
||||
### PASEO_HOME
|
||||
|
||||
Set `PASEO_HOME` to isolate state when running a second instance (e.g., in a worktree):
|
||||
`PASEO_HOME` is the directory that holds runtime state (agents, sockets, daemon log). Resolution rules:
|
||||
|
||||
- The **server itself** (e.g. when launched by the desktop app or `npm run start`) defaults to `~/.paseo` (see `packages/server/src/server/paseo-home.ts`).
|
||||
- **`npm run dev` from a git worktree** derives a stable home like `~/.paseo-<worktree-name>` and, on first run, seeds it from `~/.paseo` by copying agent/project JSON metadata and `config.json`. Checkout/worktree directories are not copied.
|
||||
- **`npm run dev` from the main checkout** (not a worktree) uses a fresh `mktemp` directory under `$TMPDIR` and removes it on exit. Set `PASEO_HOME` explicitly to keep state across runs.
|
||||
|
||||
Override knobs:
|
||||
|
||||
```bash
|
||||
PASEO_HOME=~/.paseo-blue npm run dev
|
||||
PASEO_HOME=~/.paseo-blue npm run dev # explicit home
|
||||
PASEO_DEV_SEED_HOME=/path/to/home npm run dev # seed from a different source home
|
||||
PASEO_DEV_RESET_HOME=1 npm run dev # clear and reseed the derived worktree home
|
||||
```
|
||||
|
||||
- `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.
|
||||
### Daemon endpoints
|
||||
|
||||
### Default ports
|
||||
- Stable daemon launched by the desktop app: `localhost:6767`.
|
||||
- `npm run dev` (macOS/Linux): portless URLs only — read them from the `dev.sh` banner or `portless get daemon` / `portless get app`.
|
||||
- `npm run dev` (Windows): `localhost:6767` for the daemon.
|
||||
|
||||
In the main checkout:
|
||||
In any worktree-style or portless setup, never assume default ports.
|
||||
|
||||
- Daemon: `localhost:6767`
|
||||
- Expo app: `localhost:8081`
|
||||
### Desktop renderer profiling
|
||||
|
||||
In worktrees or with `npm run dev`, ports may differ. Never assume defaults.
|
||||
`npm run dev:desktop` starts Electron with Chromium remote debugging enabled on
|
||||
`http://127.0.0.1:9223` so renderer CPU profiles can be captured through CDP.
|
||||
Override the port with `PASEO_ELECTRON_REMOTE_DEBUGGING_PORT` when `9223` is busy.
|
||||
|
||||
### Desktop macOS compositor watchdog
|
||||
|
||||
macOS display sleep can leave Chromium's GPU-process display link — the vsync
|
||||
source that drives frame production — stuck on a stale display. The compositor
|
||||
then stops producing frames and the window looks frozen: unresponsive to clicks
|
||||
and keys even though the renderer and every process stay alive. It self-recovers
|
||||
after a few minutes, which is too long for a foreground app.
|
||||
|
||||
`setupDarwinCompositorWatchdog`
|
||||
(`packages/desktop/src/window/compositor-watchdog/index.ts`) guards against
|
||||
this. It polls the renderer for frame production every couple of seconds and,
|
||||
after a sustained stall while the window is visible and unlocked, restarts the
|
||||
GPU process so Chromium rebuilds the display link. The probe is skipped while
|
||||
the screen is locked or the window is hidden or minimized, since a window
|
||||
legitimately stops producing frames then.
|
||||
|
||||
### Daemon logs
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for trace-level logs.
|
||||
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
||||
`PASEO_LOG_LEVEL=trace` before launching the daemon when you need full provider,
|
||||
session, and agent-manager traces for stuck-state debugging.
|
||||
|
||||
### 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.
|
||||
The supervisor rotates `daemon.log`. Persisted `log.file.rotate` settings in
|
||||
`$PASEO_HOME/config.json` win first. Without persisted config, the optional
|
||||
`PASEO_LOG_ROTATE_SIZE` and `PASEO_LOG_ROTATE_COUNT` env vars override the
|
||||
defaults. The default rotation is `10m` x `3` files everywhere.
|
||||
|
||||
## paseo.json service scripts
|
||||
|
||||
@@ -99,31 +109,32 @@ Every `scripts` entry with `"type": "service"` receives these environment variab
|
||||
}
|
||||
```
|
||||
|
||||
## Build sync gotchas
|
||||
## Built workspace packages
|
||||
|
||||
### Relay → Daemon
|
||||
Package imports resolve through package exports to compiled `dist/` output, not sibling `src/` files. This is true in local dev and in published packages: the app, daemon, CLI, and SDK consumers should all exercise the same runtime paths.
|
||||
|
||||
When changing `packages/relay/src/*`, rebuild before running the daemon:
|
||||
`npm run dev`, `npm run dev:server`, and `npm run dev:app` build the workspace packages they need once, then keep `@getpaseo/protocol` and `@getpaseo/client` fresh with TypeScript watch builds while the daemon or Expo runs. If you change protocol schemas or client code outside those watch workflows, rebuild the producer before trusting runtime behavior.
|
||||
|
||||
Use the named root build targets instead of remembering workspace dependency chains:
|
||||
|
||||
```bash
|
||||
npm run build --workspace=@getpaseo/relay
|
||||
npm run build:client # protocol -> client
|
||||
npm run build:server-deps # highlight -> relay -> protocol -> client
|
||||
npm run build:server # server-deps -> server -> cli
|
||||
npm run build:app-deps # highlight -> protocol -> client -> expo-two-way-audio
|
||||
```
|
||||
|
||||
The Node daemon imports `@getpaseo/relay` from `packages/relay/dist/*`, not `src/*`.
|
||||
Use `npm run build:server` whenever you have changed any daemon/server-facing package and need clean cross-package types or runtime behavior.
|
||||
|
||||
### Server → CLI
|
||||
For tighter loops, you can rebuild a single workspace:
|
||||
|
||||
When changing `packages/server/src/client/*` (especially `daemon-client.ts`) or shared WS protocol types, rebuild before running CLI commands:
|
||||
|
||||
```bash
|
||||
npm run build --workspace=@getpaseo/server
|
||||
```
|
||||
|
||||
The CLI imports `@getpaseo/server` via package exports resolving to `dist/*`. Stale `dist` means the CLI speaks an old protocol and fails with handshake warnings or timeouts.
|
||||
- Changed `packages/protocol/src/*` or `packages/client/src/*`: `npm run build:client`.
|
||||
- Changed `packages/server/src/*`, `packages/cli/src/*`, `packages/relay/src/*`, or `packages/highlight/src/*`: `npm run build:server`.
|
||||
- Changed app build dependencies: `npm run build:app-deps`.
|
||||
|
||||
## CLI reference
|
||||
|
||||
Use `npm run cli` to run the local CLI (instead of the globally installed `paseo` which points to the main checkout).
|
||||
Use `npm run cli` to run the in-repo CLI from source (`npx tsx packages/cli/src/index.ts`). The globally installed `paseo` binary on macOS is a symlink into the installed Paseo desktop app, not this checkout — use it to drive the desktop's built-in daemon, but use `npm run cli` when you want to talk to the CLI you are editing.
|
||||
|
||||
```bash
|
||||
npm run cli -- ls -a -g # List all agents globally
|
||||
@@ -177,10 +188,24 @@ Get the session ID from the agent JSON (`persistence.sessionId`), then:
|
||||
|
||||
## Testing with Playwright MCP
|
||||
|
||||
Use Playwright MCP connecting to Metro at `http://localhost:8081` for UI testing.
|
||||
Point Playwright MCP at the running Expo web target. Under `npm run dev` (macOS/Linux) that is the portless URL printed in the dev banner — typically `https://app.localhost`. If you start Expo directly with `expo start --web` (no portless), Metro defaults to `http://localhost:8081`.
|
||||
|
||||
Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL — the app uses client-side routing and browser history breaks state.
|
||||
|
||||
## App web deploys
|
||||
|
||||
`packages/app` exports a single-page Expo web app and deploys the `dist/`
|
||||
directory to Cloudflare Pages with `npm run deploy:web --workspace=@getpaseo/app`.
|
||||
|
||||
PWA install metadata lives in `packages/app/public/manifest.json` and is linked
|
||||
from `packages/app/public/index.html`. Keep the install icons in `public/` so
|
||||
Cloudflare serves them from stable root URLs after `expo export`.
|
||||
|
||||
Do not add service-worker caching casually. Paseo is a live control surface for
|
||||
agents, and an aggressive service worker can strand installed users on stale web
|
||||
code. If offline behavior becomes a product requirement, add it deliberately
|
||||
with an update strategy and test the installed-app upgrade path.
|
||||
|
||||
## Expo troubleshooting
|
||||
|
||||
```bash
|
||||
|
||||
211
docs/diagnostics/git-snapshot-startup-reshaping-2026-05-27.md
Normal file
211
docs/diagnostics/git-snapshot-startup-reshaping-2026-05-27.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# Git Snapshot Startup Reshaping - 2026-05-27
|
||||
|
||||
## What changed
|
||||
|
||||
The sidebar PR badge no longer has a special per-row fetch path. It is derived from the workspace snapshot, the same way the sidebar already gets branch/diff metadata.
|
||||
|
||||
```text
|
||||
daemon startup / workspace subscription
|
||||
-> WorkspaceGitService.refreshSnapshot(cwd)
|
||||
-> getCheckoutSnapshotFacts(cwd)
|
||||
-> getCheckoutStatus(cwd, { facts })
|
||||
-> getCheckoutShortstat(cwd, { facts })
|
||||
-> getPullRequestStatus(cwd, github, ..., { facts })
|
||||
-> WorkspaceGitRuntimeSnapshot
|
||||
-> session workspace descriptor githubRuntime.pullRequest
|
||||
-> app useSidebarWorkspacesList()
|
||||
-> SidebarWorkspaceEntry.prHint
|
||||
-> Sidebar row badge + hover card checks
|
||||
```
|
||||
|
||||
The remaining `checkout_pr_status_request` path is still present for explicit PR surfaces and compatibility, but the sidebar row badge no longer calls `useWorkspacePrHint()` and therefore no longer generates ad hoc checkout PR status requests per visible row.
|
||||
|
||||
## Shared Git Facts
|
||||
|
||||
`getCheckoutSnapshotFacts()` is now the first git read in the workspace snapshot builder. It gathers facts that were previously rediscovered by separate functions:
|
||||
|
||||
- worktree root: `rev-parse --show-toplevel`
|
||||
- current branch: `rev-parse --abbrev-ref HEAD`
|
||||
- origin remote URL
|
||||
- Paseo worktree ownership and stored base ref
|
||||
- resolved base ref and best comparison base
|
||||
- main repo root
|
||||
- branch remote/merge config
|
||||
- tracked origin branch
|
||||
- pull request lookup target for fork/PR worktrees
|
||||
|
||||
Those facts are then passed through `CheckoutContext` so status, shortstat, and PR status reuse the same answers instead of independently re-reading them.
|
||||
|
||||
## Current Data Flow
|
||||
|
||||
```text
|
||||
Workspace subscription / fetch_workspaces
|
||||
-> session workspace registry
|
||||
-> workspaceGitService.getSnapshot(cwd, includeGitHub)
|
||||
-> refresh queue/throttle/dedupe per normalized cwd
|
||||
-> refreshGitSnapshot()
|
||||
-> getCheckoutSnapshotFacts()
|
||||
-> getCheckoutStatus({ facts })
|
||||
-> getCheckoutShortstat({ facts })
|
||||
-> refreshGitHubSnapshot()
|
||||
-> getPullRequestStatus({ facts })
|
||||
-> cached WorkspaceGitRuntimeSnapshot
|
||||
-> WorkspaceDescriptorPayload.gitRuntime
|
||||
-> WorkspaceDescriptorPayload.githubRuntime
|
||||
-> app session store
|
||||
-> useSidebarWorkspacesList()
|
||||
-> diffStat from descriptor
|
||||
-> prHint from descriptor.githubRuntime.pullRequest
|
||||
```
|
||||
|
||||
## Startup Benchmark
|
||||
|
||||
Added deterministic real-home benchmark:
|
||||
|
||||
`packages/server/scripts/benchmark-startup-git-real-home.ts`
|
||||
|
||||
The script freezes the current Paseo home using the same metadata-copy shape as `scripts/dev-home.sh`: JSON under `agents`, JSON under `projects`, and `config.json`. It then starts an isolated in-process daemon against that frozen home, subscribes to workspaces/agents, records git invocations through `runGitCommand`, and reports elapsed time, git count, max concurrency, CPU, and memory deltas.
|
||||
|
||||
The frozen home used for the comparison contained 22 workspaces.
|
||||
|
||||
### Before/After
|
||||
|
||||
| run | code shape | client shape | git commands | failures | elapsed |
|
||||
| ----------- | -------------------------- | ----------------------------------------- | -----------: | -------: | ------: |
|
||||
| baseline | before change | legacy sidebar PR fanout | 529 | 20 | 39039ms |
|
||||
| split check | after change | legacy sidebar PR fanout | 375 | 15 | 39039ms |
|
||||
| after | after change | snapshot-only sidebar, no PR badge fanout | 372 | 15 | 31273ms |
|
||||
| after 2 | after service fact sharing | snapshot-only sidebar, no PR badge fanout | 308 | 15 | 31334ms |
|
||||
|
||||
The server-side fact reuse accounts for nearly all measured git command reduction: `529 -> 375` (`-154`, `-29.1%`) even when the old PR fanout is still forced. Removing the sidebar fanout removes the ad hoc request path, but in this run it only changed command count by `3` because the refreshed workspace snapshots already carried the PR data by the time the fanout ran.
|
||||
|
||||
The second pass shares checkout facts between workspace observation setup and snapshot refresh. That removes another `64` git commands from the same frozen-home run: `372 -> 308` (`-17.2%` from the previous after, `-41.8%` from baseline).
|
||||
|
||||
### Baseline: before change + legacy PR fanout
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": "legacyPrFanout",
|
||||
"workspaceCount": 22,
|
||||
"elapsedMs": 39039,
|
||||
"git": {
|
||||
"total": 529,
|
||||
"failed": 20,
|
||||
"maxConcurrent": 8,
|
||||
"byCommand": [
|
||||
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 66 },
|
||||
{ "key": "rev-parse --git-common-dir", "count": 58 },
|
||||
{ "key": "rev-parse --abbrev-ref HEAD", "count": 50 },
|
||||
{ "key": "rev-parse --git-dir", "count": 36 },
|
||||
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 35 },
|
||||
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 35 },
|
||||
{ "key": "config --get remote.origin.url", "count": 32 },
|
||||
{ "key": "ls-files --others --exclude-standard", "count": 18 },
|
||||
{ "key": "rev-parse --absolute-git-dir", "count": 18 },
|
||||
{ "key": "merge-base HEAD origin/main", "count": 17 },
|
||||
{ "key": "rev-parse --show-toplevel", "count": 14 },
|
||||
{ "key": "status --porcelain", "count": 14 }
|
||||
]
|
||||
},
|
||||
"process": {
|
||||
"cpuUserMs": 2009,
|
||||
"cpuSystemMs": 2428,
|
||||
"rssDeltaMb": -1.5,
|
||||
"heapUsedDeltaMb": 16.9
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### After: after change + snapshot-only sidebar
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": "snapshotOnly",
|
||||
"workspaceCount": 22,
|
||||
"elapsedMs": 31273,
|
||||
"git": {
|
||||
"total": 372,
|
||||
"failed": 15,
|
||||
"maxConcurrent": 8,
|
||||
"byCommand": [
|
||||
{ "key": "config --get remote.origin.url", "count": 35 },
|
||||
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 34 },
|
||||
{ "key": "rev-parse --git-common-dir", "count": 31 },
|
||||
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 22 },
|
||||
{ "key": "status --porcelain", "count": 22 },
|
||||
{ "key": "ls-files --others --exclude-standard", "count": 18 },
|
||||
{ "key": "rev-parse --absolute-git-dir", "count": 18 },
|
||||
{ "key": "merge-base HEAD origin/main", "count": 17 },
|
||||
{ "key": "rev-parse --abbrev-ref HEAD", "count": 17 },
|
||||
{ "key": "rev-parse --show-toplevel", "count": 17 },
|
||||
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 17 },
|
||||
{ "key": "rev-list --count main..origin/main", "count": 7 }
|
||||
]
|
||||
},
|
||||
"process": {
|
||||
"cpuUserMs": 1871,
|
||||
"cpuSystemMs": 2152,
|
||||
"rssDeltaMb": 4.4,
|
||||
"heapUsedDeltaMb": 8.8
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### After 2: shared service-level facts
|
||||
|
||||
```json
|
||||
{
|
||||
"scenario": "snapshotOnly",
|
||||
"workspaceCount": 22,
|
||||
"elapsedMs": 31334,
|
||||
"git": {
|
||||
"total": 308,
|
||||
"failed": 15,
|
||||
"maxConcurrent": 8,
|
||||
"byCommand": [
|
||||
{ "key": "show-ref --verify --quiet refs/heads/main", "count": 31 },
|
||||
{ "key": "rev-parse --git-common-dir", "count": 26 },
|
||||
{ "key": "show-ref --verify --quiet refs/remotes/origin/main", "count": 22 },
|
||||
{ "key": "ls-files --others --exclude-standard", "count": 18 },
|
||||
{ "key": "status --porcelain", "count": 18 },
|
||||
{ "key": "merge-base HEAD origin/main", "count": 17 },
|
||||
{ "key": "config --get remote.origin.url", "count": 13 },
|
||||
{ "key": "rev-parse --abbrev-ref HEAD", "count": 13 },
|
||||
{ "key": "rev-parse --absolute-git-dir", "count": 13 },
|
||||
{ "key": "rev-parse --show-toplevel", "count": 13 },
|
||||
{ "key": "symbolic-ref --quiet refs/remotes/origin/HEAD", "count": 13 },
|
||||
{ "key": "fetch origin --prune", "count": 5 }
|
||||
]
|
||||
},
|
||||
"process": {
|
||||
"cpuUserMs": 1817,
|
||||
"cpuSystemMs": 1869,
|
||||
"rssDeltaMb": 16.7,
|
||||
"heapUsedDeltaMb": 10.4
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Snapshot Equivalence Guard
|
||||
|
||||
Added a focused utility test proving that status, shortstat, and PR status return the same data when run from shared snapshot facts. The same test records git calls and asserts the facts-backed path does not re-run:
|
||||
|
||||
- `rev-parse --show-toplevel`
|
||||
- `rev-parse --abbrev-ref HEAD`
|
||||
|
||||
Test:
|
||||
|
||||
`packages/server/src/utils/checkout-git.test.ts` -> `reuses checkout snapshot facts across status, shortstat, and PR status reads`
|
||||
|
||||
## Remaining Waste Visible In Baseline
|
||||
|
||||
This pass reshaped the data flow and removed the sidebar PR badge special path. It did not try to optimize every command.
|
||||
|
||||
The benchmark still shows repeated per-workspace reads that are candidates for the next pass:
|
||||
|
||||
- base ref existence checks still repeat as `show-ref` probes.
|
||||
- default branch resolution still repeats `symbolic-ref refs/remotes/origin/HEAD`.
|
||||
- repo common-dir lookup is lower, but still above the apparent git workspace count.
|
||||
- shortstat still runs its own merge-base/diff/untracked scan per workspace.
|
||||
|
||||
The important invariant now is clearer: sidebar-visible git data should flow from `WorkspaceGitService` snapshots, and snapshot builders should receive reusable git facts through `CheckoutContext`.
|
||||
@@ -0,0 +1,389 @@
|
||||
# OpenCode Provider Snapshot Startup Timeout Diagnosis - 2026-05-27
|
||||
|
||||
## Answer
|
||||
|
||||
The startup timeout is real OpenCode provider snapshot work, not an agent resume path.
|
||||
|
||||
In the dev-style copied-home reproduction, the OpenCode snapshot misses the 30s budget because several expensive things stack:
|
||||
|
||||
1. Paseo starts from a copied `PASEO_HOME` containing 4,851 agent records.
|
||||
2. Clients ask for provider snapshots for three cwd scopes at almost the same time:
|
||||
- `/Users/moboudra`
|
||||
- `/Users/moboudra/dev/paseo`
|
||||
- `/Users/moboudra/dev/blankpage/editor`
|
||||
3. Each OpenCode snapshot runs two OpenCode SDK calls:
|
||||
- `GET /provider?directory=...` through `client.provider.list()`
|
||||
- `GET /agent?directory=...` through `client.app.agents()`
|
||||
4. One cold `opencode serve` process is shared by the three cwd scopes. It took 8.562s to become ready.
|
||||
5. After OpenCode was listening, Paseo issued six OpenCode HTTP calls concurrently.
|
||||
6. The OpenCode `/provider` responses are large: about 3,549,620 decompressed bytes per cwd.
|
||||
7. During the same window, the daemon was still doing heavy startup workspace git work. In the exact 18:14:19-18:14:43 window, the daemon log has 292 git spawn/close events.
|
||||
8. The `/provider` calls eventually succeeded, but too late: they completed about 32.2s-32.5s after the snapshot fetch started, while the snapshot timeout is 30s.
|
||||
|
||||
So the root cause is:
|
||||
|
||||
```text
|
||||
Cold OpenCode server startup + three concurrent cwd snapshots + large OpenCode /provider responses + daemon startup git contention causes client.provider.list() to complete after Paseo's 30s snapshot budget.
|
||||
```
|
||||
|
||||
More precise wording: the contention is machine-level process/CPU/filesystem contention created by daemon startup work, especially git work. It is not proven to be an OpenCode internal lock or a Paseo-only event-loop issue. A daemon-free repro with only OpenCode plus an external git storm slowed the same six OpenCode calls from about 1s to about 30s total.
|
||||
|
||||
Manual settings refresh works because it runs after startup contention is gone and uses `force: true`, which creates fresh OpenCode runtime/server state. The same OpenCode provider refreshes then complete in about 1.7s-2.2s.
|
||||
|
||||
The daemon does not auto-retry error snapshots. A failed provider snapshot is cached as `status: "error"` until an explicit refresh resets it to loading.
|
||||
|
||||
## Follow-up: Normal Copied-Home Startup Check
|
||||
|
||||
I later reran a normal dev-daemon startup against a fresh copy of the same Paseo home metadata and drove the app startup request path:
|
||||
|
||||
```text
|
||||
fetchWorkspaces
|
||||
fetchAgents
|
||||
getProvidersSnapshot(home scope)
|
||||
getProvidersSnapshot(first workspace scope)
|
||||
```
|
||||
|
||||
That run did not reproduce the 30s OpenCode timeout.
|
||||
|
||||
```text
|
||||
home scope:
|
||||
OpenCode ready at ~8s
|
||||
availability: 1.6s
|
||||
fetch total: 5.2s
|
||||
|
||||
first workspace scope:
|
||||
OpenCode ready at ~26s
|
||||
availability: 2.0s
|
||||
fetch total: 15.4s
|
||||
```
|
||||
|
||||
The slowest OpenCode operation in that successful run was the workspace-scoped `/provider` response body read: `13.6s`. The daemon log had no `Timed out refreshing OpenCode` entry and no OpenCode provider snapshot failure.
|
||||
|
||||
This means the timeout is reproducible under the heavier multi-scope startup contention captured below, but it is not guaranteed on every copied-home dev startup.
|
||||
|
||||
## Reproduction Used
|
||||
|
||||
The user's correction was right: the useful reproduction is not a random isolated home. It must match `dev.sh` worktree behavior.
|
||||
|
||||
Relevant scripts:
|
||||
|
||||
- `scripts/dev.sh`
|
||||
- `scripts/dev-daemon.sh`
|
||||
- `scripts/dev-home.sh`
|
||||
|
||||
`dev-home.sh` only seeds this metadata into the dev home:
|
||||
|
||||
```text
|
||||
agents/**/*.json
|
||||
projects/**/*.json
|
||||
config.json
|
||||
```
|
||||
|
||||
It does not copy `chat`, `loops`, `schedules`, sockets, pid files, logs, or worktree contents.
|
||||
|
||||
I ran a separate daemon, not the main daemon:
|
||||
|
||||
```text
|
||||
PASEO_HOME=/var/folders/xl/kkk9drfd3ms_t8x7rmy4z6900000gn/T/paseo-devseed.Wms6pi
|
||||
PASEO_LISTEN=127.0.0.1:51116
|
||||
PASEO_LOG_LEVEL=trace
|
||||
```
|
||||
|
||||
Startup facts:
|
||||
|
||||
```text
|
||||
18:13:39.552 Agent storage initialized: 712ms
|
||||
18:13:39.559 Workspace registries bootstrapped: 719ms
|
||||
18:13:39.961 Agent registry loaded: 4851 records
|
||||
18:13:39.972 Server listening: http://127.0.0.1:51116
|
||||
```
|
||||
|
||||
The probe then connected four client sessions and requested:
|
||||
|
||||
- workspaces
|
||||
- active agents
|
||||
- provider snapshots for home, paseo, and blankpage/editor
|
||||
|
||||
Client-visible result:
|
||||
|
||||
```text
|
||||
18:14:30.263 /Users/moboudra/dev/blankpage/editor opencode error:
|
||||
OpenCode app.agents timed out after 10s
|
||||
|
||||
18:14:41.687 /Users/moboudra/dev/paseo opencode error:
|
||||
Timed out refreshing OpenCode after 30000ms
|
||||
|
||||
18:14:41.688 /Users/moboudra opencode error:
|
||||
Timed out refreshing OpenCode after 30000ms
|
||||
```
|
||||
|
||||
## Exact OpenCode Timeline
|
||||
|
||||
OpenCode snapshot requests began at `18:14:10`.
|
||||
|
||||
Availability checks:
|
||||
|
||||
```text
|
||||
18:14:10.780 opencode availability start for /Users/moboudra
|
||||
18:14:10.787 opencode availability start for /Users/moboudra/dev/paseo
|
||||
18:14:10.800 opencode availability start for /Users/moboudra/dev/blankpage/editor
|
||||
|
||||
18:14:11.363 paseo availability complete: 576ms
|
||||
18:14:11.376 home availability complete: 597ms
|
||||
18:14:11.391 blankpage availability complete: 591ms
|
||||
```
|
||||
|
||||
OpenCode server acquisition:
|
||||
|
||||
```text
|
||||
18:14:11.364 OpenCode server spawn start: opencode serve --port 56376
|
||||
18:14:19.926 OpenCode server listening after 8562ms
|
||||
```
|
||||
|
||||
Six SDK calls were then issued:
|
||||
|
||||
```text
|
||||
18:14:19.931 GET /provider directory=/Users/moboudra/dev/paseo
|
||||
18:14:19.931 GET /agent directory=/Users/moboudra/dev/paseo
|
||||
18:14:19.931 GET /provider directory=/Users/moboudra
|
||||
18:14:19.931 GET /agent directory=/Users/moboudra
|
||||
18:14:19.931 GET /provider directory=/Users/moboudra/dev/blankpage/editor
|
||||
18:14:19.936 GET /agent directory=/Users/moboudra/dev/blankpage/editor
|
||||
```
|
||||
|
||||
Why six:
|
||||
|
||||
| Cwd | Why that scope exists | Model call | Mode call |
|
||||
| -------------------------------------- | ---------------------------------------------------------- | --------------------------------------- | --------------------------------- |
|
||||
| `/Users/moboudra` | home/settings provider snapshot | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
|
||||
| `/Users/moboudra/dev/paseo` | workspace-scoped provider snapshot for the Paseo workspace | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
|
||||
| `/Users/moboudra/dev/blankpage/editor` | workspace/agent cwd snapshot for blankpage/editor | `client.provider.list()` -> `/provider` | `client.app.agents()` -> `/agent` |
|
||||
|
||||
Multiple clients can request the same snapshot scope during startup, but non-forced provider loads are deduped by `(cwd, provider)`. Different cwd scopes are separate loads. Three cwd scopes times two OpenCode SDK calls each is the six OpenCode calls in this repro.
|
||||
|
||||
Headers arrived before the 30s timeout:
|
||||
|
||||
| Call | Cwd | Headers after request |
|
||||
| ----------- | ------------------------------- | --------------------- |
|
||||
| `/provider` | `/Users/moboudra` | 6.462s |
|
||||
| `/agent` | `/Users/moboudra` | 6.681s |
|
||||
| `/agent` | `/Users/moboudra/dev/paseo` | 6.681s |
|
||||
| `/provider` | `/Users/moboudra/dev/paseo` | 8.192s |
|
||||
| `/provider` | `/Users/moboudra/dev/blankpage` | 8.654s |
|
||||
| `/agent` | `/Users/moboudra/dev/blankpage` | 8.649s |
|
||||
|
||||
But body consumption and completion lagged:
|
||||
|
||||
```text
|
||||
18:14:29.380 /agent home complete, total app.agents duration 9450ms
|
||||
18:14:29.813 /agent paseo complete, total app.agents duration 9883ms
|
||||
18:14:30.263 /agent blankpage timed out at 10s
|
||||
18:14:31.332 /agent blankpage body finally finished, after the 10s app.agents timeout
|
||||
|
||||
18:14:41.687 paseo snapshot outer 30s timeout fires
|
||||
18:14:41.688 home snapshot outer 30s timeout fires
|
||||
|
||||
18:14:43.593 /provider home completes, provider.list duration 23664ms, total listModels 32218ms
|
||||
18:14:43.798 /provider blankpage completes, provider.list duration 23868ms, total listModels 32411ms
|
||||
18:14:43.839 /provider paseo completes, provider.list duration 23911ms, total listModels 32476ms
|
||||
```
|
||||
|
||||
The useful `/provider` results arrived about 1.9s-2.2s after the snapshot manager had already marked home and paseo as failed.
|
||||
|
||||
## Why Settings Refresh Works
|
||||
|
||||
After the daemon settled, I ran the same refresh path through the daemon on port `51116`, using `refreshProvidersSnapshot({ providers: ["opencode"] })`.
|
||||
|
||||
Results:
|
||||
|
||||
```text
|
||||
home refresh:
|
||||
total: 2165ms
|
||||
status: ready
|
||||
models: 409
|
||||
modes: 5
|
||||
|
||||
/Users/moboudra/dev/paseo refresh:
|
||||
total: 1675ms
|
||||
status: ready
|
||||
models: 409
|
||||
modes: 5
|
||||
|
||||
/Users/moboudra/dev/blankpage/editor refresh:
|
||||
total: 1794ms
|
||||
status: ready
|
||||
models: 409
|
||||
modes: 5
|
||||
```
|
||||
|
||||
Trace details for the manual-style refresh:
|
||||
|
||||
```text
|
||||
OpenCode server acquisition: 708ms-1291ms
|
||||
/agent completion: 433ms-592ms after request start
|
||||
/provider completion: 524ms-618ms after request start
|
||||
```
|
||||
|
||||
That proves the startup failure is not bad credentials, not a permanently wedged OpenCode install, and not OpenCode generally taking more than 30s. It is startup timing and contention.
|
||||
|
||||
## Minimal OpenCode-Only Repros
|
||||
|
||||
### OpenCode Only, No Daemon, No Artificial Load
|
||||
|
||||
I started a fresh `opencode serve`, waited for stdout `listening on`, then issued the same six HTTP calls concurrently:
|
||||
|
||||
```text
|
||||
GET /provider?directory=/Users/moboudra
|
||||
GET /agent?directory=/Users/moboudra
|
||||
GET /provider?directory=/Users/moboudra/dev/paseo
|
||||
GET /agent?directory=/Users/moboudra/dev/paseo
|
||||
GET /provider?directory=/Users/moboudra/dev/blankpage/editor
|
||||
GET /agent?directory=/Users/moboudra/dev/blankpage/editor
|
||||
```
|
||||
|
||||
Three runs:
|
||||
|
||||
| Run | `opencode serve` ready | All six calls complete |
|
||||
| --- | ---------------------- | ---------------------- |
|
||||
| 1 | 1376ms | 1295ms |
|
||||
| 2 | 906ms | 1050ms |
|
||||
| 3 | 939ms | 898ms |
|
||||
|
||||
Slowest individual call in those runs:
|
||||
|
||||
```text
|
||||
/provider /Users/moboudra/dev/paseo: 1270ms total
|
||||
/agent /Users/moboudra/dev/blankpage/editor: 1251ms total
|
||||
```
|
||||
|
||||
So six concurrent OpenCode calls alone are not the bug.
|
||||
|
||||
### OpenCode Only Plus External Git Storm, No Daemon
|
||||
|
||||
I then ran the same OpenCode-only six-call test while an external shell spawned repeated git commands across the same real workspaces/worktrees. This did not use the Paseo daemon.
|
||||
|
||||
Result:
|
||||
|
||||
```text
|
||||
opencode serve ready: 15479ms
|
||||
all six OpenCode calls complete: 15176ms after server ready
|
||||
combined cold-start + calls: about 30655ms
|
||||
```
|
||||
|
||||
Individual calls under the external git storm:
|
||||
|
||||
| Call | Cwd | Total |
|
||||
| ----------- | -------------------------------------- | ------: |
|
||||
| `/provider` | `/Users/moboudra` | 10684ms |
|
||||
| `/agent` | `/Users/moboudra` | 10767ms |
|
||||
| `/provider` | `/Users/moboudra/dev/paseo` | 13220ms |
|
||||
| `/agent` | `/Users/moboudra/dev/paseo` | 13147ms |
|
||||
| `/provider` | `/Users/moboudra/dev/blankpage/editor` | 14675ms |
|
||||
| `/agent` | `/Users/moboudra/dev/blankpage/editor` | 15038ms |
|
||||
|
||||
This is the daemon-free minimal evidence that process/filesystem contention can push the same OpenCode cold-start + six-call workload to the same 30s boundary.
|
||||
|
||||
## Why It Does Not Retry
|
||||
|
||||
`ProviderSnapshotManager.getSnapshot()` only starts background warmup for:
|
||||
|
||||
- no existing snapshot
|
||||
- missing providers
|
||||
- entries still in `loading` with no active load
|
||||
|
||||
When refresh fails, `refreshProvider()` stores:
|
||||
|
||||
```text
|
||||
status: "error"
|
||||
error: "Timed out refreshing OpenCode after 30000ms"
|
||||
```
|
||||
|
||||
An `error` entry is not treated as stale/loading by `getSnapshot()`, so normal reads keep returning the cached error.
|
||||
|
||||
Settings refresh calls `refresh_providers_snapshot_request`, which routes to:
|
||||
|
||||
```text
|
||||
refreshSettingsSnapshot()
|
||||
clearCachedProviders()
|
||||
resetSnapshotToLoading()
|
||||
refreshProviders(... force: true)
|
||||
```
|
||||
|
||||
That is why you have to force a manual refresh.
|
||||
|
||||
## Git Work During The Repro
|
||||
|
||||
This is not the final optimization report, but it matters for the timeout because it overlaps exactly with OpenCode response handling.
|
||||
|
||||
Total git commands in the dev-style copied-home daemon log:
|
||||
|
||||
```text
|
||||
632 spawned
|
||||
632 closed
|
||||
```
|
||||
|
||||
Top cwd counts:
|
||||
|
||||
| Count | Cwd |
|
||||
| ----: | ------------------------------------------------------------------------------------- |
|
||||
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/merry-ladybug` |
|
||||
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/hopeful-eel` |
|
||||
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-compaction-cancel-loading` |
|
||||
| 44 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-archive-worktree-session-history` |
|
||||
| 44 | `/Users/moboudra/.paseo/worktrees/0vpo9h4b/breezy-toad` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/steering-policy-refactor-detached` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/integration-session-mcp-command-stack` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/fix-provider-diagnostic-binary-resolution` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/feat-voice-runtime-on-demand` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/feat-find-in-pane` |
|
||||
| 36 | `/Users/moboudra/.paseo/worktrees/1luy0po7/epic-paseo-client-sdk` |
|
||||
| 24 | `/Users/moboudra/dev/paseo` |
|
||||
| 24 | `/Users/moboudra/dev/blankpage/editor` |
|
||||
| 24 | `/Users/moboudra/dev/faro/main` |
|
||||
| 24 | `/Users/moboudra/dev/konbert/web` |
|
||||
| 24 | `/Users/moboudra/dev/paseo-cloud` |
|
||||
|
||||
In the exact OpenCode pressure window, `18:14:19` through `18:14:43`, there were:
|
||||
|
||||
```text
|
||||
142 git command spawns
|
||||
150 git command closes
|
||||
```
|
||||
|
||||
The main repeated command shapes were:
|
||||
|
||||
```text
|
||||
76 git rev-parse --show-toplevel
|
||||
72 git status --porcelain
|
||||
72 git show-ref --verify --quiet refs/remotes/origin/main
|
||||
72 git show-ref --verify --quiet refs/heads/main
|
||||
16 git config --get branch.main.remote
|
||||
16 git config --get branch.main.merge
|
||||
16 git rev-list --count main..origin/main
|
||||
16 git rev-list --count origin/main..main
|
||||
```
|
||||
|
||||
## Original `log.txt` Alignment
|
||||
|
||||
The original startup showed the same home and paseo outer timeout shape:
|
||||
|
||||
```text
|
||||
16:04:22.466 /Users/moboudra/dev/paseo:
|
||||
Timed out refreshing OpenCode after 30000ms
|
||||
|
||||
16:04:22.482 /Users/moboudra:
|
||||
Timed out refreshing OpenCode after 30000ms
|
||||
```
|
||||
|
||||
The original logs did not include SDK fetch/header/body timing, so they could only show the wrapper-level timeout. The dev-style copied-home reproduction with instrumentation now shows the missing link: the `/provider` calls completed just after the 30s snapshot budget.
|
||||
|
||||
## Files Instrumented For Diagnosis
|
||||
|
||||
Temporary trace instrumentation was added to:
|
||||
|
||||
- `packages/server/src/server/agent/provider-snapshot-manager.ts`
|
||||
- `packages/server/src/server/agent/providers/opencode-agent.ts`
|
||||
- `packages/server/src/server/agent/providers/opencode/runtime.ts`
|
||||
- `packages/server/src/server/agent/providers/opencode/server-manager.ts`
|
||||
|
||||
The instrumentation is behavior-neutral and only emits trace logs.
|
||||
381
docs/diagnostics/startup-sequence-analysis-2026-05-27.md
Normal file
381
docs/diagnostics/startup-sequence-analysis-2026-05-27.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# Daemon Startup Sequence Analysis - 2026-05-27
|
||||
|
||||
Source log: `log.txt` at repository root.
|
||||
|
||||
Scope: current sliced startup log, starting at daemon worker startup and ending after workspace registry reconciliation and the first OpenCode heartbeat.
|
||||
|
||||
This report is descriptive only. It does not propose optimizations.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The daemon becomes ready quickly, then does a heavy post-listen startup pass driven by reconnecting clients and workspace/app hydration.
|
||||
|
||||
- Worker start: `16:03:46.678`, line 1.
|
||||
- Server listening: `16:03:48.285`, line 47, elapsed `602ms`.
|
||||
- First client hello: `16:03:50.285`, line 66.
|
||||
- Workspace registries reconciled: `16:04:33.666`, line 1777, elapsed `45983ms`.
|
||||
|
||||
The startup shape is therefore:
|
||||
|
||||
- Daemon listen readiness: about `0.6s`.
|
||||
- Client reconnect plus workspace/app/provider hydration: about `45s`.
|
||||
- No git commands after workspace registry reconciliation in this slice.
|
||||
|
||||
## Method
|
||||
|
||||
I parsed structured trace lines from `log.txt`, especially:
|
||||
|
||||
- `Git command closed`
|
||||
- `agent.session.inbound`
|
||||
- `agent.session.outbound`
|
||||
- `ws_slow_request`
|
||||
- provider snapshot warnings
|
||||
- provider resume events
|
||||
|
||||
Important limitation: git command logs do not carry a websocket request id, so per-request attribution is inferred from timing and server code paths. Per-workspace git counts, command shapes, durations, and failures are exact for this log.
|
||||
|
||||
Relevant code paths checked:
|
||||
|
||||
- `packages/server/src/server/session.ts`
|
||||
- `fetch_workspaces_request` calls `syncWorkspaceGitObservers(payload.entries)`.
|
||||
- `checkout_status_request` calls `workspaceGitService.getSnapshot(resolvedCwd)`.
|
||||
- `checkout_pr_status_request` calls `workspaceGitService.getSnapshot(cwd)`.
|
||||
- `packages/server/src/server/workspace-git-service.ts`
|
||||
- checkout snapshot/root resolution uses `git rev-parse --show-toplevel`.
|
||||
- snapshot refresh collects dirty state, upstream/ahead/behind, ref existence, and base divergence.
|
||||
- `packages/app/src/contexts/session-context.tsx`
|
||||
- initial workspace hydration calls `client.fetchWorkspaces({ sort: activity_at desc, subscribe, page limit 200 })`.
|
||||
- `packages/app/src/hooks/use-sidebar-workspaces-list.ts`
|
||||
- sidebar workspace refresh also calls `client.fetchWorkspaces({ sort: activity_at desc, page limit 200 })`.
|
||||
|
||||
## Startup Timeline
|
||||
|
||||
| time | line | event |
|
||||
| -------------- | ---: | ------------------------------------------------------------------ |
|
||||
| `16:03:46.678` | 1 | `DaemonRunner` starts daemon worker |
|
||||
| `16:03:47.683` | 4 | worker spawned |
|
||||
| `16:03:47.684` | 6 | daemon keypair loaded |
|
||||
| `16:03:48.281` | 44 | bootstrap complete, ready to listen |
|
||||
| `16:03:48.285` | 47 | server listening on `0.0.0.0:6767` |
|
||||
| `16:03:50.274` | 60 | first websocket awaiting hello |
|
||||
| `16:03:50.285` | 66 | first client connected via hello |
|
||||
| `16:04:22.466` | 987 | OpenCode provider snapshot timeout for `/Users/moboudra/dev/paseo` |
|
||||
| `16:04:22.482` | 1002 | OpenCode provider snapshot timeout for `/Users/moboudra` |
|
||||
| `16:04:24.183` | 1201 | OpenCode provider subscribe starts |
|
||||
| `16:04:24.183` | 1202 | OpenCode provider subscribe ready |
|
||||
| `16:04:24.306` | 1214 | OpenCode server connected event |
|
||||
| `16:04:25.933` | 1321 | OpenCode agent resumed from persistence |
|
||||
| `16:04:33.666` | 1777 | workspace registries reconciled |
|
||||
| `16:04:34.197` | 1783 | OpenCode heartbeat |
|
||||
| `16:04:44.200` | 1789 | OpenCode heartbeat |
|
||||
|
||||
## Git Command Totals
|
||||
|
||||
Total git commands in the sliced startup: `444`.
|
||||
|
||||
| phase | commands | failures | summed process time |
|
||||
| ------------------------------- | -------: | -------: | ------------------: |
|
||||
| daemon bootstrap before listen | 13 | 4 | 445ms |
|
||||
| post-listen before first client | 1 | 0 | 2020ms |
|
||||
| client reconnect + reconcile | 430 | 71 | 120813ms |
|
||||
| after reconcile | 0 | 0 | 0ms |
|
||||
| total | 444 | 75 | 123278ms |
|
||||
|
||||
Summed process time is not wall-clock time. Many commands overlap.
|
||||
|
||||
## Git Command Categories
|
||||
|
||||
| category | commands | failures | summed process time | max duration |
|
||||
| ---------------------------------------------------- | -------: | -------: | ------------------: | -----------: |
|
||||
| ahead/behind: `rev-list --count ...` | 115 | 30 | 35815ms | 1557ms |
|
||||
| refs: `show-ref --verify --quiet ...` | 86 | 2 | 14680ms | 1303ms |
|
||||
| upstream config: `config --get branch.*` | 85 | 13 | 26437ms | 1624ms |
|
||||
| root detection: `rev-parse --show-toplevel` | 80 | 30 | 24164ms | 1426ms |
|
||||
| dirty status: `status --porcelain` | 50 | 0 | 12670ms | 2020ms |
|
||||
| base divergence: `rev-list --left-right --count ...` | 28 | 0 | 9512ms | 1085ms |
|
||||
|
||||
What those categories mean in the app:
|
||||
|
||||
- Root detection: determine whether a cwd is inside a git repo and find its checkout root.
|
||||
- Dirty status: show dirty/clean workspace state.
|
||||
- Upstream config and ahead/behind: show branch tracking and sync state.
|
||||
- Ref existence and base divergence: compare checkout branch against candidate base refs for checkout/PR status.
|
||||
|
||||
## Per-Workspace Git Work
|
||||
|
||||
Columns:
|
||||
|
||||
- `phase`: `pre/warm/reconnect/after`
|
||||
- `cats`: `root/dirty/upstream/ahead/refs/base/other`
|
||||
- `total_ms`: summed process time for that workspace
|
||||
|
||||
| workspace | cmds | fail | phase | cats | total_ms | max_ms | window | failing command shapes |
|
||||
| ----------------------------------------------------------------------- | ---: | ---: | ---------- | ----------------- | -------: | -----: | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `~/.paseo/worktrees/1luy0po7/fix-compaction-cancel-loading` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 8255 | 1460 | `16:03:52.908-16:04:24.019` | `3x config --get branch.fix-compaction-cancel-loading.remote`; `3x rev-list --count fix-compaction-cancel-loading..origin/fix-compaction-cancel-loading`; `3x rev-list --count origin/fix-compaction-cancel-loading..fix-compaction-cancel-loading` |
|
||||
| `~/.paseo/worktrees/1luy0po7/hopeful-eel` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 8468 | 1544 | `16:03:53.245-16:04:27.334` | `3x config --get branch.feat/markdown-annotations.remote`; `3x rev-list --count feat/markdown-annotations..origin/feat/markdown-annotations`; `3x rev-list --count origin/feat/markdown-annotations..feat/markdown-annotations` |
|
||||
| `~/.paseo/worktrees/1luy0po7/merry-ladybug` | 33 | 9 | `0/0/33/0` | `3/3/3/9/12/3/0` | 7154 | 1099 | `16:03:53.696-16:04:29.644` | `3x config --get branch.feat/mcp-configuration.remote`; `3x rev-list --count feat/mcp-configuration..origin/feat/mcp-configuration`; `3x rev-list --count origin/feat/mcp-configuration..feat/mcp-configuration` |
|
||||
| `~/dev/paseo` | 30 | 0 | `2/0/28/0` | `5/5/10/10/0/0/0` | 7457 | 1624 | `16:03:48.171-16:04:27.284` | |
|
||||
| `~/.paseo/worktrees/0vpo9h4b/dazzling-duck` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7617 | 1269 | `16:03:51.918-16:04:23.971` | |
|
||||
| `~/.paseo/worktrees/1luy0po7/epic-paseo-client-sdk` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7428 | 1426 | `16:03:52.445-16:04:23.991` | |
|
||||
| `~/.paseo/worktrees/1luy0po7/fix-provider-diagnostic-binary-resolution` | 27 | 0 | `0/0/27/0` | `3/3/6/6/6/3/0` | 7764 | 1091 | `16:03:52.681-16:04:23.971` | |
|
||||
| `~/dev/emdash` | 22 | 6 | `0/0/22/0` | `2/2/2/6/8/2/0` | 3031 | 453 | `16:04:27.351-16:04:29.583` | `2x config --get branch.heads/main.remote`; `2x rev-list --count heads/main..origin/heads/main`; `2x rev-list --count origin/heads/main..heads/main` |
|
||||
| `~/dev/opencode` | 22 | 4 | `0/0/22/0` | `2/2/2/6/8/2/0` | 2279 | 313 | `16:04:24.058-16:04:24.970` | `2x rev-list --count ecosystem-paseo..origin/ecosystem-paseo`; `2x rev-list --count origin/ecosystem-paseo..ecosystem-paseo` |
|
||||
| `~/.paseo/worktrees/1luy0po7/integration-session-mcp-command-stack` | 18 | 0 | `0/0/18/0` | `3/3/6/6/0/0/0` | 7467 | 1242 | `16:03:53.781-16:04:23.971` | |
|
||||
| `~/dev/blankpage/editor` | 18 | 0 | `2/0/16/0` | `3/3/6/6/0/0/0` | 2418 | 520 | `16:03:48.174-16:04:26.411` | |
|
||||
| `~/dev/konbert/web` | 18 | 0 | `1/1/16/0` | `3/3/6/6/0/0/0` | 7324 | 2020 | `16:03:48.190-16:04:23.685` | |
|
||||
| `~/dev/openchamber` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 1554 | 336 | `16:04:27.399-16:04:29.616` | |
|
||||
| `~/dev/superset` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 1019 | 215 | `16:04:27.341-16:04:29.617` | |
|
||||
| `~/dev/t3code` | 12 | 0 | `0/0/12/0` | `2/2/4/4/0/0/0` | 2761 | 588 | `16:04:27.356-16:04:29.603` | |
|
||||
| `~/.paseo/worktrees/0vpo9h4b/breezy-toad` | 11 | 5 | `0/0/11/0` | `1/1/1/3/4/1/0` | 6465 | 1290 | `16:03:51.307-16:04:18.112` | `1x config --get branch.fix/user-delete-dark-mode.remote`; `1x rev-list --count fix/user-delete-dark-mode..origin/fix/user-delete-dark-mode`; `1x rev-list --count origin/fix/user-delete-dark-mode..fix/user-delete-dark-mode`; `2x show-ref --verify --quiet refs/remotes/origin/my-branch` |
|
||||
| `~/.paseo/worktrees/1luy0po7/fix-archive-worktree-session-history` | 11 | 3 | `0/0/11/0` | `1/1/1/3/4/1/0` | 4303 | 757 | `16:03:52.539-16:04:19.011` | `1x config --get branch.fix-archive-worktree-session-history.remote`; `1x rev-list --count fix-archive-worktree-session-history..origin/fix-archive-worktree-session-history`; `1x rev-list --count origin/fix-archive-worktree-session-history..fix-archive-worktree-session-history` |
|
||||
| `~/.paseo/worktrees/0vpo9h4b/codex-github-mention-implement-db-garbage` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 4986 | 1005 | `16:03:51.261-16:04:16.878` | |
|
||||
| `~/.paseo/worktrees/1luy0po7/feat-find-in-pane` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5273 | 1130 | `16:03:52.391-16:04:17.682` | |
|
||||
| `~/.paseo/worktrees/1luy0po7/feat-voice-runtime-on-demand` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5176 | 839 | `16:03:52.110-16:04:18.254` | |
|
||||
| `~/.paseo/worktrees/steering-policy-refactor-detached` | 9 | 0 | `0/0/9/0` | `1/1/2/2/2/1/0` | 5993 | 1243 | `16:03:53.984-16:04:17.673` | |
|
||||
| `~/dev/faro/main` | 6 | 0 | `2/0/4/0` | `1/1/2/2/0/0/0` | 4964 | 1603 | `16:03:48.168-16:04:03.748` | |
|
||||
| `~/dev/paseo-cloud` | 6 | 0 | `2/0/4/0` | `1/1/2/2/0/0/0` | 2123 | 1154 | `16:03:48.159-16:03:56.377` | |
|
||||
| `~/dev/assistant` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 85 | 29 | `16:03:48.165-16:04:24.048` | `3x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/review` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 224 | 105 | `16:03:48.197-16:04:26.560` | `3x rev-parse --show-toplevel` |
|
||||
| `~/dev/research/orchestrator-worker` | 3 | 3 | `1/0/2/0` | `3/0/0/0/0/0/0` | 285 | 144 | `16:03:48.194-16:04:26.575` | `3x rev-parse --show-toplevel` |
|
||||
| `/tmp` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 113 | 77 | `16:04:27.388-16:04:27.471` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 86 | 58 | `16:04:27.384-16:04:27.457` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/01-claude-opus` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 216 | 185 | `16:04:26.525-16:04:26.543` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/02-codex-gpt55` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 98 | 68 | `16:04:26.353-16:04:26.554` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/03-opencode-zai-glm51` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 209 | 158 | `16:04:26.512-16:04:26.576` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/04-opencode-zen-minimax27` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 148 | 101 | `16:04:26.431-16:04:26.549` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/05-opencode-zen-kimi26` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 231 | 172 | `16:04:26.517-16:04:26.577` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/06-opencode-or-deepseek4pro` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 93 | 73 | `16:04:27.365-16:04:27.380` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/07-opencode-zen-gemini35flash` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 78 | 66 | `16:04:27.363-16:04:27.375` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/benchmark/dashboard-2026-05-25/08-opencode-zen-gpt55` | 2 | 2 | `0/0/2/0` | `2/0/0/0/0/0/0` | 120 | 65 | `16:04:27.359-16:04:27.430` | `2x rev-parse --show-toplevel` |
|
||||
| `~/dev/assistant/game` | 1 | 1 | `1/0/0/0` | `1/0/0/0/0/0/0` | 13 | 13 | `16:03:48.155-16:03:48.155` | `1x rev-parse --show-toplevel` |
|
||||
|
||||
## Git Failure Shape
|
||||
|
||||
There were 75 nonzero git exits.
|
||||
|
||||
Most failures were not timeouts. They were expected probe failures:
|
||||
|
||||
- Non-repo checks: `rev-parse --show-toplevel` fails for paths that are not git repositories.
|
||||
- Missing upstream config: `config --get branch.<branch>.remote` fails for branches without configured upstream.
|
||||
- Missing remote branch graph: `rev-list --count <branch>..origin/<branch>` fails when the remote branch/ref does not exist.
|
||||
- Missing ref checks: `show-ref --verify --quiet refs/remotes/origin/my-branch` fails when a candidate ref does not exist.
|
||||
|
||||
The `~/dev/opencode` git failures are branch graph probes for `ecosystem-paseo` versus `origin/ecosystem-paseo`, not OpenCode provider startup failures.
|
||||
|
||||
## Inbound Client Work
|
||||
|
||||
Inbound session messages during the startup window:
|
||||
|
||||
| request | count |
|
||||
| --------------------------------- | ----: |
|
||||
| `client_heartbeat` | 19 |
|
||||
| `checkout_pr_status_request` | 18 |
|
||||
| `fetch_agents_request` | 11 |
|
||||
| `fetch_workspaces_request` | 9 |
|
||||
| `get_providers_snapshot_request` | 9 |
|
||||
| `project_icon_request` | 9 |
|
||||
| `fetch_agent_timeline_request` | 7 |
|
||||
| `clear_agent_attention` | 6 |
|
||||
| `list_terminals_request` | 5 |
|
||||
| `subscribe_terminals_request` | 5 |
|
||||
| `list_available_editors_request` | 2 |
|
||||
| `subscribe_checkout_diff_request` | 2 |
|
||||
| `checkout_status_request` | 1 |
|
||||
| `fetch_agent_request` | 1 |
|
||||
| `file_explorer_request` | 1 |
|
||||
| `read_project_config_request` | 1 |
|
||||
| `workspace_setup_status_request` | 1 |
|
||||
|
||||
Inbound by client:
|
||||
|
||||
| client | count | top work |
|
||||
| ----------------------------------------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Electron `cid_d555...`, origin `http://localhost:8082` | 68 | `checkout_pr_status_request:18`, `project_icon_request:9`, `clear_agent_attention:6`, `fetch_agent_timeline_request:4`, `fetch_workspaces_request:3`, `fetch_agents_request:3`, `get_providers_snapshot_request:3` |
|
||||
| HeadlessChrome `cid_d39...`, origin `http://localhost:8081` | 13 | `client_heartbeat:4`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
|
||||
| local web `cid_a2b...`, origin `http://localhost:6767` | 13 | `client_heartbeat:4`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
|
||||
| Android `cid_24c...`, origin `http://10.0.2.2:6767` | 11 | `client_heartbeat:2`, `fetch_workspaces_request:2`, `fetch_agents_request:2`, `get_providers_snapshot_request:2` |
|
||||
| `cid_70d...`, host `0.0.0.0:6767` | 2 | `fetch_agents_request:2` |
|
||||
|
||||
## Outbound Client Work
|
||||
|
||||
Outbound session messages during the startup window:
|
||||
|
||||
| message | count |
|
||||
| ---------------------------------- | ----: |
|
||||
| `providers_snapshot_update` | 129 |
|
||||
| `workspace_update` | 81 |
|
||||
| `checkout_status_update` | 76 |
|
||||
| `agent_update` | 47 |
|
||||
| `checkout_pr_status_response` | 18 |
|
||||
| `fetch_agents_response` | 11 |
|
||||
| `fetch_workspaces_response` | 9 |
|
||||
| `get_providers_snapshot_response` | 9 |
|
||||
| `project_icon_response` | 9 |
|
||||
| `fetch_agent_timeline_response` | 7 |
|
||||
| `list_terminals_response` | 5 |
|
||||
| `terminals_changed` | 5 |
|
||||
| `list_available_editors_response` | 2 |
|
||||
| `subscribe_checkout_diff_response` | 2 |
|
||||
| `checkout_status_response` | 1 |
|
||||
| `fetch_agent_response` | 1 |
|
||||
| `file_explorer_response` | 1 |
|
||||
| `read_project_config_response` | 1 |
|
||||
| `workspace_setup_status_response` | 1 |
|
||||
|
||||
Provider snapshot updates were large and repeated:
|
||||
|
||||
- Around lines 982-986: five `providers_snapshot_update` messages, each `215932` bytes.
|
||||
- Around lines 997-1001: five `providers_snapshot_update` messages, each `215898` bytes.
|
||||
- Around lines 1250-1254: five `providers_snapshot_update` messages, each `414735` bytes.
|
||||
|
||||
## Slow Requests
|
||||
|
||||
Slow requests logged during startup:
|
||||
|
||||
| time | request | duration | client | line |
|
||||
| -------------- | --------------------------------: | -------: | --------------------- | ---: |
|
||||
| `16:04:29.702` | `fetch_agent_timeline_request` | 39372ms | HeadlessChrome | 1767 |
|
||||
| `16:04:29.702` | `fetch_agent_timeline_request` | 39212ms | Electron | 1768 |
|
||||
| `16:04:29.702` | `fetch_agent_timeline_request` | 38914ms | local web | 1769 |
|
||||
| `16:04:33.665` | `checkout_pr_status_request` | 20181ms | Electron | 1776 |
|
||||
| `16:04:08.109` | `subscribe_checkout_diff_request` | 17618ms | Electron | 565 |
|
||||
| `16:04:29.702` | `fetch_agent_timeline_request` | 16216ms | Electron | 1770 |
|
||||
| `16:04:06.624` | `fetch_agent_timeline_request` | 16134ms | Electron | 524 |
|
||||
| `16:04:29.396` | `checkout_pr_status_request` | 15911ms | Electron | 1671 |
|
||||
| `16:04:29.256` | `checkout_pr_status_request` | 15772ms | Electron | 1651 |
|
||||
| `16:04:29.149` | `checkout_pr_status_request` | 15665ms | Electron | 1638 |
|
||||
| `16:04:29.054` | `checkout_pr_status_request` | 15569ms | Electron | 1628 |
|
||||
| `16:04:28.932` | `checkout_pr_status_request` | 15448ms | Electron | 1611 |
|
||||
| `16:04:28.809` | `checkout_pr_status_request` | 15324ms | Electron | 1601 |
|
||||
| `16:04:28.672` | `checkout_pr_status_request` | 15188ms | Electron | 1582 |
|
||||
| `16:04:28.555` | `checkout_pr_status_request` | 15071ms | Electron | 1567 |
|
||||
| `16:04:28.421` | `checkout_pr_status_request` | 14936ms | Electron | 1556 |
|
||||
| `16:04:28.323` | `checkout_pr_status_request` | 14839ms | Electron | 1549 |
|
||||
| `16:04:28.324` | `checkout_status_request` | 14839ms | Electron | 1550 |
|
||||
| `16:04:28.189` | `checkout_pr_status_request` | 14705ms | Electron | 1536 |
|
||||
| `16:04:29.634` | `fetch_agents_request` | 14590ms | `0.0.0.0:6767` client | 1759 |
|
||||
| `16:04:28.006` | `checkout_pr_status_request` | 14522ms | Electron | 1526 |
|
||||
| `16:04:27.628` | `checkout_pr_status_request` | 14143ms | Electron | 1496 |
|
||||
| `16:04:27.061` | `checkout_pr_status_request` | 13576ms | Electron | 1405 |
|
||||
| `16:04:29.645` | `fetch_agents_request` | 13384ms | `0.0.0.0:6767` client | 1762 |
|
||||
| `16:04:02.812` | `fetch_agent_timeline_request` | 12321ms | Electron | 440 |
|
||||
| `16:04:25.740` | `checkout_pr_status_request` | 12256ms | Electron | 1309 |
|
||||
| `16:04:25.352` | `checkout_pr_status_request` | 11867ms | Electron | 1296 |
|
||||
| `16:04:04.217` | `fetch_agent_timeline_request` | 11751ms | Android | 462 |
|
||||
| `16:04:25.155` | `checkout_pr_status_request` | 11671ms | Electron | 1284 |
|
||||
| `16:04:23.196` | `fetch_agent_request` | 9711ms | Electron | 1070 |
|
||||
| `16:04:17.563` | `project_icon_request` | 4079ms | Electron | 877 |
|
||||
| `16:03:53.022` | `list_available_editors_request` | 2533ms | Electron | 254 |
|
||||
| `16:04:15.703` | `project_icon_request` | 2218ms | Electron | 824 |
|
||||
| `16:04:15.696` | `project_icon_request` | 2211ms | Electron | 822 |
|
||||
| `16:04:15.694` | `project_icon_request` | 2209ms | Electron | 820 |
|
||||
| `16:04:15.103` | `file_explorer_request` | 1618ms | Electron | 806 |
|
||||
| `16:04:14.107` | `list_terminals_request` | 621ms | Electron | 764 |
|
||||
| `16:03:50.945` | `list_terminals_request` | 614ms | HeadlessChrome | 156 |
|
||||
|
||||
The checkout PR requests are especially clustered: 18 Electron `checkout_pr_status_request` messages arrive together at `16:04:13.484`, lines 699-716. Their slow-request completions drain over the next ~20s, with `inflightRequests` dropping from 20 to 0.
|
||||
|
||||
## Provider Findings
|
||||
|
||||
### OpenCode
|
||||
|
||||
OpenCode provider snapshot refresh had two timeouts:
|
||||
|
||||
| time | line | cwd | error |
|
||||
| -------------- | ---: | --------------------------- | --------------------------------------------- |
|
||||
| `16:04:22.466` | 987 | `/Users/moboudra/dev/paseo` | `Timed out refreshing OpenCode after 30000ms` |
|
||||
| `16:04:22.482` | 1002 | `/Users/moboudra` | `Timed out refreshing OpenCode after 30000ms` |
|
||||
|
||||
These are provider snapshot failures, not OpenCode agent resume failures.
|
||||
|
||||
The persisted OpenCode agent did resume:
|
||||
|
||||
| time | line | event |
|
||||
| -------------- | ---: | ----------------------------------------------------- |
|
||||
| `16:04:24.183` | 1201 | `provider.opencode.subscribe.start` |
|
||||
| `16:04:24.183` | 1202 | `provider.opencode.subscribe.ready` |
|
||||
| `16:04:24.306` | 1214 | raw event `server.connected` |
|
||||
| `16:04:25.933` | 1321 | `Agent resumed from persistence`, provider `opencode` |
|
||||
| `16:04:34.197` | 1783 | raw event `server.heartbeat` |
|
||||
| `16:04:44.200` | 1789 | raw event `server.heartbeat` |
|
||||
|
||||
There are no `provider.opencode.subscribe.error` or OpenCode agent fatal errors in this slice.
|
||||
|
||||
OpenCode-related git:
|
||||
|
||||
- `~/dev/opencode` had 22 git commands.
|
||||
- Four failed.
|
||||
- The failed commands were branch graph probes for `ecosystem-paseo` versus `origin/ecosystem-paseo`.
|
||||
- Those failures are git state/probe failures, not OpenCode provider process failures.
|
||||
|
||||
### Codex
|
||||
|
||||
Codex provider startup observations:
|
||||
|
||||
- `provider.codex.spawn` appears multiple times for provider snapshot/config discovery.
|
||||
- A persisted Codex agent resumes successfully at `16:04:06.357`, line 518.
|
||||
- Debug logs show failed reads of Codex saved config defaults, but these are debug-level and do not become provider startup warnings/errors in this slice.
|
||||
- There are unhandled Codex trace event types such as remote-control/status and thread/goal status, but no Codex timeout or fatal provider startup failure in this slice.
|
||||
|
||||
### Claude
|
||||
|
||||
Claude agents resume successfully:
|
||||
|
||||
| time | line | client | agent |
|
||||
| -------------- | ---: | -------- | -------------------------------------- |
|
||||
| `16:04:02.540` | 434 | Electron | `f884552a-1383-4dba-8583-7ae0b6a62353` |
|
||||
| `16:04:03.772` | 456 | Android | `0c89a057-05f2-4e23-9895-84c8e1952310` |
|
||||
|
||||
## What Work The App Asked For
|
||||
|
||||
The startup work visible in the app/server protocol is:
|
||||
|
||||
- Workspace list/sidebar hydration:
|
||||
- `fetch_workspaces_request`, 9 total.
|
||||
- This asks for the workspace list sorted by `activity_at desc`, usually page limit 200.
|
||||
- On the server this triggers workspace git observer sync and workspace update flushing.
|
||||
|
||||
- Agent list and agent detail hydration:
|
||||
- `fetch_agents_request`, 11 total.
|
||||
- `fetch_agent_request`, 1 total.
|
||||
- `fetch_agent_timeline_request`, 7 total.
|
||||
- Timeline requests are among the slowest requests in this slice.
|
||||
|
||||
- Checkout/PR status UI:
|
||||
- `checkout_pr_status_request`, 18 total, all Electron.
|
||||
- `checkout_status_request`, 1 total.
|
||||
- `subscribe_checkout_diff_request`, 2 total.
|
||||
- These correspond to git snapshot consumers and are clustered during Electron reconnect.
|
||||
|
||||
- Provider/model/mode UI:
|
||||
- `get_providers_snapshot_request`, 9 total.
|
||||
- `providers_snapshot_update`, 129 outbound updates.
|
||||
- OpenCode provider snapshot refresh times out twice during this flow.
|
||||
|
||||
- Workspace chrome:
|
||||
- `project_icon_request`, 9 total.
|
||||
- `file_explorer_request`, 1 total.
|
||||
|
||||
- Terminal panel:
|
||||
- `list_terminals_request`, 5 total.
|
||||
- `subscribe_terminals_request`, 5 total.
|
||||
- `terminals_changed`, 5 outbound updates.
|
||||
|
||||
- Attention state:
|
||||
- `clear_agent_attention`, 6 total.
|
||||
- Some failures appear while clearing attention for persisted agents, but these are not provider startup failures.
|
||||
|
||||
## Concrete Waste-Looking Work, Without Optimizing Yet
|
||||
|
||||
The log shows repeated work in these exact forms:
|
||||
|
||||
- 444 git commands total, but only 14 complete before the first client hello. The rest are post-listen startup/client hydration work.
|
||||
- Several workspaces get repeated full checkout snapshot patterns:
|
||||
- three 33-command worktrees each get `3` root checks, `3` dirty checks, `3` upstream config probes, `9` ahead/behind probes, `12` ref checks, and `3` base divergence checks.
|
||||
- three 27-command worktrees each get `3` root checks, `3` dirty checks, `6` upstream config probes, `6` ahead/behind probes, `6` ref checks, and `3` base divergence checks.
|
||||
- `~/dev/paseo` gets `5` root checks, `5` dirty checks, `10` upstream config probes, and `10` ahead/behind probes.
|
||||
- Electron sends 18 `checkout_pr_status_request` messages at the same timestamp, then they drain slowly over ~20s.
|
||||
- Provider snapshot updates are broadcast very frequently: 129 outbound `providers_snapshot_update` messages, including large repeated payloads around 216KB and 415KB.
|
||||
- OpenCode snapshot refresh times out twice after 30s, but the actual OpenCode agent connection/resume succeeds.
|
||||
|
||||
Again, this section names repeated work observed in the startup. It does not claim which repetition should be removed.
|
||||
@@ -15,6 +15,7 @@ This file is auto-generated. Do not edit it by hand.
|
||||
- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings
|
||||
- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names
|
||||
- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon
|
||||
- `packages/app/src/components/file-explorer-pane.tsx` is the only consumer; it renders the SVG with `SvgXml` from `react-native-svg`
|
||||
|
||||
## Adding a new icon
|
||||
|
||||
|
||||
179
docs/floating-panels.md
Normal file
179
docs/floating-panels.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Floating Panels
|
||||
|
||||
Anchored popovers — tooltips, hover cards, dropdowns, autocompletes — that visually
|
||||
float above an anchor element on iOS, Android, and web. This doc captures the
|
||||
non-obvious traps. It is **not** a tutorial; it assumes you have seen the
|
||||
canonical files and are trying to add or change one.
|
||||
|
||||
## Canonical files
|
||||
|
||||
| File | Use case |
|
||||
| ---------------------------------------- | ----------------------------------------------------------------- |
|
||||
| `components/ui/combobox.tsx` | Anchored picker with search; mobile falls back to bottom sheet |
|
||||
| `components/ui/tooltip.tsx` | Non-interactive hover/long-press tooltip |
|
||||
| `components/workspace-hover-card.tsx` | Desktop-web hover card with measure + computePosition + Portal |
|
||||
| `components/ui/autocomplete-popover.tsx` | Slash-command autocomplete anchored to the focused composer input |
|
||||
|
||||
Each handles a different mix of concerns: combobox owns input focus, tooltip is
|
||||
non-interactive, hover-card is web-only desktop, autocomplete keeps the composer
|
||||
input focused while its scrollable list lives in a Portal. There is no shared
|
||||
"floating panel" primitive yet — when a fifth use case shows up we can revisit;
|
||||
until then prefer copying the closest file and trimming.
|
||||
|
||||
## Gotcha 1 — Android touch hit-test by parent bounds
|
||||
|
||||
On Android, a child View whose bounds fall outside its parent's bounds renders
|
||||
correctly (with `overflow: visible`, the default) but **does not receive touch
|
||||
events**. `ViewGroup.dispatchTouchEvent` filters touches by the parent's hit
|
||||
rect first, then iterates children. A touch in the overflowing region never
|
||||
reaches the parent, let alone the child. iOS and web do not share this rule —
|
||||
iOS hit-test descends into overflowing children, web uses standard CSS pointer
|
||||
events. This is the bug that put autocomplete on this path: the popover was
|
||||
positioned `bottom: 100%` of its parent and worked on iOS/web for months;
|
||||
Android touches sailed straight through to the chat scroll view behind it.
|
||||
|
||||
Two escape hatches in the codebase:
|
||||
|
||||
- **`Modal`** (combobox, tooltip on native) — opens a new Android window, so
|
||||
hit-testing starts fresh in that window. Side effect: a Modal opening on
|
||||
Android can detach the IME from an underlying TextInput. Fine for combobox
|
||||
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
|
||||
(the composer's input must stay focused so the user keeps typing).
|
||||
- **`<Portal>` from `@gorhom/portal`** (hover-card, autocomplete-popover) —
|
||||
hoists the React subtree to a fixed mount point whose bounds cover the
|
||||
screen. Same window, same IME, hit-test works because the new parent is
|
||||
full-screen. This is the right default when you must keep IME attachment.
|
||||
Choose the host by layer: app-global overlays use the root host; content
|
||||
overlays can use the current `FloatingPanelPortalHost` so sliding sidebars
|
||||
cover them.
|
||||
|
||||
Choose Modal vs Portal by whether the underlying input can lose its keyboard.
|
||||
|
||||
## Gotcha 2 — Portal breaks lifecycle and coordinate-system inheritance
|
||||
|
||||
A Portal escapes Android's hit-test, but it also escapes two things you were
|
||||
quietly relying on:
|
||||
|
||||
- **Lifecycle.** The portal'd subtree mounts at the app root, not inside your
|
||||
component's natural ancestor chain. When the user navigates away, your
|
||||
component may stay mounted (offscreen, in a tab) — the popover stays with it.
|
||||
Gate `visible` on a screen-focus signal. For panes inside `agent-panel`, the
|
||||
`isPaneFocused` prop already exists and flips on pane switches; pass
|
||||
`visible={isYourOwnVisible && isPaneFocused}`.
|
||||
- **Transforms.** The composer is wrapped in a Reanimated `Animated.View` with
|
||||
`translateY: -keyboardShift` (see `use-keyboard-shift-style.ts`). The chat
|
||||
content has the same transform applied (`agent-panel.tsx:939`). They move
|
||||
together because they share the SharedValue. A portal'd popover is outside
|
||||
the composer tree — it does not get that transform unless you apply it
|
||||
yourself.
|
||||
- **Layering.** The default root host renders after app content, so it sits
|
||||
above compact sidebars. Content overlays that must sit below sidebars should
|
||||
use the current `FloatingPanelPortalHost`.
|
||||
- **Coordinate systems.** `measureInWindow` gives window coordinates. A Portal
|
||||
renders inside its host, not necessarily at window origin. Position anchored
|
||||
content relative to the host: `anchorRect - hostRect`. This is what
|
||||
`measureFloatingPanelPortalHost()` is for.
|
||||
|
||||
The fix for transforms is Gotcha 3.
|
||||
|
||||
## Gotcha 3 — Reanimated transforms vs `measureInWindow`
|
||||
|
||||
`measureInWindow` returns the view's _current_ screen position. In theory that
|
||||
includes Reanimated-applied transforms (Reanimated updates native view
|
||||
properties, and Android's `getLocationInWindow` reads transformed coords). In
|
||||
practice it's racy — the measurement may snapshot mid-animation, and on Android
|
||||
with Reanimated worklets the result is not always stable.
|
||||
|
||||
If the panel cannot stay inside the transformed ancestor, do not try to track
|
||||
the keyboard by re-measuring on every frame. Instead,
|
||||
**slave the popover's transform to the same SharedValue the composer uses**:
|
||||
|
||||
1. Snapshot `openShift = shift.value` at the moment you measure the anchor.
|
||||
2. Apply `useAnimatedStyle(() => ({ transform: [{ translateY: openShift.value - shift.value }] }))`
|
||||
to the popover wrapper.
|
||||
|
||||
When `shift` equals `openShift`, the translate is 0 and the popover sits at
|
||||
the measured position. When the keyboard moves afterward, the delta translates
|
||||
the popover by exactly the amount the composer translates. They move in
|
||||
lockstep, no re-measurement needed.
|
||||
|
||||
Re-measure on `Keyboard.addListener('keyboardDidShow'|'keyboardDidHide')` only
|
||||
to refresh the snapshot if the keyboard was mid-transition when the popover
|
||||
opened.
|
||||
|
||||
## Gotcha 4 — Host-relative positioning before platform offsets
|
||||
|
||||
The generic anchored-overlay rule is:
|
||||
|
||||
1. Measure the anchor with `measureInWindow`.
|
||||
2. Measure the Portal host with `measureFloatingPanelPortalHost(hostName)`.
|
||||
3. Position with anchor coordinates relative to the host:
|
||||
|
||||
```ts
|
||||
left = anchorRect.x - hostRect.x;
|
||||
bottom = hostRect.height - (anchorRect.y - hostRect.y) + offset;
|
||||
```
|
||||
|
||||
Do this before adding any platform offset. If anchor and host are both measured
|
||||
with `measureInWindow`, Android's status-bar coordinate behavior cancels out.
|
||||
Only add a status-bar offset when the render surface is not measured in the same
|
||||
coordinate system. See `tooltip.tsx` for that separate case.
|
||||
|
||||
## Gotcha 5 — The two-measurement flash
|
||||
|
||||
If your popover needs `top` (or `left`) computed from both:
|
||||
|
||||
- the anchor's screen position (`anchorRect` from `measureInWindow`), **and**
|
||||
- the popover's own size (`contentSize` from `onLayout`),
|
||||
|
||||
then a naïve implementation will flash through three positions on every open:
|
||||
|
||||
1. **Frame 1** — render with `top: -9999` (or any placeholder) while waiting
|
||||
for either measurement. Wrapper has no `width`, so the inner content lays
|
||||
out at its natural (often narrow) intrinsic width.
|
||||
2. **Frame 2** — `anchorRect` lands. Wrapper now has `width: anchorRect.width`.
|
||||
But the stale `onLayout` from frame 1 has already set `contentSize` to the
|
||||
narrow-width dimensions. `top = anchorRect.y - wrongHeight - gap` — visible
|
||||
at the wrong spot.
|
||||
3. **Frame 3** — real `onLayout` fires with the correct width. `contentSize`
|
||||
updates. Position snaps to the right place.
|
||||
|
||||
The visible jump in frame 2 is the flash. Two pieces solve it, and you need
|
||||
both:
|
||||
|
||||
- **Do not mount the floating content until `anchorRect` is set.** Return
|
||||
`null` until then. This prevents the bad-width onLayout from happening at
|
||||
all.
|
||||
- **Once `anchorRect` is set but `contentSize` isn't, render the wrapper with
|
||||
the final width but `opacity: 0`.** The first visible paint is at the
|
||||
correct position. This is the combobox pattern —
|
||||
`shouldHideDesktopContent` at `combobox.tsx:481, 876`. **Do not** use
|
||||
`top: -9999` as the placeholder; the layout work still happens at -9999 and
|
||||
any subsequent state-flash is visible when you flip back.
|
||||
|
||||
The "render invisible to measure, then reveal" pattern is the canonical
|
||||
solution to chicken-and-egg positioning in this codebase. Reach for it before
|
||||
anything fancier.
|
||||
|
||||
## Recipe for a new anchored panel
|
||||
|
||||
Before you write a new one, ask:
|
||||
|
||||
1. **Can the underlying input lose its keyboard?** If yes, use Modal (simpler).
|
||||
If no, use Portal.
|
||||
2. **Does the panel need to dismiss on screen change?** Almost always yes —
|
||||
gate `visible` on an upstream focus prop (`isPaneFocused` or similar).
|
||||
3. **Is the panel rendered in a Portal host?** Measure the host too. Never use
|
||||
raw window coordinates as local Portal coordinates.
|
||||
4. **Does the panel sit above something that moves with the keyboard?** If
|
||||
yes, slave a Reanimated transform to the same SharedValue (Gotcha 3).
|
||||
If no, you can probably skip the transform entirely.
|
||||
5. **Will the panel's content height vary?** If yes, you need both
|
||||
`anchorRect` and `contentSize` for positioning → apply Gotcha 5 (return
|
||||
null until anchor, then opacity-0 until contentSize). If no — content has
|
||||
a known fixed max height — you might be able to use bottom-anchored
|
||||
positioning (`bottom: windowHeight - anchor.y + gap`) and skip the
|
||||
`contentSize` round-trip entirely. **But only if the height is genuinely
|
||||
bounded**. Verify before you commit.
|
||||
|
||||
Then copy the closest canonical file and trim.
|
||||
@@ -2,44 +2,35 @@
|
||||
|
||||
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.
|
||||
- **Project** — Logical grouping of workspaces sharing a git remote (or main repo root). UI: "Project" / "Add project". Code: `ProjectSummary` (`packages/app/src/utils/projects.ts:22`), `projectKey` (`packages/server/src/server/workspace-registry-model.ts:16`). Forbidden: "Repo", "Repository" as UI label.
|
||||
- **Workspace** — One concrete `cwd` on one daemon, with git state; belongs to exactly one project. UI: "Workspace". Code: `WorkspaceDescriptorPayload` (`packages/protocol/src/messages.ts:2178`). Don't confuse with: Branch (one branch can back many workspaces via worktrees). Forbidden: "Folder", "Directory" as UI label.
|
||||
- **Workspace kind** — `"directory" | "local_checkout" | "worktree"`. Code: `PersistedWorkspaceKind` (`packages/server/src/server/workspace-registry-model.ts:8`).
|
||||
- **Agent** — One AI coding agent run on a daemon (one provider, one model, one cwd, one timeline). UI: "Agent" / "New Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
|
||||
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
|
||||
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
|
||||
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
|
||||
- **Placement** — One workspace's relationship to its project (projectKey, projectName, git checkout snapshot). Internal. Code: `ProjectPlacementPayload` (`packages/protocol/src/messages.ts:2113`).
|
||||
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
|
||||
- **Worktree** — Paseo-managed git worktree (`~/.paseo/worktrees/{name}`); also a `workspaceKind` value. UI: CLI + `paseo.json` keys (`worktree.setup`, `worktree.teardown`) only. Code: `ProjectCheckoutLiteGitPaseoPayload` (`packages/protocol/src/messages.ts:2092`); CLI `paseo worktree` (`packages/cli/src/commands/worktree/index.ts:8`). Forbidden: "Checkout" as a synonym.
|
||||
- **Repository / Remote** — Internal git inputs (`remoteUrl`, `mainRepoRoot`) used to derive `projectKey`. No UI label.
|
||||
- **Session** — Per-client connection to a daemon. Internal. Code: `Session` (`packages/server/src/server/session.ts`). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts: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.)
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing.
|
||||
- **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`).
|
||||
- **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates remote agents. UI: CLI only (`paseo schedule`). Code: `ScheduleCreateRequest` (re-exported from `packages/protocol/src/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/protocol/src/messages.ts:257`).
|
||||
- **Attachment** — GitHub PR or Issue bound to an agent prompt. UI: "Attach issue or PR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
|
||||
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
|
||||
- **Composer input** — The text-entry surface inside the composer. Code: `MessageInput` (`packages/app/src/composer/input/input.tsx`).
|
||||
- **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar".
|
||||
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
|
||||
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
|
||||
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
|
||||
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
|
||||
- **Conflict** — Two distinct senses; do NOT use the bare word in UI copy without qualifying which: (a) **stale-write conflict** on `paseo.json` ("Config changed on disk", code `stale_project_config`, `packages/app/src/screens/project-settings-screen.tsx:593`); (b) **git merge conflict** (no current UI string).
|
||||
|
||||
## Inconsistencies (documented, not papered over)
|
||||
|
||||
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/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.
|
||||
- CLI `--host <host>` description `"Daemon host target"` (`packages/cli/src/utils/command-options.ts:5`) blurs daemon/host; the app keeps them distinct.
|
||||
- `WorkspaceDescriptorPayloadSchema.workspaceKind` accepts legacy `"checkout"` on the wire (`packages/protocol/src/messages.ts:2187`) while `PersistedWorkspaceKind` does not (`packages/server/src/server/workspace-registry-model.ts:8`).
|
||||
|
||||
138
docs/hover.md
Normal file
138
docs/hover.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Hover
|
||||
|
||||
Read this before writing any hover code. Every hover regression we ship is one of the three failure modes below, and every one of them is solved by the same canonical pattern. The pattern is hardwon — it survived every other shape we tried — so copy it, don't reinvent it.
|
||||
|
||||
## The pattern
|
||||
|
||||
The canonical implementation lives in `packages/app/src/components/sidebar-workspace-list.tsx`, in the workspace row (around line 1369). When in doubt, open that file and copy the shape.
|
||||
|
||||
```tsx
|
||||
//
|
||||
// ┌─ Plain View. Tracks hover via pointerenter/pointerleave.
|
||||
// │
|
||||
<View
|
||||
style={styles.workspaceRowContainer}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
<Pressable // ┐ Separate inner Pressable.
|
||||
onPress={handlePress} // │ Handles press only.
|
||||
onPressIn={...} // │ Never has onHoverIn/onHoverOut.
|
||||
onPressOut={...} // ┘
|
||||
style={workspaceRowStyle}
|
||||
>
|
||||
<View style={styles.workspaceRowMain}>
|
||||
<View style={styles.workspaceRowLeft}>…</View>
|
||||
<WorkspaceRowRightGroup isHovered={isHovered} />
|
||||
{/* └─ Reveals content based on hover state. */}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
```
|
||||
|
||||
Five things make this work. Every one of them matters.
|
||||
|
||||
1. **Hover lives on a plain `View`, not a `Pressable`.** `Pressable` carries its own internal hover state machine. Nested `Pressable`s fight over it. A plain `View` just dispatches DOM events — no state machine, no fighting.
|
||||
2. **Press lives on a _separate_ inner `Pressable`.** Hover and press never share an element. The two state machines never see each other.
|
||||
3. **`onPointerEnter` / `onPointerLeave` are non-bubbling**, mouseenter-style by W3C spec. They fire only when crossing the outer `View`'s bounding box. Crossing into descendants — including descendant `Pressable`s (the kebab menu's buttons, a copy button, a tooltip target) — does **not** fire `pointerleave`. This is why nesting `Pressable`s inside is safe.
|
||||
4. **The row has a fixed `minHeight`.** When content swaps in on hover (kebab replacing a diff stat), both occupy the same fixed slot. Zero layout shift, zero geometry flicker.
|
||||
5. **The outer `View` has nothing but `position: relative`.** It exists only to be the hover target. All real layout lives on the inner `Pressable`. The hover-tracker is a sealed envelope around the row; layout changes inside it never leak out and re-enter through the side.
|
||||
|
||||
That's the whole pattern. Internalize it.
|
||||
|
||||
## When you skip the pattern, here is what breaks
|
||||
|
||||
### Failure mode 1 — Nested Pressables fight over hover state
|
||||
|
||||
If you put `onHoverIn` / `onHoverOut` on a `Pressable` that has another `Pressable` anywhere inside it (a copy button, an icon button, a nested action), the moment the cursor moves onto the inner `Pressable`, the inner one's hover state machine claims hover and the outer one's `onHoverOut` fires. Your reveal state flips off. The reveal hides. The cursor is no longer over the hidden reveal, so it ends up back over the trigger area. The outer's `onHoverIn` fires. Loop.
|
||||
|
||||
This is the most common hover bug shipped in this codebase, by a wide margin. It is what the workspace row is structured to avoid. The fix is not "be clever about handlers" — it's "don't put hover on a Pressable that contains other Pressables."
|
||||
|
||||
> **Rule:** the hover-tracking element is a plain `View` with `onPointerEnter` / `onPointerLeave`. Any `Pressable`s — including ones you forgot are Pressables, like `TurnCopyButton`, icon buttons, anything that handles a tap — live inside it.
|
||||
|
||||
### Failure mode 2 — The hovered state changes the trigger's geometry
|
||||
|
||||
Symptom: you hover a button, it changes appearance, then flickers between hovered and not-hovered without the cursor moving.
|
||||
|
||||
Cause: the hover state changed the size or position of the trigger. The cursor was on the original element; the new layout shifts or shrinks it out from under the cursor; `onHoverOut` fires; state reverts; original layout returns; cursor is back over the trigger; `onHoverIn` fires; loop.
|
||||
|
||||
Common variants:
|
||||
|
||||
- Hover state changes the trigger's `width`, `height`, `padding`, or `borderWidth`.
|
||||
- Hover state mounts/unmounts a child that pushes the trigger to a new position.
|
||||
- Hover state swaps the trigger for a different element type, remounting it.
|
||||
|
||||
Fixes, in preferred order:
|
||||
|
||||
1. **Don't change the trigger's outer geometry on hover.** Change colors, opacity, borders that don't take layout space (`outlineWidth` on web, absolutely positioned overlays), or child content that fits inside the same fixed box. Never change `width`, `height`, `padding`, or `borderWidth` of the hover target itself.
|
||||
2. **Hide with `opacity` + `pointerEvents`, not conditional rendering**, when the hidden element lives inside the trigger. Mounting/unmounting on hover reflows the layout under the cursor.
|
||||
3. **Pin the hit area.** Set a fixed `minHeight` / `minWidth` on the trigger so internal swaps (icon-A becomes icon-B on hover) leave the bounding box unchanged. The workspace row's `minHeight: 36` is what makes the kebab/diff-stat swap stable.
|
||||
|
||||
### Failure mode 3 — Revealed content lives outside the hover trigger
|
||||
|
||||
If hovering element A reveals element B, B must be **inside** A's hover trigger. If B is a sibling, the moment the cursor moves from A toward B it crosses out of A's bounding box, `pointerleave` fires, B disappears.
|
||||
|
||||
Wrong:
|
||||
|
||||
```tsx
|
||||
<View>
|
||||
<View onPointerEnter={...} onPointerLeave={...}> {/* hover trigger */}
|
||||
<Bubble />
|
||||
</View>
|
||||
<TrailingRow /> {/* OUTSIDE — sibling, not child */}
|
||||
</View>
|
||||
```
|
||||
|
||||
Right:
|
||||
|
||||
```tsx
|
||||
<View onPointerEnter={...} onPointerLeave={...}> {/* hover trigger */}
|
||||
<Bubble />
|
||||
<TrailingRow /> {/* INSIDE — child */}
|
||||
</View>
|
||||
```
|
||||
|
||||
Any gap between A and B (margins between siblings inside the same parent) is part of the parent's bounding box, so the cursor stays inside the hover region while crossing it. No bridge needed.
|
||||
|
||||
If A and B genuinely can't share a parent — B portals into a different layer, floats above other content — see [Section: real gaps](#real-gaps-with-floating-panels) below.
|
||||
|
||||
## Native fallback
|
||||
|
||||
Hover doesn't exist on touch devices. Anything you hide behind hover must have a non-hover path on native and compact layouts:
|
||||
|
||||
```tsx
|
||||
const showControls = isHovered || isNative || isCompact;
|
||||
```
|
||||
|
||||
`isNative` and `isCompact` come from `@/constants/platform` and `@/constants/layout`. Don't use `Platform.OS === "ios"` as a proxy.
|
||||
|
||||
`onPointerEnter` / `onPointerLeave` are DOM events. They do not fire on native. You do not need to gate them — on native, hover is unreachable anyway and visibility is driven by `isNative` / `isCompact` in your show-the-controls expression above. This is why the workspace row's pointer events are not wrapped in `if (isWeb)`.
|
||||
|
||||
## What about `Pressable.onHoverIn` / `onHoverOut`?
|
||||
|
||||
It's fine when a `Pressable` styles **itself** based on its own hover — for example, an icon button that changes color on hover. That's self-contained. The render-prop `<Pressable style={({ hovered }) => ...}>` does the same thing more cleanly and is the preferred form.
|
||||
|
||||
It is **not** fine for tracking hover to drive state **outside** that `Pressable` (revealing a sibling, opening a tooltip, showing a kebab) when there is any other `Pressable` inside — because that's Failure Mode 1.
|
||||
|
||||
Heuristic: if your hover state is going to be `useState`'d and read by anything other than the same `Pressable`'s own style, do not use `onHoverIn` / `onHoverOut`. Use the canonical pattern.
|
||||
|
||||
## Real gaps with floating panels
|
||||
|
||||
Sometimes the revealed content can't live inside the trigger — a hover card portals into a different layer, a tooltip floats above other content, a popover renders into a `Portal`. There's a real visual gap the user has to cross with the cursor.
|
||||
|
||||
For this case, use `useHoverSafeZone` (`packages/app/src/hooks/use-hover-safe-zone.ts`). It computes a rectangular "bridge" between the trigger and the content; while the pointer is inside trigger, content, or the bridge, the card stays open. A short grace timer absorbs jitter at the edges. The canonical caller is `packages/app/src/components/workspace-hover-card.tsx`.
|
||||
|
||||
Don't roll your own. The math is annoying, the edge cases (pointer leaves window, drag in progress, content unmounts) are subtle, and we already paid for the hook.
|
||||
|
||||
## Pre-PR checklist
|
||||
|
||||
Before opening a PR that touches hover:
|
||||
|
||||
- [ ] Hover-tracking is on a plain `View` with `onPointerEnter` / `onPointerLeave`, **not** on a `Pressable` that wraps anything pressable.
|
||||
- [ ] Any press behavior lives on a separate inner `Pressable` that does not have `onHoverIn` / `onHoverOut`.
|
||||
- [ ] The hover trigger's bounding box contains every element the user might mouse into while interacting with the feature.
|
||||
- [ ] Hovered state does **not** change the trigger's outer geometry (`width`, `height`, `padding`, `borderWidth`, mount/unmount of siblings that shift it). Internal swaps fit inside a fixed `minHeight` / `minWidth`.
|
||||
- [ ] Revealed content inside the trigger uses `opacity` + `pointerEvents`, not conditional rendering, if mounting it would reflow the trigger.
|
||||
- [ ] Visibility on native and compact layouts works without hover (`isHovered || isNative || isCompact`).
|
||||
- [ ] If the revealed content sits in a separate layer (portal, floating panel), `useHoverSafeZone` is wired up.
|
||||
- [ ] You opened the dev server, hovered the trigger, and slowly moved the mouse along **every** revealed element — including any visible gaps — without losing hover state.
|
||||
@@ -143,7 +143,7 @@ Maestro `inputText` fires one character at a time. React Native's **controlled**
|
||||
|
||||
For inputs that E2E flows type into (host endpoint, pairing URL, etc.), use an **uncontrolled ref-backed input**: `defaultValue` + `onChangeText` writes into a `useRef`, reads via the ref on submit. No per-keystroke re-render, no dropped characters.
|
||||
|
||||
See `add-host-modal.tsx` and `pair-link-modal.tsx` for the pattern. Always pair the source change with a Maestro `assertVisible` on the input's `id + text` after `inputText`, so regressions are caught immediately.
|
||||
See `pair-link-modal.tsx` for the pattern (`useRef`-backed `onChangeText`, no `value=` prop). Always pair the source change with a Maestro `assertVisible` on the input's `id + text` after `inputText`, so regressions are caught immediately.
|
||||
|
||||
### Dropdowns that launch native presenters (iOS)
|
||||
|
||||
@@ -249,6 +249,23 @@ const { theme } = useUnistyles();
|
||||
|
||||
Regular `View` components can safely use Unistyles dynamic styles — the conflict is specific to `Animated.View`.
|
||||
|
||||
## Native Chat Stream Layout
|
||||
|
||||
The native agent stream uses an inverted `FlatList`, so chat layout has three coordinate systems:
|
||||
|
||||
- chronological stream order
|
||||
- strategy-ordered array order
|
||||
- native inverted cell visual order
|
||||
|
||||
Do not compute stream neighbors, history/live-head seams, turn footer ownership, assistant block spacing, or tool sequence endings inside React render loops. Those policies live in `packages/app/src/agent-stream/layout.ts` and are unit-tested without React Native rendering.
|
||||
|
||||
Platform-specific stream edges belong on `StreamStrategy`:
|
||||
|
||||
- forward web uses the last history item as the history/live-head boundary and renders content before a footer
|
||||
- native inverted uses the first history item as the history/live-head boundary and compensates for inverted cell child order
|
||||
|
||||
If a chat footer looks duplicated or appears above the assistant message on mobile, start with `packages/app/src/agent-stream/layout.test.ts`. Do not add a React Native renderer test for this class of bug; make the pure layout invariant fail first.
|
||||
|
||||
## iOS Simulator
|
||||
|
||||
```bash
|
||||
|
||||
49
docs/opencode-global-event-baseline.md
Normal file
49
docs/opencode-global-event-baseline.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# OpenCode Global Event Verification
|
||||
|
||||
Date: 2026-05-11
|
||||
|
||||
## Objective
|
||||
|
||||
Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/global/event` stream and remove the EOF polling recovery path that was added for the `/event` regression.
|
||||
|
||||
## Environment
|
||||
|
||||
- `opencode --version`: `1.14.46`
|
||||
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
|
||||
- `node --version`: `v22.20.0`
|
||||
- `npm --version`: `10.9.3`
|
||||
|
||||
Each OpenCode test file was run independently with:
|
||||
|
||||
```bash
|
||||
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1
|
||||
```
|
||||
|
||||
## Baseline
|
||||
|
||||
Before the provider change, the OpenCode matrix had 16 passing files and 4 failing files:
|
||||
|
||||
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: Vitest reports "No test suite found in file".
|
||||
- `packages/server/src/server/agent/providers/opencode-agent.test.ts`: `plan mode blocks edits while build mode can write files` did not observe a completed tool call.
|
||||
- `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts`: brittle unavailable-model assertion received an auth failure from the upstream API.
|
||||
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: timed out waiting for an interrupted sleep tool call, even though the recent bash tool call status was `failed`.
|
||||
|
||||
## Post-Change Result
|
||||
|
||||
After switching to `/global/event`, removing polling recovery, and replacing the brittle initial-prompt model case with `opencode/big-pickle`, the OpenCode matrix had 18 passing files and 2 baseline-equivalent failing files:
|
||||
|
||||
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: unchanged; Vitest still reports "No test suite found in file".
|
||||
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: unchanged; still times out after the interrupted sleep tool call is already marked `failed`.
|
||||
|
||||
The previously failing provider unit file now passes, and `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts` passes with `opencode/big-pickle`.
|
||||
|
||||
One live reasoning-dedup matrix run returned no reasoning content; an immediate targeted rerun passed. This appears model-output dependent rather than related to the event-stream change.
|
||||
|
||||
## Focused Verification
|
||||
|
||||
- `npm run typecheck`
|
||||
- `npm run lint`
|
||||
- `git diff --check`
|
||||
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --maxWorkers=1`
|
||||
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts --maxWorkers=1`
|
||||
- `npx vitest run packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts --maxWorkers=1`
|
||||
@@ -67,10 +67,14 @@ Anyone who builds software:
|
||||
3. **The daemon as infrastructure.** Server/client architecture enables deployment anywhere.
|
||||
4. **Open source outlasts funding.** Open source communities are resilient. Contributors become advocates.
|
||||
|
||||
## Current state (March 2026)
|
||||
## Current state (May 2026)
|
||||
|
||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||
- Providers: Claude Code (Agent SDK), Codex (app-server), OpenCode
|
||||
- Daily releases
|
||||
- Community contributions starting (packaging, bug fixes)
|
||||
- Key UX: split panes, keybinding customization, workspace model
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi
|
||||
- One-click ACP provider catalog: Cursor, DeepSeek TUI, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- Voice mode: dictate prompts or talk through problems hands-free
|
||||
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
|
||||
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||
- Frequent releases (multiple per week)
|
||||
- Community contributions across packaging, providers, and bug fixes
|
||||
- Key UX: split panes, keybinding customization, workspace model, in-app browser
|
||||
|
||||
@@ -6,15 +6,45 @@ This guide walks through adding a new agent provider end-to-end. There are two i
|
||||
|
||||
### ACP (Agent Client Protocol) -- recommended
|
||||
|
||||
Extend `ACPAgentClient`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks.
|
||||
Extend `ACPAgentClient` from `packages/server/src/server/agent/providers/acp-agent.ts`. The base class handles process spawning, stdio transport, session lifecycle, streaming, permissions, and model discovery. You provide configuration (command, modes, capabilities) and optionally override `isAvailable()` for auth checks.
|
||||
|
||||
Existing ACP providers: `claude-acp`, `copilot`.
|
||||
The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `GenericACPAgentClient` (`generic-acp-agent.ts`) is also ACP-based but is used for user-defined custom providers configured via `extends: "acp"` overrides — see [docs/custom-providers.md](custom-providers.md).
|
||||
|
||||
### Direct
|
||||
|
||||
Implement the `AgentClient` and `AgentSession` interfaces yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
|
||||
Existing direct providers: `claude`, `codex`, `opencode`.
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||
|
||||
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
|
||||
|
||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||
|
||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||
|
||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||
|
||||
OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path.
|
||||
|
||||
OpenCode owns user message IDs. Do not pass Paseo-generated IDs to OpenCode prompt APIs; let OpenCode create `msg*` IDs and record the user timeline item from the `message.updated` event.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
|
||||
|
||||
---
|
||||
|
||||
## Provider Snapshot Refresh Contract
|
||||
|
||||
The daemon keeps provider snapshots per resolved working directory. Missing or blank cwd resolves to the user's home directory. Workspace selectors and old model/mode list requests should pass the cwd that will launch the provider so providers with project-specific models or modes are probed in the right context. Settings/provider management intentionally uses the home-directory snapshot.
|
||||
|
||||
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
|
||||
|
||||
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the home-directory snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
|
||||
|
||||
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
|
||||
|
||||
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that cwd; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only home.
|
||||
|
||||
---
|
||||
|
||||
@@ -151,7 +181,7 @@ export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
|
||||
|
||||
### 3. Add the factory to the provider registry
|
||||
|
||||
In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry:
|
||||
In `packages/server/src/server/agent/provider-registry.ts`, import your class and add a factory entry to `PROVIDER_CLIENT_FACTORIES`:
|
||||
|
||||
```ts
|
||||
import { MyProviderACPAgentClient } from "./providers/my-provider-agent.js";
|
||||
@@ -161,11 +191,13 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
"my-provider": (logger, runtimeSettings) =>
|
||||
new MyProviderACPAgentClient({
|
||||
logger,
|
||||
runtimeSettings: runtimeSettings?.["my-provider"],
|
||||
runtimeSettings,
|
||||
}),
|
||||
};
|
||||
```
|
||||
|
||||
The factory is invoked with `(logger, runtimeSettings, options)`; `options.workspaceGitService` is also available if you need it (see the `codex` factory for an example). The registry already passes the per-provider runtime settings slice through, so you don't index into the map yourself.
|
||||
|
||||
### 4. Add a provider icon (app)
|
||||
|
||||
Create `packages/app/src/components/icons/my-provider-icon.tsx` following the pattern from existing icons (e.g., `claude-icon.tsx`):
|
||||
@@ -187,19 +219,18 @@ export function MyProviderIcon({ size = 16, color = "currentColor" }: MyProvider
|
||||
}
|
||||
```
|
||||
|
||||
Then register it in `packages/app/src/components/provider-icons.ts`:
|
||||
Then register it in `packages/app/src/components/provider-icons.ts` by adding an entry to the existing `PROVIDER_ICONS` map (which already covers the built-in providers):
|
||||
|
||||
```ts
|
||||
import { MyProviderIcon } from "@/components/icons/my-provider-icon";
|
||||
|
||||
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||
claude: ClaudeIcon as unknown as typeof Bot,
|
||||
codex: CodexIcon as unknown as typeof Bot,
|
||||
// ... existing entries ...
|
||||
"my-provider": MyProviderIcon as unknown as typeof Bot,
|
||||
};
|
||||
```
|
||||
|
||||
If no icon is registered, the app falls back to a generic `Bot` icon from lucide.
|
||||
If no icon is registered, `getProviderIcon()` falls back to a generic `Bot` icon from lucide.
|
||||
|
||||
### 5. Add E2E test config
|
||||
|
||||
@@ -219,25 +250,25 @@ export const agentConfigs = {
|
||||
} as const satisfies Record<string, AgentTestConfig>;
|
||||
```
|
||||
|
||||
Add an availability check in `isProviderAvailable()`:
|
||||
Add an availability check in `isProviderAvailable()`. Note `isCommandAvailable` is async, so all branches `await` it:
|
||||
|
||||
```ts
|
||||
case "my-provider":
|
||||
return (
|
||||
isCommandAvailable("my-agent-binary") &&
|
||||
(await isCommandAvailable("my-agent-binary")) &&
|
||||
Boolean(process.env.MY_PROVIDER_API_KEY)
|
||||
);
|
||||
```
|
||||
|
||||
Add to the `allProviders` array:
|
||||
Add to the `allProviders` array (current built-ins are `claude`, `codex`, `copilot`, `opencode`, `pi`):
|
||||
|
||||
```ts
|
||||
export const allProviders: AgentProvider[] = [
|
||||
"claude",
|
||||
"claude-acp",
|
||||
"codex",
|
||||
"copilot",
|
||||
"opencode",
|
||||
"pi",
|
||||
"my-provider",
|
||||
];
|
||||
```
|
||||
@@ -258,7 +289,9 @@ If your agent does not speak ACP, implement the interfaces from `agent-sdk-types
|
||||
|
||||
### Interfaces to implement
|
||||
|
||||
**`AgentClient`** -- factory for sessions and model listing:
|
||||
The interfaces below are abridged signatures — read `agent-sdk-types.ts` for the full source of truth (option bag types, generics, etc.).
|
||||
|
||||
**`AgentClient`** -- factory for sessions and model/mode listing:
|
||||
|
||||
```ts
|
||||
interface AgentClient {
|
||||
@@ -267,16 +300,19 @@ interface AgentClient {
|
||||
createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession>;
|
||||
resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession>;
|
||||
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||
listModels(options: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||
isAvailable(): Promise<boolean>;
|
||||
// Optional:
|
||||
listModes?(options: ListModesOptions): Promise<AgentMode[]>;
|
||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||
getDiagnostic?(): Promise<{ diagnostic: string }>;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -287,6 +323,7 @@ interface AgentSession {
|
||||
readonly provider: AgentProvider;
|
||||
readonly id: string | null;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
readonly features?: AgentFeature[];
|
||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
|
||||
@@ -296,7 +333,10 @@ interface AgentSession {
|
||||
getCurrentMode(): Promise<string | null>;
|
||||
setMode(modeId: string): Promise<void>;
|
||||
getPendingPermissions(): AgentPermissionRequest[];
|
||||
respondToPermission(requestId: string, response: AgentPermissionResponse): Promise<void>;
|
||||
respondToPermission(
|
||||
requestId: string,
|
||||
response: AgentPermissionResponse,
|
||||
): Promise<AgentPermissionResult | void>;
|
||||
describePersistence(): AgentPersistenceHandle | null;
|
||||
interrupt(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
@@ -304,6 +344,10 @@ interface AgentSession {
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void>;
|
||||
setFeature?(featureId: string, value: unknown): Promise<void>;
|
||||
tryHandleOutOfBand?(prompt: AgentPromptInput): {
|
||||
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
|
||||
} | null;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
158
docs/release.md
158
docs/release.md
@@ -2,30 +2,53 @@
|
||||
|
||||
All workspaces share one version and release together.
|
||||
|
||||
## Two steps
|
||||
|
||||
A release has exactly two steps. The agent does the first, the user authorizes the second.
|
||||
|
||||
**Preparation** (local, reversible — agent does this):
|
||||
|
||||
- format, lint, typecheck all green
|
||||
- draft the changelog, show it to the user, wait for review
|
||||
- run the pre-release sanity check, surface findings to the user
|
||||
- confirm CI is green
|
||||
|
||||
**Go-ahead** (user says "go ahead"):
|
||||
|
||||
- commit the approved changelog
|
||||
- run the release
|
||||
|
||||
Rules that apply to both steps:
|
||||
|
||||
- Last-minute changes always need approval. Every time.
|
||||
- No code changes bundled into the changelog commit or the release commit. Code shims live in their own commit, reviewed on their own merits.
|
||||
- A sanity-check finding is information, not a directive. The agent surfaces it; the user decides.
|
||||
- Invoking a release skill is intent to start the flow, not blanket authorization to publish.
|
||||
|
||||
## Two paths
|
||||
|
||||
There are two supported ways to ship from `main`:
|
||||
|
||||
1. **Direct stable release**: you are ready to ship the current `main` commit to everyone immediately.
|
||||
2. **Beta flow**: you want public test builds first, but you are not ready for the website, npm, or production mobile release flows to move yet.
|
||||
2. **Beta flow**: silent release candidates. Betas don't touch the changelog, don't move the website, and don't publish npm or production mobile builds.
|
||||
|
||||
## Standard release (patch)
|
||||
|
||||
Before running any stable patch release command:
|
||||
|
||||
- Make sure the intended release commit is already committed to `main` and the working tree is clean.
|
||||
- Make sure local `npm run typecheck` passes on that commit.
|
||||
- **Run `npm run format`, `npm run lint`, and `npm run typecheck` and commit any resulting changes BEFORE you start any `release:*` command.** `release:check` runs `npm install --workspaces --include-workspace-root` as part of `release:prepare`, which can mutate `package-lock.json` (e.g. churning `"dev": true` markers on optional deps). The next step, `version:all:*`, runs `npm version` which aborts when the working tree is dirty. If this happens mid-flight you have to commit the lockfile churn before retrying — and the pre-commit format hook will reject a lockfile-only commit because oxfmt internally skips `package-lock.json` while lefthook's glob still matches it. Avoid the whole mess by running format/lint/typecheck first, then `release:prepare` once on its own to absorb any lockfile churn into a normal commit, then start the release.
|
||||
- Do not use `npm run release:patch` as a substitute for checking whether the current commit is actually ready.
|
||||
|
||||
```bash
|
||||
npm run release:patch
|
||||
```
|
||||
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag (triggering desktop, APK, and EAS mobile workflows).
|
||||
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
|
||||
|
||||
If asked to "release paseo" without specifying major/minor, treat it as a patch release.
|
||||
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
|
||||
|
||||
Use the direct stable path when the current `main` changes are ready to become the public release immediately.
|
||||
**Stable means stable.** If the user says "stable" or "ship stable", do not ask whether they want a beta first. They picked stable; treat it as a direct stable release. Only run the beta flow when the user explicitly says "beta".
|
||||
|
||||
## Manual step-by-step
|
||||
|
||||
@@ -51,17 +74,18 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z
|
||||
- `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag
|
||||
- Desktop assets now come from the Electron package at `packages/desktop`
|
||||
- Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published.
|
||||
- **Do create a changelog entry for betas.** The beta entry is temporary and gets updated in place until promotion.
|
||||
- **Betas don't touch `CHANGELOG.md`.** Beta GitHub releases ship with empty notes — that's intentional. The changelog entry is written once, at promotion time, covering the full stable-to-stable diff. The release-notes sync script skips betas cleanly because no matching section exists.
|
||||
|
||||
Use the beta path when you need to:
|
||||
|
||||
- smoke a build yourself before promoting it to everyone
|
||||
- test a build manually in a Linux or Windows VM
|
||||
- send a build to a user who is hitting a specific problem
|
||||
- iterate on `beta.1`, `beta.2`, `beta.3`, and so on before deciding to ship broadly
|
||||
|
||||
## Staged rollout (stable channel)
|
||||
|
||||
Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 24 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
|
||||
Stable desktop releases go out via a linear time-based rollout: 0% admitted when the updater manifests appear, 100% admitted 36 hours later, linear ramp in between. Beta releases bypass the rollout entirely — beta users always receive updates immediately.
|
||||
|
||||
The rollout is driven by a `rolloutHours` field stamped into the GitHub Release manifests (`latest-mac.yml`, `latest-linux.yml`, `latest.yml`) by the `finalize-rollout` job in `desktop-release.yml`.
|
||||
|
||||
@@ -74,16 +98,16 @@ Updater clients only discover a release through those `.yml` manifests, so there
|
||||
|
||||
### Default behavior
|
||||
|
||||
`npm run release:patch` → tag push → 24h ramp. No extra action needed.
|
||||
`npm run release:patch` → tag push → 36h ramp. No extra action needed.
|
||||
|
||||
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 24. To get any other rollout duration on a fresh release, use the post-publish flip below.
|
||||
The `rollout_hours` input on `desktop-release.yml` is **only read on `workflow_dispatch`** — tag-push runs always default to 36. To get any other rollout duration on a fresh release, use the post-publish flip below.
|
||||
|
||||
### Instant-admit release (rollout_hours=0 from publish)
|
||||
|
||||
For a fresh release that should admit everyone immediately (low-risk change, doc-only, hotfix, or just a release you want out fast), cut the release normally and queue the rollout flip immediately after:
|
||||
|
||||
```bash
|
||||
# 1. Cut and publish (default 24h ramp from tag push).
|
||||
# 1. Cut and publish (default 36h ramp from tag push).
|
||||
npm run release:patch
|
||||
|
||||
# 2. Immediately queue the flip — runs as soon as finalize-rollout completes.
|
||||
@@ -92,7 +116,7 @@ gh workflow run desktop-rollout.yml \
|
||||
-f rollout_hours=0
|
||||
```
|
||||
|
||||
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=24`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
|
||||
**Why this is gap-free:** `desktop-release.yml`'s `finalize-rollout` job and `desktop-rollout.yml` share the concurrency group `desktop-rollout-<tag>`. Dispatching `desktop-rollout.yml` while the tag-push pipeline is still running queues it safely behind `finalize-rollout`. The first public manifests already carry `rolloutHours=36`, then `desktop-rollout.yml` flips them to `rolloutHours=0` shortly afterward. The renderer polls every 30 minutes, so active stable users pick up the new manifest on their next check.
|
||||
|
||||
Run the dispatch right after `release:patch` returns. Don't wait for the tag-push CI to finish.
|
||||
|
||||
@@ -132,7 +156,7 @@ gh workflow run desktop-release.yml \
|
||||
-f rollout_hours=6
|
||||
```
|
||||
|
||||
This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 24. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
|
||||
This does **not** apply to fresh releases cut via `npm run release:patch` — that path always tag-pushes and stamps 36. For a fresh release with a custom ramp, cut normally and then dispatch `desktop-rollout.yml` (same pattern as the instant-admit flow above, with your chosen `rollout_hours`).
|
||||
|
||||
### Releasing during an active rollout
|
||||
|
||||
@@ -147,11 +171,73 @@ If N+1 is a hotfix for a bug in N, dispatch `desktop-rollout.yml -f tag=v0.1.<N+
|
||||
- **Bootstrap caveat.** Clients running a build older than the rollout feature ignore `rolloutHours` and admit immediately. Rollout protection only applies to clients running the rollout-aware version or later.
|
||||
- **Up to ~30 min admission latency.** Renderer polls every 30 minutes, so a stable user may take up to that long to be evaluated against the rollout window.
|
||||
|
||||
## Mobile builds (EAS)
|
||||
|
||||
iOS and Android store builds are not in `.github/workflows`. They are triggered by the EAS GitHub app the moment the `v*` tag is pushed:
|
||||
|
||||
- **Android (Play Store)** — EAS builds with profile `production` and auto-submits to the Play Store via `eas submit` (EAS-managed credentials, no Fastlane).
|
||||
- **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review.
|
||||
- **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo.
|
||||
|
||||
There is no `release-mobile.yml` in this repo. Earlier versions of these docs referenced one — that workflow was removed and the EAS GitHub app handles tag triggering directly.
|
||||
|
||||
### Watching mobile builds from the terminal
|
||||
|
||||
Use the EAS CLI from `packages/app/`:
|
||||
|
||||
```bash
|
||||
cd packages/app
|
||||
|
||||
# Recent builds (newest first). Pipe to jq for status only.
|
||||
npx eas build:list --limit 8 --non-interactive --json | jq '.[] | {platform, status, appVersion, gitCommitHash}'
|
||||
|
||||
# Filter by platform.
|
||||
npx eas build:list --platform ios --limit 5 --non-interactive --json
|
||||
npx eas build:list --platform android --limit 5 --non-interactive --json
|
||||
|
||||
# Inspect a specific build.
|
||||
npx eas build:view <build-id>
|
||||
|
||||
# Stream logs for a build.
|
||||
npx eas build:view <build-id> --json | jq '.logFiles[]'
|
||||
```
|
||||
|
||||
A build's `gitCommitHash` must match the release tag commit. `status` walks through `NEW` → `IN_QUEUE` → `IN_PROGRESS` → `FINISHED` (or `ERRORED`/`CANCELED`).
|
||||
|
||||
Once a build is `FINISHED`, EAS auto-submits it to the store: Android via the `submit` block in `eas.json` (EAS-managed Play Console credentials), iOS via the Fastlane `submit_review` lane (uploads to TestFlight, then submits for App Store review). To confirm the submission landed, run `npx eas build:view <build-id>` and open the `Logs` URL it prints — the build's Expo dashboard page has a Submissions section listing each attempt with its store response. App Store Connect (TestFlight tab → ready for review) and the Play Console (Internal testing / Production tracks) are the final ground truth.
|
||||
|
||||
### Babysitting mobile after a release
|
||||
|
||||
The user rarely opens the Expo dashboard. A failed EAS build can sit silently until users complain about a stale version. After every stable release, set up a long-delay babysit that re-checks both EAS builds and GitHub Actions for the release tag. If anything is `ERRORED` or `FAILED`, surface it immediately. If everything is `FINISHED`/`SUCCESS`, confirm and stop.
|
||||
|
||||
**Use a heartbeat schedule, never a new-agent schedule.** Babysitting fires back into the current conversation as a wake-up prompt — `target: "self"` in `mcp__paseo__create_schedule`. Never use `target: "new-agent"`. A new agent spawns a fresh conversation the user has to find and read; a heartbeat surfaces the build status inline in the conversation that owns the release, where it is impossible to miss. If you find yourself reaching for `new-agent` for a release babysit, you are about to ship a status report into a void.
|
||||
|
||||
Pattern:
|
||||
|
||||
```jsonc
|
||||
// mcp__paseo__create_schedule arguments
|
||||
{
|
||||
"name": "vX.Y.Z release babysit heartbeat",
|
||||
"every": "15m",
|
||||
"maxRuns": 8, // covers ~2h of build + store-submission window
|
||||
"target": "self", // heartbeat, NOT "new-agent"
|
||||
"cwd": "/path/to/paseo",
|
||||
"prompt": "Heartbeat: check vX.Y.Z release builds. Run gh run list + eas build:list, report concisely; flag any ERRORED/FAILED/CANCELED.",
|
||||
}
|
||||
```
|
||||
|
||||
Tight cadence on purpose. The first run fires immediately, giving a near-real-time status check before the conversation closes. Subsequent runs at 15-minute intervals catch transitions quickly: a failed EAS build that errors at +20m should not wait until +50m to surface. Keep the prompt short — the heartbeat is a status probe, not a research task — and have it bail out as soon as everything is green so the remaining runs do not generate noise.
|
||||
|
||||
## Release notes on GitHub
|
||||
|
||||
The GitHub Release body is populated automatically by the `Release Notes Sync` workflow (`.github/workflows/release-notes-sync.yml`). It triggers on every `v*` tag push and on any push to `main` that touches `CHANGELOG.md`, then runs `scripts/sync-release-notes-from-changelog.mjs` to mirror the matching changelog entry into the release body. You don't need to write release notes on GitHub manually — keep `CHANGELOG.md` correct and the workflow will sync it. To force a re-sync, dispatch the workflow with the tag input.
|
||||
|
||||
## Website behavior
|
||||
|
||||
- The website download page points to GitHub's latest published **stable** release.
|
||||
- Published beta prereleases are public on GitHub Releases, but they do **not** become the website download target.
|
||||
- The website only moves when you publish the final stable release tag like `v0.1.41`.
|
||||
- The website itself is deployed by `Deploy Website` (Cloudflare Workers), which redeploys on `release: published` for non-prerelease releases and on pushes to `main` that touch `CHANGELOG.md` or `packages/website/**`.
|
||||
|
||||
## Fixing a failed release build
|
||||
|
||||
@@ -202,18 +288,15 @@ Release notes depend on the changelog heading format. The heading **must** be st
|
||||
|
||||
```
|
||||
## X.Y.Z - YYYY-MM-DD
|
||||
## X.Y.Z-beta.N - YYYY-MM-DD
|
||||
```
|
||||
|
||||
No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to extract the version. A malformed heading will break download links on the homepage.
|
||||
|
||||
## Changelog policy
|
||||
|
||||
- `CHANGELOG.md` includes stable releases and the current beta line.
|
||||
- The first beta inserts a top entry like `## 0.1.60-beta.1 - YYYY-MM-DD`.
|
||||
- The next beta updates that same top entry in place, for example from `0.1.60-beta.1` to `0.1.60-beta.2`.
|
||||
- Stable promotion updates that same entry in place, for example from `0.1.60-beta.2` to `0.1.60`.
|
||||
- Do not create duplicate entries for each beta on the same version line.
|
||||
- `CHANGELOG.md` only lists stable releases. Betas are silent.
|
||||
- The changelog entry is authored once, at stable promotion time, with the date set to the promotion day.
|
||||
- It covers the full diff from the previous stable tag, regardless of how many betas were cut in between.
|
||||
|
||||
## Changelog ownership
|
||||
|
||||
@@ -225,7 +308,20 @@ No prefix (`v`), no extra text. The parser matches the first `## X.Y.Z` line to
|
||||
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`.
|
||||
- **Never mention component names, internal modules, or implementation details.** No `WorkingIndicator`, no `accumulatedUsage`, no `reconcileAndEmitWorkspaceUpdates`. Also no "virtualized lists", no "remount", no "memoization", no "debounced", no "fuzzy ranking", no "controlled input", no "uncontrolled input" — these are implementation words masquerading as user-facing copy.
|
||||
- **Concrete WRONG → RIGHT examples** (real mistakes from past releases):
|
||||
|
||||
| Wrong (implementation-facing) | Right (user-facing) |
|
||||
| ----------------------------------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| Switching layouts no longer remounts the active agent | Splitting a pane no longer loses your scroll position |
|
||||
| Model, mode, and thinking pickers — searchable virtualized lists with fuzzy ranking | Mobile model selector is faster and more straightforward |
|
||||
| Text inputs in mobile sheets no longer flicker while typing fast | Typing in mobile sheets no longer flickers |
|
||||
| Compact web sheets no longer crash when swiped to dismiss | Sheets on mobile web no longer crash when swiped to dismiss |
|
||||
| Reduced re-renders in the agent list | Agent list scrolls smoothly |
|
||||
| Added debouncing to the search input | Search results no longer lag behind typing |
|
||||
|
||||
Test: would a non-developer reader recognise what changed when using the app? If they'd need an engineer to translate ("what's a remount?"), the bullet is still implementation-facing — rewrite it as the symptom the user experiences.
|
||||
|
||||
- **Collapse internal iterations.** If a feature was added and then fixed within the same release, just list the feature as working. Users never saw the broken version.
|
||||
- **Only list changes relative to the previous stable release.** The diff is `v(previous)..HEAD`. If something was introduced and fixed between those two tags, it never shipped — don't mention the fix.
|
||||
- **Common trap:** when drafting from `git log`, every commit looks like a separate bullet — including the "fix X" commits that landed on top of a brand-new feature in the same release window. Before listing a Fixed entry, check whether the thing being fixed was itself added in this same release. If so, drop the fix and fold it into the feature bullet.
|
||||
@@ -236,9 +332,12 @@ The changelog is shown on the Paseo homepage. Write it for **end users**, not de
|
||||
|
||||
Every bullet must be scannable at a glance. The changelog is not release documentation — it's a list.
|
||||
|
||||
- **One sentence per bullet, max.** If a bullet contains two sentences, the second one is doing work that belongs in product docs, not the changelog. Cut it.
|
||||
- **No trailing periods.** Bullets are list items, not prose. Drop the period at the end of every bullet, including the period inside any bolded lead-in. `**Configurable terminal scrollback**` not `**Configurable terminal scrollback.**`.
|
||||
- **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 what the user can do, not the mechanism.** The reader cares about the capability, not how it works under the hood. Do not explain LAN vs WAN, TLS handshakes, IPC, the daemon-relay topology, or any internal concept the user has not asked about. "Self-hosted relays can use a different TLS setting for the public endpoint" — not "Self-hosted relays support a separate TLS setting for the public endpoint, so the daemon can reach the relay over the LAN while the phone reaches it over the public secure address." If a feature genuinely needs background to be understood, it belongs in product docs, with a one-line teaser in the changelog.
|
||||
- **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.
|
||||
|
||||
@@ -281,7 +380,7 @@ Entries within each section (Added, Improved, Fixed) are ordered by user impact:
|
||||
|
||||
## Pre-release sanity check
|
||||
|
||||
Before cutting any release (beta or stable), run a Codex review of the diff as a last line of defence against shipping bugs.
|
||||
Before cutting a **stable** release, run a Codex review of the diff as a last line of defence against shipping bugs. Skip this for betas — the beta itself is the smoke test, and gating each beta on a code review defeats the point of using betas as fast release candidates.
|
||||
|
||||
Load the `paseo` skill and launch a **Codex 5.4** agent with a prompt like:
|
||||
|
||||
@@ -297,15 +396,19 @@ The agent's job is a deep sanity check, not a full code review. If it flags anyt
|
||||
|
||||
## Changelog scope
|
||||
|
||||
The changelog always covers **stable-to-HEAD**:
|
||||
|
||||
- **Beta release**: the diff and release notes cover `latest stable tag -> HEAD`. The current beta changelog entry is updated in place.
|
||||
- **Stable release**: the same changelog entry is promoted in place. It still captures the full delta from the previous stable release, not just what changed since the last beta.
|
||||
|
||||
In other words, betas are checkpoints along the way; the changelog entry remains the single record for the final jump from one stable version to the next.
|
||||
The changelog covers **stable-to-stable**. Betas are not represented. When you promote, draft the entry from the diff between the previous stable tag and `HEAD`, ignoring beta tag boundaries — they're just checkpoints along the way.
|
||||
|
||||
## Completion checklist
|
||||
|
||||
### Beta release
|
||||
|
||||
- [ ] Working tree is clean and the intended commit is on `main`
|
||||
- [ ] `npm run release:beta:patch` (or `:next`) completes successfully
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*-beta.N` tag is green
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
|
||||
### Stable release (or promotion)
|
||||
|
||||
- [ ] Run the pre-release sanity check (see above) and address any findings
|
||||
- [ ] Ensure the intended release commit is already committed and the git worktree is clean before running any `release:*` patch/promote command
|
||||
- [ ] Ensure local `npm run typecheck` passes on that exact commit before running any `release:*` patch/promote command
|
||||
@@ -314,4 +417,5 @@ In other words, betas are checkpoints along the way; the changelog entry remains
|
||||
- [ ] `npm run release:patch` or `npm run release:promote` completes successfully
|
||||
- [ ] GitHub `Desktop Release` workflow for the `v*` tag is green
|
||||
- [ ] GitHub `Android APK Release` workflow for the same tag is green
|
||||
- [ ] EAS `release-mobile.yml` workflow for the same tag is green
|
||||
- [ ] EAS iOS production build for the same tag completes and submits via Fastlane
|
||||
- [ ] EAS Android production build for the same tag completes and auto-submits to the Play Store
|
||||
|
||||
84
docs/rpc-namespacing.md
Normal file
84
docs/rpc-namespacing.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# RPC Namespacing
|
||||
|
||||
New WebSocket session RPCs use dotted names with the direction as the final segment:
|
||||
|
||||
```ts
|
||||
checkout.github.set_auto_merge.request;
|
||||
checkout.github.set_auto_merge.response;
|
||||
```
|
||||
|
||||
The namespace reads left to right:
|
||||
|
||||
- Domain: `checkout`
|
||||
- Provider or subsystem: `github`
|
||||
- Operation: `set_auto_merge`; this segment is a verb, not a noun. If you would name an RPC `noun.request`, name it `get_noun.request` instead.
|
||||
- Direction: `request` or `response`
|
||||
|
||||
Use dots, not slashes. Dots are protocol namespaces; slashes imply paths or transport routing.
|
||||
|
||||
## Request/Response Pairs
|
||||
|
||||
For ordinary correlated RPCs, a `.request` has a matching `.response` with the same prefix. The daemon client may derive the response type mechanically:
|
||||
|
||||
```ts
|
||||
checkout.github.set_auto_merge.request;
|
||||
// -> checkout.github.set_auto_merge.response
|
||||
```
|
||||
|
||||
Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema.
|
||||
|
||||
## Message Shape
|
||||
|
||||
Requests keep their parameters at the top level:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: "checkout.github.set_auto_merge.request",
|
||||
cwd: "/repo",
|
||||
enabled: true,
|
||||
mergeMethod: "squash",
|
||||
requestId: "req_123"
|
||||
}
|
||||
```
|
||||
|
||||
Responses put correlated result data under `payload`:
|
||||
|
||||
```ts
|
||||
{
|
||||
type: "checkout.github.set_auto_merge.response",
|
||||
payload: {
|
||||
cwd: "/repo",
|
||||
enabled: true,
|
||||
success: true,
|
||||
error: null,
|
||||
requestId: "req_123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep `requestId` in both request and response payloads. It is the correlation key.
|
||||
|
||||
## Provider Namespacing
|
||||
|
||||
Provider-specific behavior belongs under the provider segment:
|
||||
|
||||
- `checkout.github.*` for GitHub-specific checkout operations
|
||||
- `checkout.gitlab.*` for future GitLab-specific checkout operations
|
||||
|
||||
Do not put GitHub-specific enums or semantics into generic checkout RPC names. A generic RPC should only exist when the behavior is genuinely provider-neutral.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The existing flat RPC names remain part of the protocol until they are intentionally migrated:
|
||||
|
||||
```ts
|
||||
checkout_pr_merge_request;
|
||||
checkout_pr_merge_response;
|
||||
```
|
||||
|
||||
Do not add new flat names. When migrating old RPCs, keep protocol compatibility rules in mind:
|
||||
|
||||
- Add the new names first.
|
||||
- Gate new feature behavior through `server_info.features.*` when an old host cannot support it.
|
||||
- Keep old names accepted until the compatibility window expires.
|
||||
- Mark shims with `COMPAT(...)` and a removal date.
|
||||
@@ -98,6 +98,39 @@ When a test is labeled end-to-end, it calls the real service. No environment var
|
||||
- Test bodies should read like plain English
|
||||
- Build a vocabulary of test helpers that make complex flows simple
|
||||
|
||||
### File naming
|
||||
|
||||
Vitest picks up tests by suffix. The suffix tells the runner which category it belongs to.
|
||||
|
||||
| Suffix | What it is | Where it runs |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `*.test.ts(x)` | Unit test — pure, fast, no daemon | `npm run test:unit` |
|
||||
| `*.posix.test.ts` | Unit test that needs POSIX-only behavior | unit, skipped on Windows |
|
||||
| `*.browser.test.ts` | App test that needs a real browser (DOM) | `npm run test:browser` (Vitest browser mode, Playwright provider, headless Chromium) |
|
||||
| `*.e2e.test.ts` | End-to-end against a real daemon | `npm run test:e2e` |
|
||||
| `*.real.e2e.test.ts` | E2E that hits a real provider (Claude/Codex/Copilot/OpenCode/Pi) — needs creds in `packages/server/.env.test` | `npm run test:integration:real` / `test:e2e:real` |
|
||||
| `*.local.e2e.test.ts` | E2E that needs a local-only resource | `npm run test:integration:local` / `test:e2e:local` |
|
||||
|
||||
App-level Playwright browser E2E lives in `packages/app/e2e/*.spec.ts` and runs via `npm run test:e2e --workspace=@getpaseo/app` (separate from Vitest E2E). App Playwright specs that hit real providers use `*.real.spec.ts` and run through `npm run test:e2e:real --workspace=@getpaseo/app`; the default app E2E project ignores that suffix so CI does not need provider credentials.
|
||||
|
||||
Live provider smoke tests belong in `*.real.e2e.test.ts`, not `*.test.ts`, even when guarded by environment variables. Default unit suites must use deterministic provider adapters/fakes so missing credits, auth outages, and upstream model drift do not block normal CI.
|
||||
|
||||
### Test setup
|
||||
|
||||
- Server: `packages/server/src/test-utils/vitest-setup.ts` loads `.env.test`, sets `PASEO_SUPERVISED=0`, and disables Git/SSH prompts. Add new global env shims here, not in individual tests.
|
||||
- App: `packages/app/vitest.setup.ts` provides `expo`/`__DEV__` shims and stubs a few native-only modules (`react-native-unistyles`, `react-native-svg`, `expo-linking`, `@xterm/addon-ligatures`). Stubbing here is for modules that have no meaningful Node behavior — not a license to mock app code.
|
||||
|
||||
## Running tests locally
|
||||
|
||||
Test suites in this repo are heavy. Running them in bulk freezes the machine, especially with multiple agents in parallel.
|
||||
|
||||
- Run only the file you changed: `npx vitest run <path> --bail=1`
|
||||
- Never run `npm run test` for a whole workspace unless asked.
|
||||
- For a broad sweep, redirect to a file and read it after: `npx vitest run <path> --bail=1 > /tmp/test-output.txt 2>&1`
|
||||
- Never re-run a suite another agent already reported green.
|
||||
- For full-suite confidence, push to CI and check GitHub Actions.
|
||||
- Never run the full Playwright E2E suite locally — defer whole-suite verification to CI. Targeted Playwright specs are allowed when you changed or need to prove that specific flow.
|
||||
|
||||
## Agent authentication in tests
|
||||
|
||||
Agent providers handle their own auth. Do not add auth checks, environment variable gates, or conditional skips to tests. If auth fails, report it.
|
||||
@@ -124,3 +157,12 @@ If code isn't testable, refactor it. Signs:
|
||||
- Setup requires too much global state
|
||||
|
||||
Aim for deep modules: small interface, deep implementation. Fewer methods = fewer tests needed, simpler params = simpler setup.
|
||||
|
||||
## Two test categories, no others
|
||||
|
||||
Every test in this repo lives in exactly one of these shapes:
|
||||
|
||||
1. **Unit tests with ports and adapters** — production code receives its real-world dependencies (DB, HTTP, CLI process, clock, randomness, filesystem, other modules) through an injected interface. Tests wire a typed in-memory fake colocated with the production module. **No `vi.mock`, `vi.hoisted`, `vi.spyOn` of own exports, JSDOM, `@testing-library` component mounting, RN test renderer, monkey-patched globals, or fake-server fixtures.** If a test needs any of those, the production module is missing a port — fix the seam, then write the test against a fake adapter.
|
||||
2. **Real end-to-end tests** — real daemon, real network, real browser (Playwright for app code) or a real isolated server instance (for daemon code). No JSDOM, no mocked transport.
|
||||
|
||||
Anything in between — component tests in JSDOM, vitest tests that mock the module under test, tests that assert on private state — is slop on its way out.
|
||||
|
||||
42
docs/timeline-sync.md
Normal file
42
docs/timeline-sync.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Timeline sync
|
||||
|
||||
Agent chat delivery has two paths:
|
||||
|
||||
1. **Live stream** — `agent_stream` WebSocket messages for immediacy.
|
||||
2. **Authoritative history** — `fetch_agent_timeline_request` for correctness.
|
||||
|
||||
The invariant is:
|
||||
|
||||
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
|
||||
|
||||
## Presence is not delivery
|
||||
|
||||
Client heartbeat reports presence:
|
||||
|
||||
- device type
|
||||
- app visibility
|
||||
- focused agent
|
||||
- last activity time
|
||||
|
||||
Heartbeat is used for notification routing. It must not be used as a correctness gate for `agent_stream` delivery. A stale mobile focus heartbeat may affect whether the user gets notified; it must not make timeline rows disappear from the live stream.
|
||||
|
||||
## Catch-up is paged but complete
|
||||
|
||||
Large unbounded timeline responses can exceed relay frame limits, so catch-up uses bounded pages. Bounded does not mean partial.
|
||||
|
||||
When the app fetches `direction: "after"` and the daemon responds with `hasNewer: true`, the app must immediately fetch the next page from `endCursor`. The catch-up is complete only when `hasNewer: false`.
|
||||
|
||||
The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward.
|
||||
|
||||
## Resume behavior
|
||||
|
||||
When a client resumes with a known cursor, it catches up after that cursor to completion. It does not replace the view with a latest tail page, because tail pagination can skip the middle of a long background run.
|
||||
|
||||
When a client resumes without a cursor, it fetches the latest tail page.
|
||||
|
||||
## Relevant code
|
||||
|
||||
- Server live stream forwarding: `packages/server/src/server/session.ts`
|
||||
- App sync planning: `packages/app/src/timeline/timeline-sync-plan.ts`
|
||||
- App stream/timeline reducer: `packages/app/src/timeline/session-stream-reducers.ts`
|
||||
- Session wiring: `packages/app/src/contexts/session-context.tsx`
|
||||
@@ -4,15 +4,17 @@ This app uses [`react-native-unistyles` v3](https://www.unistyl.es/) for theme-a
|
||||
|
||||
That model is powerful, but it has sharp edges. Use this note when adding theme-dependent styles.
|
||||
|
||||
## STOP — `useUnistyles()` Is Forbidden
|
||||
## STOP — `useUnistyles()` Is Banned
|
||||
|
||||
**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):
|
||||
**Do not call `useUnistyles()`. Anywhere. New code MUST NOT add a call; existing call sites are tolerated only because nobody has rewritten them yet and will be converted as they are touched.** 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.
|
||||
We have hit this gotcha repeatedly in Paseo. The hook subscribes the component to **every** Unistyles runtime change (theme, breakpoint, insets, color scheme, scale) and returns a fresh object reference each call. That means a periodic lockstep re-render of warm subtrees (agent streams, panels, sidebars) even when nothing the user can see has changed — confirmed in profiling, with `theme` as the only changed input every cycle. It 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:
|
||||
Reviewers MUST reject PRs that introduce a new `useUnistyles()` call. There is no last-resort carveout. If you cannot solve a case with the alternatives below, file an issue and stop — do not paper over it with the hook.
|
||||
|
||||
Use these alternatives in order:
|
||||
|
||||
### 1. `StyleSheet.create((theme) => ...)` — default
|
||||
|
||||
@@ -46,31 +48,9 @@ const ThemedBlur = withUnistyles(BlurView);
|
||||
|
||||
(Mind the `> *` child-selector leak documented further down.)
|
||||
|
||||
### 4. Lift the read into a tiny leaf component
|
||||
### 4. There is no "last resort"
|
||||
|
||||
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.
|
||||
There is no escape hatch. If none of (1)–(3) fit, the problem is upstream — fix it there or file an issue. The hook is not on the table.
|
||||
|
||||
## How Updates Propagate
|
||||
|
||||
@@ -80,6 +60,34 @@ The important detail: the automatic native path tracks `props.style`. It does no
|
||||
|
||||
[`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.
|
||||
|
||||
## Dynamic Pixel Styles On Web
|
||||
|
||||
Avoid feeding changing pixel values such as `{ top, left }`, `{ maxHeight }`, or `{ minWidth }` into the `style` prop of Unistyles-managed React Native components on web. The web runtime hashes each distinct style object by value and appends a CSS rule to `#unistyles-web`; those rules are not reclaimed during the page lifetime, so pointer-driven positioning can turn into steady stylesheet growth.
|
||||
|
||||
Use the inline style escape hatch below for high-churn values. Do not split a component into plain/web/native variants just to keep one measured value out of the CSS registry. Raw DOM wrappers are reserved for real DOM infrastructure, such as terminal hosts, virtualized web rows, or third-party drag wrappers.
|
||||
|
||||
## Inline Style Escape Hatch
|
||||
|
||||
When a style value is high-churn and must bypass Unistyles' CSS registry, keep the component on the normal Unistyles path and mark only that style object with `inlineUnistylesStyle`.
|
||||
|
||||
```tsx
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
thumb: {
|
||||
position: "absolute",
|
||||
},
|
||||
});
|
||||
|
||||
<View style={[styles.thumb, inlineUnistylesStyle({ height, transform: [{ translateY }] })]} />;
|
||||
```
|
||||
|
||||
This uses Unistyles' own animated-style lane: ordinary styles still become Unistyles classes, while the marked style object stays in React Native's inline style array. Use it for measured geometry, scroll or drag transforms, and pressed/hovered/open state where generating CSS classes is the wrong ownership boundary.
|
||||
|
||||
Do not split a component into plain and Unistyles variants just to handle one high-churn value. The component remains a normal Unistyles component; only the specific style object escapes.
|
||||
|
||||
When a reusable component has a prop whose whole job is dynamic geometry, make that prop the seam. For example, `FloatingSurface.frameStyle` and `FloatingScrollView.style` own their own escape hatch so menu, tooltip, hover-card, and combobox callers can stay declarative instead of knowing about Unistyles internals.
|
||||
|
||||
## 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.
|
||||
@@ -124,18 +132,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
|
||||
This is the pattern used by the settings screen: the screen background lives on a normal `View style={styles.container}`, while the scroll content container only carries layout.
|
||||
|
||||
When the content container itself needs themed behavior, wrap the component with [`withUnistyles`](https://www.unistyl.es/v3/references/with-unistyles):
|
||||
In practice the wrapper-`View` pattern is the one we use. Across the app, `withUnistyles` is now reserved for wrapping leaf components — mostly lucide icons (`ThemedActivityIndicator`, `ThemedChevronDown`, …) and small third-party components like `MarkdownWithStableRenderer` — so they pick up theme-reactive `color`/`tintColor` props without re-rendering their parent.
|
||||
|
||||
```tsx
|
||||
import { ScrollView } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
|
||||
const ThemedScrollView = withUnistyles(ScrollView);
|
||||
|
||||
<ThemedScrollView style={styles.scrollView} contentContainerStyle={styles.contentContainer} />;
|
||||
```
|
||||
|
||||
`withUnistyles` extracts dependency metadata from both `style` and `contentContainerStyle`, subscribes to the relevant theme/runtime changes, and re-renders only that wrapped component when needed. Its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props) is the reason it fixes themed `ScrollView` content containers. Reach for it when wrapper-view layout would be awkward or when a third-party component needs theme-aware non-`style` props mapped through Unistyles.
|
||||
In principle, [`withUnistyles`](https://www.unistyl.es/v3/references/with-unistyles) can also wrap a `ScrollView` to make `contentContainerStyle` theme-reactive via its [auto-mapping behavior for `style` and `contentContainerStyle`](https://www.unistyl.es/v3/references/with-unistyles#auto-mapping-for-style-and-contentcontainerstyle-props). We previously did this on the welcome screen and hit the `> *` child-selector leak documented below; we have since moved the welcome screen to the wrapper-`View` pattern. If you find yourself reaching for `withUnistyles(ScrollView)`, treat it as a smell and check whether a wrapper view works first.
|
||||
|
||||
The smallest escape hatch is to use `useUnistyles()` and pass an inline value through React:
|
||||
|
||||
@@ -155,7 +154,7 @@ Use this sparingly. It works because React re-renders the prop, but it gives up
|
||||
|
||||
The sharp edge: Unistyles hashes styles by value. If `withUnistyles` receives a style whose value is **identical** to a style used elsewhere in the app on a plain `View`, both usages get the same hash — and both CSS rules (the element rule and the `> *` child rule) are emitted under the same class name. The `> *` rule then leaks onto the direct children of every `View` that shares the hash.
|
||||
|
||||
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
|
||||
Concrete regression we hit: `welcome-screen.tsx` had `const ThemedScrollView = withUnistyles(ScrollView)` with `style={{ flex: 1, backgroundColor: theme.colors.surface0 }}`. `panels/agent-panel.tsx` had `root` and `container` styles with the exact same value. All three collided on class `unistyles_j2k2iilhfz`, so the browser stylesheet contained:
|
||||
|
||||
```css
|
||||
.unistyles_j2k2iilhfz {
|
||||
@@ -231,15 +230,49 @@ If a style factory is cheap, skipping `useMemo` entirely is also fine.
|
||||
|
||||
Do not import `theme` from `@/styles/theme` for live UI colors. That export is a dark-theme compatibility default, so using it in render code leaves icons, placeholders, or third-party props pinned to dark colors in light mode.
|
||||
|
||||
Use `useUnistyles()` inside the component instead:
|
||||
Wrap the icon (or other leaf component) with `withUnistyles` instead, so only that node re-renders when the theme changes:
|
||||
|
||||
```tsx
|
||||
const { theme } = useUnistyles();
|
||||
import { ChevronDown } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
|
||||
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />;
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
icon: { color: theme.colors.foregroundMuted },
|
||||
}));
|
||||
|
||||
<ThemedChevronDown size={theme.iconSize.md} style={styles.icon} />;
|
||||
```
|
||||
|
||||
Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
|
||||
This is the dominant pattern in the app today (see `sidebar-workspace-list.tsx`, `message.tsx`, the workspace screens). Reserve `useUnistyles()` for the last-resort cases described at the top of this file. Importing `baseColors`, theme-name constants, or `type Theme` is fine when the value is intentionally static or type-only.
|
||||
|
||||
## Reanimated `Animated.View` + Dynamic Styles Crashes
|
||||
|
||||
Do not apply `StyleSheet.create((theme) => ...)` styles to a Reanimated `Animated.View`. Unistyles wraps styled components in a `<UnistylesComponent>` and patches native view props from C++ via the ShadowRegistry. Reanimated also reaches into the same native node from its worklet runtime. When a theme change fires, both systems try to mutate the same node and the app crashes with `Unable to find node on an unmounted component.` This was a real iOS sidebar crash on theme toggle (commit `4896cfe9`).
|
||||
|
||||
Fix: keep static positioning on the `Animated.View` in plain React Native `StyleSheet`, and pass theme-dependent values (e.g. `backgroundColor`) as inline style from `useUnistyles()` — the inline path is acceptable here because no other escape works:
|
||||
|
||||
```tsx
|
||||
import { StyleSheet as RNStyleSheet } from "react-native";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
|
||||
const positionStyles = RNStyleSheet.create({
|
||||
sidebar: { position: "absolute", inset: 0, width: 280 },
|
||||
});
|
||||
|
||||
function Sidebar() {
|
||||
const { theme } = useUnistyles();
|
||||
return (
|
||||
<Animated.View
|
||||
style={[positionStyles.sidebar, animatedStyle, { backgroundColor: theme.colors.surface1 }]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
This is one of the rare places `useUnistyles()` is the right tool: there is no `withUnistyles(Animated.View)` equivalent, the affected component is small, and the alternative is a crash.
|
||||
|
||||
## Adaptive Themes And Persisted Settings
|
||||
|
||||
|
||||
@@ -26,11 +26,18 @@
|
||||
let
|
||||
pkgs = pkgsFor system;
|
||||
paseo = pkgs.callPackage ./nix/package.nix { };
|
||||
isLinux = nixpkgs.lib.elem system [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
];
|
||||
in
|
||||
{
|
||||
default = paseo;
|
||||
paseo = paseo;
|
||||
}
|
||||
// nixpkgs.lib.optionalAttrs isLinux {
|
||||
desktop = pkgs.callPackage ./nix/desktop-package.nix { inherit paseo; };
|
||||
}
|
||||
);
|
||||
|
||||
nixosModules.default = self.nixosModules.paseo;
|
||||
|
||||
@@ -3,6 +3,9 @@ pre-commit:
|
||||
jobs:
|
||||
- name: format
|
||||
glob: "*.{css,js,json,jsonc,jsx,md,ts,tsx,yaml,yml}"
|
||||
exclude:
|
||||
- "package-lock.json"
|
||||
- "**/package-lock.json"
|
||||
run: npm run format:check:files -- {staged_files}
|
||||
- name: lint
|
||||
glob: "*.{js,jsx,ts,tsx}"
|
||||
|
||||
159
nix/desktop-package.nix
Normal file
159
nix/desktop-package.nix
Normal file
@@ -0,0 +1,159 @@
|
||||
{
|
||||
lib,
|
||||
stdenv,
|
||||
buildNpmPackage,
|
||||
nodejs_22,
|
||||
python3,
|
||||
makeWrapper,
|
||||
copyDesktopItems,
|
||||
makeDesktopItem,
|
||||
electron,
|
||||
libuv,
|
||||
# Reuse the daemon's prebuilt npm-deps FOD. Same lockfile, same content —
|
||||
# without this, the desktop drv produces a separately-named store path
|
||||
# (`paseo-desktop-<v>-npm-deps`) and refetches the entire registry. Override
|
||||
# the upstream hash via `paseo.override { npmDepsHash = "..."; }`.
|
||||
paseo,
|
||||
}:
|
||||
|
||||
buildNpmPackage rec {
|
||||
pname = "paseo-desktop";
|
||||
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
|
||||
|
||||
src = lib.cleanSourceWith {
|
||||
src = ./..;
|
||||
filter =
|
||||
path: type:
|
||||
let
|
||||
baseName = builtins.baseNameOf path;
|
||||
relPath = lib.removePrefix (toString ./..) path;
|
||||
in
|
||||
# Exclude mobile-only platform code (we only need the web/electron build)
|
||||
!(lib.hasPrefix "/packages/app/android" relPath)
|
||||
&& !(lib.hasPrefix "/packages/app/ios" relPath)
|
||||
# Website is unrelated to the desktop app
|
||||
&& !(lib.hasPrefix "/packages/website" relPath)
|
||||
# Test fixtures and build artifacts
|
||||
&& !(lib.hasSuffix ".test.ts" baseName)
|
||||
&& !(lib.hasSuffix ".e2e.test.ts" baseName)
|
||||
&& baseName != "node_modules"
|
||||
&& baseName != ".git"
|
||||
&& baseName != ".paseo"
|
||||
&& baseName != ".DS_Store"
|
||||
&& baseName != "release";
|
||||
};
|
||||
|
||||
nodejs = nodejs_22;
|
||||
inherit (paseo) npmDeps;
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild. We manually rebuild only node-pty in buildPhase.
|
||||
npmRebuildFlags = [ "--ignore-scripts" ];
|
||||
|
||||
nativeBuildInputs = [
|
||||
python3 # for node-gyp (node-pty)
|
||||
makeWrapper
|
||||
copyDesktopItems
|
||||
];
|
||||
|
||||
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [ libuv ];
|
||||
|
||||
dontNpmBuild = true;
|
||||
|
||||
env = {
|
||||
EXPO_NO_TELEMETRY = "1";
|
||||
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
|
||||
# to phone home during the build.
|
||||
CI = "1";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
|
||||
# Native deps (terminal emulation; libuv-linked on Linux)
|
||||
npm rebuild node-pty
|
||||
|
||||
# Server workspaces (highlight + relay + protocol + client + server + cli)
|
||||
npm run build:server
|
||||
|
||||
# App workspace deps not covered by build:server
|
||||
npm run build --workspace=@getpaseo/expo-two-way-audio
|
||||
|
||||
# Expo web export for the Electron renderer
|
||||
( cd packages/app && PASEO_WEB_PLATFORM=electron npx expo export --platform web )
|
||||
|
||||
# Desktop main process (tsc only — NOT electron-builder)
|
||||
npm run build:main --workspace=@getpaseo/desktop
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/share/paseo-desktop $out/bin
|
||||
|
||||
# Preserve the monorepo layout so main.js's dev-mode path resolution
|
||||
# (`__dirname/../../app/dist`, `__dirname/../assets/icon.png`) works
|
||||
# without patching: invoked unpackaged via `electron path/to/main.js`,
|
||||
# `app.isPackaged` is false, so these relative paths are used.
|
||||
#
|
||||
# Copy the entire packages/ tree (not just built artifacts) because npm
|
||||
# creates workspace symlinks from node_modules/@getpaseo/* into packages/*.
|
||||
# Missing any workspace package leaves dangling symlinks and fails the
|
||||
# noBrokenSymlinks output check. The cleanSourceWith filter above already
|
||||
# drops the big platform-specific things (android/ios, website, tests).
|
||||
cp package.json $out/share/paseo-desktop/
|
||||
cp -a packages $out/share/paseo-desktop/
|
||||
cp -a node_modules $out/share/paseo-desktop/
|
||||
|
||||
# Skills directory referenced at runtime by some agents
|
||||
if [ -d skills ]; then
|
||||
cp -a skills $out/share/paseo-desktop/
|
||||
fi
|
||||
|
||||
# Hicolor icon for desktop environments
|
||||
install -Dm644 packages/desktop/assets/icon.png \
|
||||
$out/share/icons/hicolor/512x512/apps/paseo-desktop.png
|
||||
|
||||
# Launcher wraps nixpkgs electron.
|
||||
# --no-sandbox: Chromium's setuid sandbox can't live in /nix/store
|
||||
# (immutable, no setuid). Acceptable for v1; a follow-up can wire
|
||||
# `security.wrappers` via a NixOS module for users who want the sandbox.
|
||||
#
|
||||
# EXPO_DEV_URL: We run unpackaged via `electron path/to/main.js`, so
|
||||
# `app.isPackaged` is false. In that mode main.ts loads `DEV_SERVER_URL`
|
||||
# (defaults to http://localhost:8081 — the Expo dev server, which doesn't
|
||||
# exist here). Point it at the `paseo://` protocol handler instead, which
|
||||
# serves from `__dirname/../../app/dist` (our install layout matches).
|
||||
makeWrapper ${electron}/bin/electron $out/bin/paseo-desktop \
|
||||
--add-flags "$out/share/paseo-desktop/packages/desktop/dist/main.js" \
|
||||
--add-flags "--no-sandbox" \
|
||||
--set EXPO_DEV_URL "paseo://app/"
|
||||
|
||||
copyDesktopItems
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
desktopItems = [
|
||||
(makeDesktopItem {
|
||||
name = "paseo-desktop";
|
||||
desktopName = "Paseo";
|
||||
genericName = "AI Coding Agents";
|
||||
comment = "Self-hosted daemon for AI coding agents";
|
||||
exec = "paseo-desktop";
|
||||
icon = "paseo-desktop";
|
||||
categories = [ "Development" ];
|
||||
startupWMClass = "Paseo";
|
||||
})
|
||||
];
|
||||
|
||||
meta = {
|
||||
description = "Paseo desktop app (Electron wrapper)";
|
||||
homepage = "https://github.com/getpaseo/paseo";
|
||||
license = lib.licenses.agpl3Plus;
|
||||
mainProgram = "paseo-desktop";
|
||||
platforms = lib.platforms.linux;
|
||||
};
|
||||
}
|
||||
150
nix/module.nix
150
nix/module.nix
@@ -81,7 +81,59 @@ in
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to enable the relay connection for remote access via app.paseo.sh.";
|
||||
description = ''
|
||||
Whether to enable relay-based remote access. When false, the daemon
|
||||
runs with `--no-relay` and only accepts direct (LAN/loopback)
|
||||
connections.
|
||||
'';
|
||||
};
|
||||
|
||||
mode = lib.mkOption {
|
||||
type = lib.types.enum [ "hosted" "remote" ];
|
||||
default = "hosted";
|
||||
description = ''
|
||||
How the daemon reaches the relay when `relay.enable = true`:
|
||||
|
||||
- `"hosted"` (default): use the upstream `app.paseo.sh` relay.
|
||||
Preserves the current behavior; no extra options needed.
|
||||
- `"remote"`: connect to a self-hosted relay at
|
||||
`relay.host:relay.port`. Sets `PASEO_RELAY_ENDPOINT` and
|
||||
`PASEO_RELAY_USE_TLS` for the daemon.
|
||||
|
||||
A `"local"` mode (running a relay on the same host as a systemd
|
||||
unit) is not yet implemented — the relay package currently only
|
||||
ships a Cloudflare Workers adapter. Tracked separately.
|
||||
'';
|
||||
};
|
||||
|
||||
host = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "";
|
||||
example = "relay.example.com";
|
||||
description = "Relay hostname. Required when `relay.mode = \"remote\"`.";
|
||||
};
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 443;
|
||||
description = "Relay port. Used when `relay.mode = \"remote\"`.";
|
||||
};
|
||||
|
||||
useTls = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Whether to use TLS when connecting to the relay. Used when `relay.mode = \"remote\"`.";
|
||||
};
|
||||
|
||||
publicUseTls = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.bool;
|
||||
default = null;
|
||||
description = ''
|
||||
Whether the public (client-facing) relay endpoint uses TLS.
|
||||
When `null` (default), the daemon falls back to `relay.useTls`.
|
||||
Override when the internal path is plain `ws://` behind a
|
||||
TLS-terminating reverse proxy.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
@@ -94,8 +146,9 @@ in
|
||||
|
||||
When Paseo runs as a real user (not the default system user), AI agents
|
||||
need access to the user's tools (git, ssh, etc.). This adds the user's
|
||||
NixOS profile and system paths so agents can use them without manually
|
||||
setting PATH.
|
||||
NixOS profile, home-manager profile (`~/.nix-profile/bin` and
|
||||
`~/.local/state/nix/profile/bin`), and system paths so agents can use
|
||||
them without manually setting PATH.
|
||||
|
||||
Enabled by default when `user` is set to a non-default value.
|
||||
'';
|
||||
@@ -111,9 +164,50 @@ in
|
||||
'';
|
||||
description = "Extra environment variables for the Paseo daemon.";
|
||||
};
|
||||
|
||||
settings = lib.mkOption {
|
||||
type = (pkgs.formats.json { }).type;
|
||||
default = { };
|
||||
example = lib.literalExpression ''
|
||||
{
|
||||
daemon.mcp = { enabled = true; injectIntoAgents = false; };
|
||||
agents.providers.myAcp = {
|
||||
extends = "acp";
|
||||
label = "My Agent";
|
||||
command = { path = "/run/current-system/sw/bin/my-acp"; };
|
||||
};
|
||||
log.file = { level = "info"; path = "/var/lib/paseo/daemon.log"; };
|
||||
}
|
||||
'';
|
||||
description = ''
|
||||
Declarative content for `$PASEO_HOME/config.json`. Rendered to JSON
|
||||
and installed on every service start.
|
||||
|
||||
Runtime mutations to `config.json` (e.g. via `paseo daemon set-password`
|
||||
or the mobile app toggling MCP injection / provider overrides) are
|
||||
overwritten on the next restart. Pick one: manage via this option, or
|
||||
manage via the CLI — not both.
|
||||
|
||||
The full schema is defined by `PersistedConfigSchema` in
|
||||
`packages/server/src/server/persisted-config.ts`.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
config = lib.mkIf cfg.enable (
|
||||
let
|
||||
settingsFile = (pkgs.formats.json { }).generate "paseo-config.json" cfg.settings;
|
||||
in
|
||||
{
|
||||
assertions = [
|
||||
{
|
||||
assertion = !(cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.host == "");
|
||||
message = ''
|
||||
services.paseo.relay.host must be set when relay.mode = "remote".
|
||||
'';
|
||||
}
|
||||
];
|
||||
|
||||
users.users.${cfg.user} = lib.mkIf (cfg.user == "paseo") {
|
||||
isSystemUser = true;
|
||||
group = cfg.group;
|
||||
@@ -131,24 +225,47 @@ in
|
||||
after = [ "network.target" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
|
||||
preStart = lib.mkIf (cfg.settings != { }) ''
|
||||
install -m 0600 ${settingsFile} ${cfg.dataDir}/config.json
|
||||
'';
|
||||
|
||||
environment = {
|
||||
NODE_ENV = "production";
|
||||
PASEO_HOME = cfg.dataDir;
|
||||
PASEO_LISTEN = "${cfg.listenAddress}:${toString cfg.port}";
|
||||
} // lib.optionalAttrs cfg.inheritUserEnvironment {
|
||||
# mkForce overrides the default PATH from NixOS's systemd module (which
|
||||
# only includes store paths for coreutils/grep/sed/systemd). Our PATH
|
||||
# includes /run/current-system/sw/bin which is a superset of those.
|
||||
PATH = lib.mkForce (lib.concatStringsSep ":" [
|
||||
"/etc/profiles/per-user/${cfg.user}/bin"
|
||||
"/run/current-system/sw/bin"
|
||||
"/run/wrappers/bin"
|
||||
"/nix/var/nix/profiles/default/bin"
|
||||
]);
|
||||
} // lib.optionalAttrs (cfg.hostnames == true) {
|
||||
} // lib.optionalAttrs cfg.inheritUserEnvironment (
|
||||
let
|
||||
# Match dataDir's convention. We can't read users.users.<name>.home
|
||||
# because the user may be managed outside NixOS.
|
||||
userHome = "/home/${cfg.user}";
|
||||
in {
|
||||
# mkForce overrides the default PATH from NixOS's systemd module (which
|
||||
# only includes store paths for coreutils/grep/sed/systemd). When the
|
||||
# daemon runs as a real user, also include home-manager profile paths
|
||||
# so user-installed CLIs (claude, opencode, codex, ...) are reachable
|
||||
# by agent processes the daemon spawns.
|
||||
PATH = lib.mkForce (lib.concatStringsSep ":" (
|
||||
lib.optionals (cfg.user != "paseo") [
|
||||
"${userHome}/.nix-profile/bin"
|
||||
"${userHome}/.local/state/nix/profile/bin"
|
||||
]
|
||||
++ [
|
||||
"/etc/profiles/per-user/${cfg.user}/bin"
|
||||
"/run/current-system/sw/bin"
|
||||
"/run/wrappers/bin"
|
||||
"/nix/var/nix/profiles/default/bin"
|
||||
]
|
||||
));
|
||||
}
|
||||
) // lib.optionalAttrs (cfg.hostnames == true) {
|
||||
PASEO_HOSTNAMES = "true";
|
||||
} // lib.optionalAttrs (lib.isList cfg.hostnames && cfg.hostnames != [ ]) {
|
||||
PASEO_HOSTNAMES = lib.concatStringsSep "," cfg.hostnames;
|
||||
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote") {
|
||||
PASEO_RELAY_ENDPOINT = "${cfg.relay.host}:${toString cfg.relay.port}";
|
||||
PASEO_RELAY_USE_TLS = if cfg.relay.useTls then "true" else "false";
|
||||
} // lib.optionalAttrs (cfg.relay.enable && cfg.relay.mode == "remote" && cfg.relay.publicUseTls != null) {
|
||||
PASEO_RELAY_PUBLIC_USE_TLS = if cfg.relay.publicUseTls then "true" else "false";
|
||||
} // cfg.environment;
|
||||
|
||||
serviceConfig = {
|
||||
@@ -172,5 +289,6 @@ in
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
|
||||
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
1
nix/npm-deps.hash
Normal file
1
nix/npm-deps.hash
Normal file
@@ -0,0 +1 @@
|
||||
sha256-PYbY3Lk9+y2byl1mN9dVO4YGXLEZrmnuPYxDw59LE5g=
|
||||
@@ -7,6 +7,15 @@
|
||||
makeWrapper,
|
||||
# node-pty needs libuv headers on Linux
|
||||
libuv,
|
||||
# Exposed so downstream flakes that follow a different nixpkgs revision
|
||||
# (where `fetchNpmDeps` may produce a different hash for the same lockfile)
|
||||
# can override via `.override { npmDepsHash = "sha256-..."; }` without
|
||||
# `overrideAttrs` gymnastics — `npmDepsHash` is destructured from
|
||||
# `buildNpmPackage`'s args, so `overrideAttrs` cannot reach it.
|
||||
#
|
||||
# The default is read from a sidecar file so the CI auto-updater can replace
|
||||
# the hash with a single file write instead of a sed against this source.
|
||||
npmDepsHash ? lib.fileContents ./npm-deps.hash,
|
||||
}:
|
||||
|
||||
buildNpmPackage rec {
|
||||
@@ -40,9 +49,9 @@ buildNpmPackage rec {
|
||||
|
||||
nodejs = nodejs_22;
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-Pjfl4RV+2keXdYWMPonsPkwPAbVNSgczmwSeQRwNSu4=";
|
||||
# Default hash lives in nix/npm-deps.hash (see arg default above).
|
||||
# CI auto-updates that file when package-lock.json changes (see .github/workflows/).
|
||||
inherit npmDepsHash;
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
@@ -70,8 +79,8 @@ buildNpmPackage rec {
|
||||
# degrade when unavailable.
|
||||
npm rebuild node-pty
|
||||
|
||||
# Build all daemon packages in dependency order (defined in package.json)
|
||||
npm run build:daemon
|
||||
# Build all server packages in dependency order (defined in package.json)
|
||||
npm run build:server
|
||||
|
||||
runHook postBuild
|
||||
'';
|
||||
@@ -79,51 +88,29 @@ buildNpmPackage rec {
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
# Compute the daemon's runtime closure by static module-graph tracing
|
||||
# (@vercel/nft from supervisor-entrypoint.js, cli/dist/index.js, and the
|
||||
# forked terminal-worker-process.js) plus an explicit list of non-JS
|
||||
# assets read at runtime. The trace script is the single source of
|
||||
# truth for what the daemon needs at $out — auditable in plain JS, no
|
||||
# npm hoisting / .bin / workspace-symlink footguns.
|
||||
mkdir -p $out/lib/paseo
|
||||
node scripts/trace-daemon.mjs > daemon-files.txt
|
||||
|
||||
# Copy root package metadata
|
||||
while IFS= read -r path; do
|
||||
[ -z "$path" ] && continue
|
||||
mkdir -p "$out/lib/paseo/$(dirname "$path")"
|
||||
cp -a "$path" "$out/lib/paseo/$path"
|
||||
done < daemon-files.txt
|
||||
|
||||
# Root package.json lets node resolve the workspace layout when the
|
||||
# CLI/server bin starts from $out.
|
||||
cp package.json $out/lib/paseo/
|
||||
|
||||
# Copy node_modules (preserving workspace symlinks)
|
||||
cp -a node_modules $out/lib/paseo/
|
||||
|
||||
# Auto-detect which @getpaseo/* packages were built by build:daemon
|
||||
# (they'll have a dist/ directory). Copy those and remove the rest.
|
||||
for link in $out/lib/paseo/node_modules/@getpaseo/*; do
|
||||
name=$(basename "$link")
|
||||
if [ -d "packages/$name/dist" ]; then
|
||||
mkdir -p "$out/lib/paseo/packages/$name"
|
||||
cp "packages/$name/package.json" "$out/lib/paseo/packages/$name/"
|
||||
cp -a "packages/$name/dist" "$out/lib/paseo/packages/$name/"
|
||||
if [ -d "packages/$name/node_modules" ]; then
|
||||
cp -a "packages/$name/node_modules" "$out/lib/paseo/packages/$name/"
|
||||
fi
|
||||
else
|
||||
rm -f "$link"
|
||||
fi
|
||||
done
|
||||
|
||||
# Copy CLI bin entry
|
||||
mkdir -p $out/lib/paseo/packages/cli/bin
|
||||
cp packages/cli/bin/paseo $out/lib/paseo/packages/cli/bin/
|
||||
|
||||
# Copy extra server files referenced at runtime
|
||||
for f in agent-prompt.md .env.example; do
|
||||
if [ -f packages/server/$f ]; then
|
||||
cp packages/server/$f $out/lib/paseo/packages/server/
|
||||
fi
|
||||
done
|
||||
|
||||
# Copy server scripts (including supervisor-entrypoint) needed by CLI
|
||||
if [ -d packages/server/dist/scripts ]; then
|
||||
mkdir -p $out/lib/paseo/packages/server/dist/scripts
|
||||
cp -a packages/server/dist/scripts/* $out/lib/paseo/packages/server/dist/scripts/
|
||||
fi
|
||||
|
||||
# Create wrapper for the server entry point (for systemd / direct use)
|
||||
mkdir -p $out/bin
|
||||
makeWrapper ${nodejs}/bin/node $out/bin/paseo-server \
|
||||
--add-flags "$out/lib/paseo/packages/server/dist/server/server/index.js" \
|
||||
--add-flags "$out/lib/paseo/packages/server/dist/scripts/supervisor-entrypoint.js" \
|
||||
--set NODE_ENV production
|
||||
|
||||
# Create wrapper for the CLI
|
||||
|
||||
7720
package-lock.json
generated
7720
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.67",
|
||||
"version": "0.1.86",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
|
||||
"keywords": [
|
||||
@@ -24,6 +24,8 @@
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
"packages/highlight",
|
||||
"packages/protocol",
|
||||
"packages/client",
|
||||
"packages/server",
|
||||
"packages/app",
|
||||
"packages/relay",
|
||||
@@ -34,16 +36,24 @@
|
||||
"scripts": {
|
||||
"dev": "./scripts/dev.sh",
|
||||
"dev:win": "powershell ./scripts/dev.ps1",
|
||||
"dev:server": "npm run dev --workspace=@getpaseo/server",
|
||||
"dev:server": "npm run build:server-deps && concurrently --kill-others --names protocol,client,server --prefix-colors yellow,blue,cyan \"npm run watch:protocol\" \"npm run watch:client\" \"npm run dev:server:raw\"",
|
||||
"dev:server:raw": "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",
|
||||
"build:relay": "npm run build --workspace=@getpaseo/relay",
|
||||
"build:protocol": "npm run build --workspace=@getpaseo/protocol",
|
||||
"build:client": "npm run build:protocol && npm run build --workspace=@getpaseo/client",
|
||||
"build:server-deps": "npm run build:highlight && npm run build:relay && npm run build:client",
|
||||
"build:server": "npm run build:server-deps && npm run build --workspace=@getpaseo/server && npm run build --workspace=@getpaseo/cli",
|
||||
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
|
||||
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
|
||||
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
|
||||
"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",
|
||||
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
|
||||
"test": "npm run test --workspaces --if-present",
|
||||
"format": "oxfmt .",
|
||||
"format:files": "oxfmt",
|
||||
@@ -63,7 +73,7 @@
|
||||
"web": "npm run web --workspace=@getpaseo/app",
|
||||
"dev:desktop": "npm run dev --workspace=@getpaseo/desktop",
|
||||
"dev:win:desktop": "npm run dev:win --workspace=@getpaseo/desktop",
|
||||
"build:desktop": "npm run build:workspace-deps --workspace=@getpaseo/app && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
|
||||
"build:desktop": "npm run build:app-deps && npm run build:server-deps && npm run build --workspace=@getpaseo/server && cd packages/app && cross-env PASEO_WEB_PLATFORM=electron npx expo export --platform web && cd ../.. && npm run build --workspace=@getpaseo/desktop --",
|
||||
"db:query": "npm run db:query --workspace=@getpaseo/server --",
|
||||
"cli": "npx tsx packages/cli/src/index.js",
|
||||
"version": "npm run version:sync-internal && npm run release:prepare && git add -A",
|
||||
@@ -77,9 +87,9 @@
|
||||
"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:check": "npm run release:prepare && npm run typecheck --workspace=@getpaseo/highlight && npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run build:client && npm run typecheck --workspace=@getpaseo/client && npm run build:server && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli && npm pack --dry-run --workspace=@getpaseo/highlight && npm pack --dry-run --workspace=@getpaseo/relay && npm pack --dry-run --workspace=@getpaseo/protocol && npm pack --dry-run --workspace=@getpaseo/client && 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/protocol --access public && npm publish --dry-run --workspace=@getpaseo/client --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/protocol --access public && npm publish --workspace=@getpaseo/client --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: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",
|
||||
@@ -93,6 +103,7 @@
|
||||
"devDependencies": {
|
||||
"@types/ws": "^8.5.14",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260423.1",
|
||||
"@vercel/nft": "^1.5.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"get-port-cli": "^3.0.0",
|
||||
@@ -101,6 +112,7 @@
|
||||
"lefthook": "^2.1.6",
|
||||
"oxfmt": "0.46.0",
|
||||
"oxlint": "1.61.0",
|
||||
"oxlint-tsgolint": "^0.22.1",
|
||||
"patch-package": "^8.0.1",
|
||||
"playwright": "^1.56.1",
|
||||
"typescript": "^5.9.3",
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
|
||||
submit_ios_for_review:
|
||||
name: Submit iOS for App Store review
|
||||
needs: [submit_ios]
|
||||
needs: [build_ios, submit_ios]
|
||||
environment: production
|
||||
runs_on: macos-medium
|
||||
steps:
|
||||
@@ -49,4 +49,7 @@ jobs:
|
||||
- name: Install fastlane
|
||||
run: bundle install
|
||||
- name: Submit for review
|
||||
run: bundle exec fastlane ios submit_review
|
||||
run: |
|
||||
export APP_VERSION="${{ needs.build_ios.outputs.app_version }}"
|
||||
export APP_BUILD_VERSION="${{ needs.build_ios.outputs.app_build_version }}"
|
||||
bundle exec fastlane ios submit_review
|
||||
|
||||
33
packages/app/.eas/workflows/resubmit-ios-review.yml
Normal file
33
packages/app/.eas/workflows/resubmit-ios-review.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
name: Resubmit iOS for App Store review
|
||||
|
||||
# Standalone re-trigger for the App Store review submission step. The iOS
|
||||
# binary is already uploaded to TestFlight via the EAS GitHub app's tag-push
|
||||
# build; this workflow just runs the fastlane submit_review lane against the
|
||||
# latest TestFlight build, without rebuilding or re-uploading.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
app_version:
|
||||
type: string
|
||||
required: false
|
||||
description: "Marketing version to resubmit, e.g. 0.1.76. Leave empty to target the most recently uploaded iOS build."
|
||||
app_build_version:
|
||||
type: string
|
||||
required: false
|
||||
description: "CFBundleVersion to resubmit, e.g. 2. Only used when app_version is set."
|
||||
|
||||
jobs:
|
||||
submit_ios_for_review:
|
||||
name: Submit iOS for App Store review
|
||||
environment: production
|
||||
runs_on: macos-medium
|
||||
steps:
|
||||
- uses: eas/checkout
|
||||
- name: Install fastlane
|
||||
run: bundle install
|
||||
- name: Submit for review
|
||||
run: |
|
||||
export APP_VERSION="${{ inputs.app_version }}"
|
||||
export APP_BUILD_VERSION="${{ inputs.app_build_version }}"
|
||||
bundle exec fastlane ios submit_review
|
||||
@@ -1,3 +1,3 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "fastlane"
|
||||
gem "fastlane", "~> 2.234"
|
||||
|
||||
34
packages/app/e2e/00-sessions-empty.spec.ts
Normal file
34
packages/app/e2e/00-sessions-empty.spec.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// "00-" prefix is intentional: this file must sort before every other spec.
|
||||
// Sessions history is daemon-global — any agent created by a prior spec hides the empty state.
|
||||
// If the beforeAll probe below fails, a spec sorted before this file is creating agents.
|
||||
import { test } from "./fixtures";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { expectSessionsEmptyState, openSessions } from "./helpers/archive-tab";
|
||||
|
||||
test.describe("Sessions screen empty state", () => {
|
||||
test.beforeAll(async () => {
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
const history = await client.fetchAgentHistory({ page: { limit: 1 } });
|
||||
if (history.entries.length > 0) {
|
||||
throw new Error(
|
||||
`Sessions empty-state precondition failed: daemon already has ${history.entries.length} agent(s). ` +
|
||||
`Either a spec that sorts before 00-sessions-empty.spec.ts created agents, ` +
|
||||
`or the daemon has stale history from a previous run.`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("shows empty placeholder when there is no session history", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
const workspace = await withWorkspace({ prefix: "sessions-empty-" });
|
||||
await workspace.navigateTo();
|
||||
await openSessions(page);
|
||||
await expectSessionsEmptyState(page);
|
||||
});
|
||||
});
|
||||
26
packages/app/e2e/acp-provider-catalog.spec.ts
Normal file
26
packages/app/e2e/acp-provider-catalog.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
expectProviderInstalledInSettings,
|
||||
installAcpCatalogProvider,
|
||||
openAddProviderModal,
|
||||
openSettingsHost,
|
||||
} from "./helpers/settings";
|
||||
|
||||
const ACP_PROVIDER = {
|
||||
id: "hermes",
|
||||
name: "Hermes",
|
||||
};
|
||||
|
||||
test.describe("ACP provider catalog", () => {
|
||||
test("adds a catalog provider from settings", async ({ page }) => {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, getServerId());
|
||||
await openAddProviderModal(page);
|
||||
|
||||
await installAcpCatalogProvider(page, ACP_PROVIDER.name);
|
||||
await expectProviderInstalledInSettings(page, ACP_PROVIDER.name);
|
||||
});
|
||||
});
|
||||
65
packages/app/e2e/agent-stream-ui.spec.ts
Normal file
65
packages/app/e2e/agent-stream-ui.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
awaitAssistantMessage,
|
||||
expectAgentIdle,
|
||||
expectInlineWorkingIndicator,
|
||||
expectTurnCopyButton,
|
||||
expectScrollFollowsNewContent,
|
||||
} from "./helpers/agent-stream";
|
||||
import { clickNewChat } from "./helpers/launcher";
|
||||
import { startRunningMockAgent } from "./helpers/composer";
|
||||
|
||||
test.describe("Agent stream UI", () => {
|
||||
test("auto-scroll sticks to bottom across token bursts", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
prefix: "stream-scroll-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stream for auto-scroll test.",
|
||||
});
|
||||
try {
|
||||
await awaitAssistantMessage(page);
|
||||
await expectScrollFollowsNewContent(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("working-indicator transitions to copy-button when stream ends", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
prefix: "stream-indicator-",
|
||||
model: "ten-second-stream",
|
||||
prompt: "Stream briefly for indicator transition test.",
|
||||
});
|
||||
try {
|
||||
await awaitAssistantMessage(page);
|
||||
await expectInlineWorkingIndicator(page);
|
||||
await expectAgentIdle(page, 30_000);
|
||||
await expectTurnCopyButton(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shows elapsed timer on first app-created running turn", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(90_000);
|
||||
const workspace = await withWorkspace({ prefix: "stream-first-app-turn-timer-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await page.getByText("Model defaults are still loading").waitFor({
|
||||
state: "hidden",
|
||||
timeout: 30_000,
|
||||
});
|
||||
const prompt = "Stream briefly for first app-created turn timer test.";
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
await composer.fill(prompt);
|
||||
await page.getByRole("button", { name: "Send message" }).click();
|
||||
await page.getByText(prompt, { exact: true }).first().waitFor({ state: "visible" });
|
||||
await awaitAssistantMessage(page);
|
||||
await expectInlineWorkingIndicator(page);
|
||||
await page.getByTestId("turn-working-elapsed").waitFor({ state: "visible", timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test } from "./fixtures";
|
||||
import { connectSeedClient } from "./helpers/seed-client";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
archiveAgentFromDaemon,
|
||||
archiveAgentFromSessions,
|
||||
clickSessionRow,
|
||||
closeWorkspaceAgentTab,
|
||||
connectArchiveTabDaemonClient,
|
||||
createIdleAgent,
|
||||
expectArchivedAgentFocused,
|
||||
expectSessionRowArchived,
|
||||
@@ -21,14 +21,14 @@ import {
|
||||
} from "./helpers/archive-tab";
|
||||
|
||||
test.describe("Archive tab reconciliation", () => {
|
||||
let client: Awaited<ReturnType<typeof connectArchiveTabDaemonClient>>;
|
||||
let client: Awaited<ReturnType<typeof connectSeedClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
|
||||
test.describe.configure({ timeout: 300_000 });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("archive-tab-");
|
||||
client = await connectArchiveTabDaemonClient();
|
||||
client = await connectSeedClient();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
|
||||
148
packages/app/e2e/client-slash-commands.spec.ts
Normal file
148
packages/app/e2e/client-slash-commands.spec.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { composerLocator, expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
import {
|
||||
expectSessionRowArchived,
|
||||
expectWorkspaceTabHidden,
|
||||
expectWorkspaceTabVisible,
|
||||
openSessions,
|
||||
} from "./helpers/archive-tab";
|
||||
|
||||
interface SlashCommandScenario {
|
||||
agentId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const REPLACEMENT_PROMPT = "Replacement prompt after slash clear.";
|
||||
|
||||
async function withOpenReadyMockAgent(
|
||||
page: Page,
|
||||
input: {
|
||||
title: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
},
|
||||
run: (scenario: SlashCommandScenario) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "client-slash-command-",
|
||||
title: input.title,
|
||||
model: input.model,
|
||||
modeId: input.modeId,
|
||||
initialPrompt: "Prepare a client slash command test agent.",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectWorkspaceTabVisible(page, session.agentId);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await run({ agentId: session.agentId, title: input.title });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function runClientSlashCommand(page: Page, command: "/quit" | "/clear"): Promise<void> {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill(command);
|
||||
await expect(input).toHaveValue(command);
|
||||
await input.press("Enter");
|
||||
}
|
||||
|
||||
async function selectClientSlashCommand(page: Page, query: string, label: string): Promise<void> {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill(query);
|
||||
await expect(page.getByText(label, { exact: true }).first()).toBeVisible({ timeout: 30_000 });
|
||||
await input.press("Enter");
|
||||
}
|
||||
|
||||
async function expectAgentArchivedInSessions(page: Page, title: string): Promise<void> {
|
||||
await openSessions(page);
|
||||
await expectSessionRowArchived(page, title);
|
||||
}
|
||||
|
||||
async function expectReplacementDraftMatchesPreviousSetup(page: Page): Promise<void> {
|
||||
await expectComposerVisible(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Select model (Ten second stream)" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Select agent mode (Load test)" })).toBeVisible();
|
||||
}
|
||||
|
||||
async function createAgentFromReplacementDraft(page: Page): Promise<void> {
|
||||
await submitMessage(page, REPLACEMENT_PROMPT);
|
||||
}
|
||||
|
||||
async function waitForReplacementAgentId(page: Page, oldAgentId: string): Promise<string> {
|
||||
let newAgentId: string | null = null;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const ids = await page
|
||||
.locator('[data-testid^="workspace-tab-agent_"]')
|
||||
.evaluateAll((nodes) =>
|
||||
nodes.flatMap((node) => {
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
return [];
|
||||
}
|
||||
const testId = node.getAttribute("data-testid") ?? "";
|
||||
if (!testId.startsWith("workspace-tab-agent_")) {
|
||||
return [];
|
||||
}
|
||||
if (node.offsetParent === null) {
|
||||
return [];
|
||||
}
|
||||
return [testId.slice("workspace-tab-agent_".length)];
|
||||
}),
|
||||
);
|
||||
newAgentId = ids.find((id) => id !== oldAgentId) ?? null;
|
||||
return newAgentId;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
if (!newAgentId) {
|
||||
throw new Error("Replacement agent was not created.");
|
||||
}
|
||||
return newAgentId;
|
||||
}
|
||||
|
||||
test.describe("Client slash commands", () => {
|
||||
test("slash quit archives the active agent and removes its tab", async ({ page }) => {
|
||||
await withOpenReadyMockAgent(page, { title: "Slash quit e2e" }, async ({ agentId, title }) => {
|
||||
await runClientSlashCommand(page, "/quit");
|
||||
await expectWorkspaceTabHidden(page, agentId);
|
||||
await expectAgentArchivedInSessions(page, title);
|
||||
});
|
||||
});
|
||||
|
||||
test("slash quit selected from autocomplete archives immediately", async ({ page }) => {
|
||||
await withOpenReadyMockAgent(
|
||||
page,
|
||||
{ title: "Slash quit autocomplete e2e" },
|
||||
async ({ agentId, title }) => {
|
||||
await selectClientSlashCommand(page, "/qu", "/exit");
|
||||
await expectWorkspaceTabHidden(page, agentId);
|
||||
await expectAgentArchivedInSessions(page, title);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("slash clear replaces the active agent with a matching draft", async ({ page }) => {
|
||||
await withOpenReadyMockAgent(
|
||||
page,
|
||||
{ title: "Slash clear e2e", model: "ten-second-stream", modeId: "load-test" },
|
||||
async ({ agentId, title }) => {
|
||||
await runClientSlashCommand(page, "/clear");
|
||||
await expectWorkspaceTabHidden(page, agentId);
|
||||
await expectReplacementDraftMatchesPreviousSetup(page);
|
||||
await createAgentFromReplacementDraft(page);
|
||||
await waitForReplacementAgentId(page, agentId);
|
||||
await expectAgentArchivedInSessions(page, title);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,6 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { allowPermission, waitForPermissionPrompt } from "./helpers/app";
|
||||
import { connectTerminalClient } from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
import { allowPermission, waitForPermissionPrompt } from "./helpers/permissions";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
|
||||
test.describe("Codex plan approval", () => {
|
||||
test("shows a single actionable plan panel and removes it after implementation starts", async ({
|
||||
@@ -18,29 +8,14 @@ test.describe("Codex plan approval", () => {
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const repo = await createTempGitRepo("codex-plan-approval-");
|
||||
const client = await connectTerminalClient();
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "codex-plan-approval-",
|
||||
title: "Codex plan approval e2e",
|
||||
initialPrompt: "Emit synthetic plan approval.",
|
||||
});
|
||||
|
||||
try {
|
||||
const workspaceResult = await client.openProject(repo.path);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||
}
|
||||
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
title: "Codex plan approval e2e",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "Emit synthetic plan approval.",
|
||||
});
|
||||
|
||||
const agentUrl = `${buildHostWorkspaceRoute(
|
||||
getServerId(),
|
||||
repo.path,
|
||||
)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await openAgentRoute(page, session);
|
||||
|
||||
await waitForPermissionPrompt(page, 120_000);
|
||||
|
||||
@@ -54,8 +29,7 @@ test.describe("Codex plan approval", () => {
|
||||
});
|
||||
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
275
packages/app/e2e/composer-attachments.spec.ts
Normal file
275
packages/app/e2e/composer-attachments.spec.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { clickNewChat } from "./helpers/launcher";
|
||||
import { expectComposerVisible } from "./helpers/composer";
|
||||
import { expectAgentIdle } from "./helpers/agent-stream";
|
||||
import {
|
||||
openAttachmentMenu,
|
||||
openGithubPickerFromMenu,
|
||||
attachImageFromMenu,
|
||||
expectAttachmentPill,
|
||||
removeAttachmentPill,
|
||||
openImageLightbox,
|
||||
closeImageLightbox,
|
||||
pressInterruptShortcut,
|
||||
expectComposerDraft,
|
||||
expectComposerDisabled,
|
||||
expectComposerEditable,
|
||||
expectAttachButtonDisabled,
|
||||
fillComposerDraft,
|
||||
sendDraftToQueue,
|
||||
expectQueuedMessageButton,
|
||||
startRunningMockAgent,
|
||||
selectGithubOption,
|
||||
expectGithubAttachmentPill,
|
||||
openGithubWorkspace,
|
||||
} from "./helpers/composer";
|
||||
import { delayBrowserAgentCreatedStatus, openNewWorkspaceComposer } from "./helpers/new-workspace";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { waitForSidebarHydration, switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { hasGithubAuth, createTempGithubRepo } from "./helpers/github-fixtures";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
const MINIMAL_PNG = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
||||
"base64",
|
||||
);
|
||||
|
||||
const TEST_IMAGE = { name: "test.png", mimeType: "image/png", buffer: MINIMAL_PNG };
|
||||
|
||||
test.describe("Composer attachments", () => {
|
||||
test("Plus menu shows image and GitHub options", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-plus-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await openAttachmentMenu(page);
|
||||
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-image")).toBeVisible();
|
||||
await expect(page.getByTestId("message-input-attachment-menu-item-github")).toBeVisible();
|
||||
});
|
||||
|
||||
test("GitHub combobox does not render until the picker is opened", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-gh-lazy-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await expect(page.getByTestId("combobox-desktop-container")).not.toBeVisible();
|
||||
|
||||
await openGithubPickerFromMenu(page);
|
||||
|
||||
await expect(page.getByPlaceholder("Search issues and PRs...")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(
|
||||
page.getByTestId("combobox-empty-text").or(page.getByText("Searching...")),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("GitHub issue attachment pill visible after search and selection", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
if (!hasGithubAuth()) {
|
||||
test.skip(true, "GitHub auth not available in this environment");
|
||||
}
|
||||
|
||||
const ghRepo = await createTempGithubRepo({
|
||||
category: "attach-issue",
|
||||
issues: [{ title: "fix: attach-issue-unique-alpha" }],
|
||||
prs: [{ title: "feat: attach-issue-dummy-pr", state: "open" }],
|
||||
});
|
||||
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
|
||||
try {
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await selectGithubOption(
|
||||
page,
|
||||
"attach-issue-unique-alpha",
|
||||
`issue:${ghRepo.issues[0].number}`,
|
||||
);
|
||||
|
||||
await expectGithubAttachmentPill(page, {
|
||||
number: ghRepo.issues[0].number,
|
||||
title: ghRepo.issues[0].title,
|
||||
});
|
||||
} finally {
|
||||
await handle.cleanup();
|
||||
await ghRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("GitHub PR attachment pill visible after search and selection", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
if (!hasGithubAuth()) {
|
||||
test.skip(true, "GitHub auth not available in this environment");
|
||||
}
|
||||
|
||||
const ghRepo = await createTempGithubRepo({
|
||||
category: "attach-pr",
|
||||
prs: [{ title: "feat: attach-pr-unique-beta", state: "open" }],
|
||||
});
|
||||
const handle = await openGithubWorkspace(page, ghRepo.prs[0].localPath);
|
||||
try {
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await selectGithubOption(page, "attach-pr-unique-beta", `pr:${ghRepo.prs[0].number}`);
|
||||
|
||||
await expectGithubAttachmentPill(page, {
|
||||
number: ghRepo.prs[0].number,
|
||||
title: ghRepo.prs[0].title,
|
||||
});
|
||||
} finally {
|
||||
await handle.cleanup();
|
||||
await ghRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.fixme("workspace-review pill suppresses on X-click and reappears after send", async () => {
|
||||
// The review attachment is created via InlineReviewEditor in surface.tsx (addComment action).
|
||||
// Automating this requires: a workspace with staged changes, navigating to the diff panel,
|
||||
// hovering the gutter "+" button, typing a comment, and submitting. A dedicated
|
||||
// helpers/review.ts with addInlineReviewComment(page, filePath, lineNumber, comment) is
|
||||
// needed before this can be exercised end-to-end.
|
||||
});
|
||||
|
||||
test.fixme("browser-element attachment pill is created from Electron webview selection", async () => {
|
||||
// The browser-element attachment is only created in browser-pane.electron.tsx via DOM
|
||||
// element selection in the Electron webview. It is not exercisable in headless Chromium E2E.
|
||||
});
|
||||
|
||||
test("image lightbox opens on pill click and closes on Escape", async ({
|
||||
page,
|
||||
withWorkspace,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-lightbox-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
|
||||
await openImageLightbox(page);
|
||||
await closeImageLightbox(page);
|
||||
});
|
||||
|
||||
test("image attachment pill renders after file is selected", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-pill-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
});
|
||||
|
||||
test("clicking the X on an image pill removes it", async ({ page, withWorkspace }) => {
|
||||
test.setTimeout(60_000);
|
||||
const workspace = await withWorkspace({ prefix: "attach-remove-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
await attachImageFromMenu(page, TEST_IMAGE);
|
||||
await expectAttachmentPill(page, "composer-image-attachment-pill");
|
||||
|
||||
await removeAttachmentPill(page, "composer-image-attachment-pill", "Remove image attachment");
|
||||
|
||||
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("submitting while agent is running queues the message and clears the draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
prefix: "attach-queue-",
|
||||
model: "one-minute-stream",
|
||||
prompt: "Stay running for queue test.",
|
||||
});
|
||||
try {
|
||||
await fillComposerDraft(page, "queued draft text");
|
||||
await sendDraftToQueue(page);
|
||||
|
||||
await expectQueuedMessageButton(page);
|
||||
await expectComposerDraft(page, "");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("Escape interrupt cancels the running agent and preserves composer draft", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const { client, repo } = await startRunningMockAgent(page, {
|
||||
prefix: "attach-interrupt-",
|
||||
model: "ten-second-stream",
|
||||
prompt: "Stay running for interrupt test.",
|
||||
});
|
||||
try {
|
||||
await fillComposerDraft(page, "preserve me");
|
||||
await pressInterruptShortcut(page);
|
||||
|
||||
await expectAgentIdle(page, 15_000);
|
||||
await expectComposerDraft(page, "preserve me");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("composer is locked while new workspace agent is being created", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
const serverId = getServerId();
|
||||
|
||||
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "attach-lock-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId,
|
||||
targetWorkspacePath: workspace.workspaceId,
|
||||
});
|
||||
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName,
|
||||
});
|
||||
await fillComposerDraft(page, "lock test prompt");
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
await expect(createButton).toBeVisible({ timeout: 30_000 });
|
||||
await createButton.click();
|
||||
|
||||
await agentCreatedDelay.waitForCreateRequest();
|
||||
await agentCreatedDelay.waitForDelayedCreatedStatus();
|
||||
|
||||
await expectComposerDisabled(page);
|
||||
await expectAttachButtonDisabled(page);
|
||||
|
||||
agentCreatedDelay.release();
|
||||
|
||||
await expectComposerEditable(page);
|
||||
} finally {
|
||||
agentCreatedDelay.release();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
483
packages/app/e2e/composer-autocomplete.spec.ts
Normal file
483
packages/app/e2e/composer-autocomplete.spec.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
import { expect, test, type Page } from "./fixtures";
|
||||
import { composerLocator, expectComposerVisible } from "./helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./helpers/mock-agent";
|
||||
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
||||
import { daemonWsRoutePattern } from "./helpers/daemon-port";
|
||||
|
||||
const TEST_COMMANDS = [
|
||||
{
|
||||
name: "tdd",
|
||||
description: "Write a red test, verify it fails for the right reason, implement to green",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "help",
|
||||
description: "Show help for the current agent session and available slash commands",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "hello",
|
||||
description: "Insert a friendly greeting prompt into the current composer",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "heapdump",
|
||||
description: "Dump the JavaScript heap for local desktop debugging",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "health",
|
||||
description: "Show runtime health checks and connection diagnostics",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "history",
|
||||
description: "Summarize recent session history",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "handoff",
|
||||
description: "Prepare a complete handoff note for another agent",
|
||||
argumentHint: "[agent]",
|
||||
},
|
||||
{
|
||||
name: "hover",
|
||||
description: "Audit hover behavior in desktop web surfaces",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "harness",
|
||||
description: "Inspect the local test harness configuration",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "hydrate",
|
||||
description: "Refresh persisted state used by the current workspace",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "highlight",
|
||||
description: "Highlight important changes in the active diff",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "home",
|
||||
description: "Navigate back to the workspace home surface",
|
||||
argumentHint: "",
|
||||
},
|
||||
{
|
||||
name: "host",
|
||||
description: "Inspect host connection metadata",
|
||||
argumentHint: "",
|
||||
},
|
||||
] as const;
|
||||
|
||||
interface PopoverFrame {
|
||||
exists: boolean;
|
||||
top: number;
|
||||
bottom: number;
|
||||
height: number;
|
||||
opacity: number;
|
||||
display: string;
|
||||
visibility: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface PopoverFrameRecorderWindow extends Window {
|
||||
__composerAutocompleteFrames?: PopoverFrame[];
|
||||
__stopComposerAutocompleteFrameRecorder?: () => void;
|
||||
}
|
||||
|
||||
async function getTopTestIdAtPoint(page: Page, x: number, y: number) {
|
||||
return page.evaluate(
|
||||
([pointX, pointY]) => {
|
||||
const element = document.elementFromPoint(pointX, pointY);
|
||||
return element?.closest("[data-testid]")?.getAttribute("data-testid") ?? null;
|
||||
},
|
||||
[x, y],
|
||||
);
|
||||
}
|
||||
|
||||
async function installListCommandsStub(page: Page): Promise<void> {
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
if (typeof message !== "string") {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(message) as {
|
||||
type?: string;
|
||||
message?: {
|
||||
type?: string;
|
||||
payload?: {
|
||||
commands?: unknown;
|
||||
error?: string | null;
|
||||
};
|
||||
};
|
||||
};
|
||||
if (
|
||||
parsed.type === "session" &&
|
||||
parsed.message?.type === "list_commands_response" &&
|
||||
parsed.message.payload
|
||||
) {
|
||||
parsed.message.payload.commands = TEST_COMMANDS;
|
||||
parsed.message.payload.error = null;
|
||||
ws.send(JSON.stringify(parsed));
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Forward non-JSON frames unchanged.
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openReadyMockAgent(
|
||||
page: Page,
|
||||
options?: { expectWorkspaceTab?: boolean },
|
||||
): Promise<{
|
||||
cleanup: () => Promise<void>;
|
||||
}> {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "autocomplete-popover-",
|
||||
title: "Autocomplete popover regression",
|
||||
});
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
if (options?.expectWorkspaceTab !== false) {
|
||||
await expectWorkspaceTabVisible(page, session.agentId);
|
||||
}
|
||||
await expectComposerVisible(page);
|
||||
return { cleanup: session.cleanup };
|
||||
} catch (error) {
|
||||
await session.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function visiblePopoverBox(
|
||||
page: Page,
|
||||
): Promise<{ top: number; bottom: number; height: number }> {
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover).toBeVisible({ timeout: 30_000 });
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
popover.evaluate((element) => {
|
||||
const style = window.getComputedStyle(element);
|
||||
return Number(style.opacity);
|
||||
}),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeGreaterThan(0.95);
|
||||
const box = await popover.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error("Autocomplete popover did not produce a bounding box.");
|
||||
}
|
||||
return {
|
||||
top: box.y,
|
||||
bottom: box.y + box.height,
|
||||
height: box.height,
|
||||
};
|
||||
}
|
||||
|
||||
async function startPopoverFrameRecorder(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const win = window as PopoverFrameRecorderWindow;
|
||||
win.__composerAutocompleteFrames = [];
|
||||
let active = true;
|
||||
|
||||
const record = () => {
|
||||
if (!active) return;
|
||||
|
||||
const element = document.querySelector('[data-testid="composer-autocomplete-popover"]');
|
||||
if (element instanceof HTMLElement) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
win.__composerAutocompleteFrames?.push({
|
||||
exists: true,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
height: rect.height,
|
||||
opacity: Number(style.opacity),
|
||||
display: style.display,
|
||||
visibility: style.visibility,
|
||||
timestamp: performance.now(),
|
||||
});
|
||||
} else {
|
||||
win.__composerAutocompleteFrames?.push({
|
||||
exists: false,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
display: "none",
|
||||
visibility: "hidden",
|
||||
timestamp: performance.now(),
|
||||
});
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(record);
|
||||
};
|
||||
|
||||
win.__stopComposerAutocompleteFrameRecorder = () => {
|
||||
active = false;
|
||||
};
|
||||
window.requestAnimationFrame(record);
|
||||
});
|
||||
}
|
||||
|
||||
async function stopPopoverFrameRecorder(page: Page): Promise<PopoverFrame[]> {
|
||||
return page.evaluate(() => {
|
||||
const win = window as PopoverFrameRecorderWindow;
|
||||
win.__stopComposerAutocompleteFrameRecorder?.();
|
||||
return win.__composerAutocompleteFrames ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
function visiblePopoverFrames(frames: PopoverFrame[]): PopoverFrame[] {
|
||||
return frames.filter(
|
||||
(frame) =>
|
||||
frame.exists &&
|
||||
frame.height > 0 &&
|
||||
frame.opacity > 0.95 &&
|
||||
frame.display !== "none" &&
|
||||
frame.visibility !== "hidden",
|
||||
);
|
||||
}
|
||||
|
||||
function formatFrame(frame: PopoverFrame | undefined): string {
|
||||
if (!frame) return "none";
|
||||
return JSON.stringify({
|
||||
top: Math.round(frame.top),
|
||||
bottom: Math.round(frame.bottom),
|
||||
height: Math.round(frame.height),
|
||||
opacity: frame.opacity,
|
||||
});
|
||||
}
|
||||
|
||||
function expectPopoverFramesStable(frames: PopoverFrame[]): void {
|
||||
const visibleFrames = visiblePopoverFrames(frames);
|
||||
expect(visibleFrames.length).toBeGreaterThan(0);
|
||||
|
||||
const finalFrame = visibleFrames[visibleFrames.length - 1];
|
||||
const jumpingFrame = visibleFrames.find(
|
||||
(frame) =>
|
||||
Math.abs(frame.top - finalFrame.top) > 4 ||
|
||||
Math.abs(frame.bottom - finalFrame.bottom) > 4 ||
|
||||
Math.abs(frame.height - finalFrame.height) > 4,
|
||||
);
|
||||
|
||||
expect(
|
||||
jumpingFrame,
|
||||
`expected first visible popover paint to be stable; first=${formatFrame(
|
||||
visibleFrames[0],
|
||||
)} jumping=${formatFrame(jumpingFrame)} final=${formatFrame(finalFrame)}`,
|
||||
).toBeUndefined();
|
||||
}
|
||||
|
||||
function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]): void {
|
||||
const firstVisibleIndex = frames.findIndex(
|
||||
(frame) =>
|
||||
frame.exists &&
|
||||
frame.height > 0 &&
|
||||
frame.opacity > 0.95 &&
|
||||
frame.display !== "none" &&
|
||||
frame.visibility !== "hidden",
|
||||
);
|
||||
expect(firstVisibleIndex).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const hiddenFrame = frames
|
||||
.slice(firstVisibleIndex)
|
||||
.find((frame) => !frame.exists || frame.opacity < 0.95);
|
||||
expect(
|
||||
hiddenFrame,
|
||||
`expected mounted popover to stay visible while filtering; hidden=${formatFrame(hiddenFrame)}`,
|
||||
).toBeUndefined();
|
||||
}
|
||||
|
||||
test.describe("Composer autocomplete", () => {
|
||||
test("does not flash at the wrong position on the first slash command paint", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page);
|
||||
|
||||
try {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.click();
|
||||
|
||||
await startPopoverFrameRecorder(page);
|
||||
await page.keyboard.type("/");
|
||||
await expect(page.getByText("/help", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await page.waitForTimeout(250);
|
||||
const frames = await stopPopoverFrameRecorder(page);
|
||||
|
||||
expectPopoverFramesStable(frames);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not jump when deleting a slash command search", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page);
|
||||
|
||||
try {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
await input.fill("/he");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const beforeDelete = await visiblePopoverBox(page);
|
||||
|
||||
await input.press("Backspace");
|
||||
await expect(input).toHaveValue("/h");
|
||||
await expect(popover.getByText("/history", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const afterDelete = await visiblePopoverBox(page);
|
||||
|
||||
expect(Math.abs(afterDelete.bottom - beforeDelete.bottom)).toBeLessThanOrEqual(4);
|
||||
expect(afterDelete.height).toBeGreaterThan(beforeDelete.height);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("shrinks to filtered slash command results without moving the bottom edge", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page);
|
||||
|
||||
try {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
await input.fill("/");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const allCommands = await visiblePopoverBox(page);
|
||||
|
||||
await input.fill("/tdd");
|
||||
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
const oneCommand = await visiblePopoverBox(page);
|
||||
|
||||
expect(Math.abs(oneCommand.bottom - allCommands.bottom)).toBeLessThanOrEqual(4);
|
||||
expect(oneCommand.height).toBeLessThan(allCommands.height - 40);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("stays visible while filtering slash command results", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page);
|
||||
|
||||
try {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
await input.fill("/");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await startPopoverFrameRecorder(page);
|
||||
await page.keyboard.type("tdd", { delay: 40 });
|
||||
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.waitForTimeout(250);
|
||||
const frames = await stopPopoverFrameRecorder(page);
|
||||
|
||||
expectPopoverDoesNotDisappearAfterFirstVisible(frames);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("stays anchored to the composer when the desktop sidebar is open", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page);
|
||||
|
||||
try {
|
||||
await expect(page.getByTestId("sidebar-sessions")).toBeVisible({ timeout: 30_000 });
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
await input.fill("/");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const composerBox = await page.getByTestId("message-input-root").boundingBox();
|
||||
const popoverBox = await popover.boundingBox();
|
||||
expect(composerBox).not.toBeNull();
|
||||
expect(popoverBox).not.toBeNull();
|
||||
|
||||
expect(Math.abs(popoverBox!.x - composerBox!.x)).toBeLessThanOrEqual(4);
|
||||
expect(Math.abs(popoverBox!.width - composerBox!.width)).toBeLessThanOrEqual(4);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("compact sidebar layering", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
|
||||
test("keeps the mobile agent sidebar above autocomplete", async ({ page }) => {
|
||||
await installListCommandsStub(page);
|
||||
const agent = await openReadyMockAgent(page, { expectWorkspaceTab: false });
|
||||
|
||||
try {
|
||||
const input = composerLocator(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
|
||||
await input.fill("/");
|
||||
const popover = page.getByTestId("composer-autocomplete-popover");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
|
||||
|
||||
const popoverBox = await popover.boundingBox();
|
||||
expect(popoverBox).not.toBeNull();
|
||||
|
||||
const topTestId = await getTopTestIdAtPoint(
|
||||
page,
|
||||
popoverBox!.x + popoverBox!.width / 2,
|
||||
popoverBox!.y + popoverBox!.height / 2,
|
||||
);
|
||||
|
||||
expect(topTestId).not.toBe("composer-autocomplete-popover");
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
144
packages/app/e2e/desktop-updates.spec.ts
Normal file
144
packages/app/e2e/desktop-updates.spec.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import {
|
||||
loadRealDaemonState,
|
||||
injectDesktopBridge,
|
||||
openDesktopSettings,
|
||||
expectUpdateBanner,
|
||||
clickInstallUpdate,
|
||||
expectInstallInProgress,
|
||||
interceptDaemonManagementConfirmDialog,
|
||||
toggleDaemonManagement,
|
||||
expectDaemonManagementConfirmDialog,
|
||||
expectDaemonManagementEnabled,
|
||||
expectDaemonManagementDisabled,
|
||||
expectDaemonStatusPid,
|
||||
expectDaemonStatusLogPath,
|
||||
expectDaemonStatusVersion,
|
||||
} from "./helpers/desktop-updates";
|
||||
|
||||
// No Playwright Electron runner exists; we simulate the desktop bridge via
|
||||
// addInitScript so Electron-gated UI activates without a real Electron process.
|
||||
test.describe("Desktop updates", () => {
|
||||
test("update banner appears in the sidebar when an app update is available", async ({ page }) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getServerId(),
|
||||
updateAvailable: true,
|
||||
latestVersion: "1.2.3",
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
|
||||
await expectUpdateBanner(page, "1.2.3");
|
||||
});
|
||||
|
||||
test("clicking install shows the installing state on the callout", async ({ page }) => {
|
||||
await injectDesktopBridge(page, {
|
||||
serverId: getServerId(),
|
||||
updateAvailable: true,
|
||||
latestVersion: "1.2.3",
|
||||
slowInstall: true,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
|
||||
await expectUpdateBanner(page, "1.2.3");
|
||||
await clickInstallUpdate(page);
|
||||
await expectInstallInProgress(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Desktop daemon management", () => {
|
||||
test("disabling built-in daemon management shows confirm dialog with correct copy", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
confirmShouldAccept: false,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopSettings(page, serverId);
|
||||
|
||||
const dialogArgs = await interceptDaemonManagementConfirmDialog(page);
|
||||
expectDaemonManagementConfirmDialog(dialogArgs);
|
||||
|
||||
await expectDaemonManagementEnabled(page);
|
||||
});
|
||||
|
||||
test("cancelling the confirm dialog leaves the daemon management toggle on", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
confirmShouldAccept: false,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopSettings(page, serverId);
|
||||
|
||||
await expectDaemonManagementEnabled(page);
|
||||
await toggleDaemonManagement(page, "disable");
|
||||
await expectDaemonManagementEnabled(page);
|
||||
});
|
||||
|
||||
test("confirming the dialog disables built-in daemon management", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
confirmShouldAccept: true,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopSettings(page, serverId);
|
||||
|
||||
await toggleDaemonManagement(page, "disable");
|
||||
|
||||
await expectDaemonManagementDisabled(page);
|
||||
});
|
||||
|
||||
test("daemon status panel renders version, PID, and log path from the real daemon", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const realState = await loadRealDaemonState();
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: false,
|
||||
daemonPid: realState.pid,
|
||||
daemonVersion: realState.version,
|
||||
daemonLogPath: realState.logPath,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopSettings(page, serverId);
|
||||
|
||||
await expectDaemonStatusVersion(page, realState.version);
|
||||
await expectDaemonStatusPid(page, realState.pid);
|
||||
await expectDaemonStatusLogPath(page, realState.logPath);
|
||||
});
|
||||
|
||||
test("stopping and restarting the daemon updates the PID", async ({ page }) => {
|
||||
const serverId = getServerId();
|
||||
const realState = await loadRealDaemonState();
|
||||
await injectDesktopBridge(page, {
|
||||
serverId,
|
||||
manageBuiltInDaemon: true,
|
||||
daemonPid: realState.pid,
|
||||
daemonVersion: realState.version,
|
||||
daemonLogPath: realState.logPath,
|
||||
confirmShouldAccept: true,
|
||||
});
|
||||
await gotoAppShell(page);
|
||||
await openDesktopSettings(page, serverId);
|
||||
|
||||
await expectDaemonStatusPid(page, realState.pid);
|
||||
|
||||
await toggleDaemonManagement(page, "disable");
|
||||
await expectDaemonManagementDisabled(page);
|
||||
await expectDaemonStatusPid(page, null);
|
||||
|
||||
await toggleDaemonManagement(page, "enable");
|
||||
await expectDaemonManagementEnabled(page);
|
||||
const newPid = realState.pid !== null ? realState.pid + 1000 : 11000;
|
||||
await expectDaemonStatusPid(page, newPid);
|
||||
});
|
||||
});
|
||||
@@ -9,41 +9,31 @@ import {
|
||||
openFileFromExplorer,
|
||||
} from "./helpers/file-explorer";
|
||||
import { gotoWorkspace } from "./helpers/launcher";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
connectWorkspaceSetupClient,
|
||||
type WorkspaceSetupDaemonClient,
|
||||
} from "./helpers/workspace-setup";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
let workspaceId: string;
|
||||
let seedClient: WorkspaceSetupDaemonClient;
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
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" },
|
||||
],
|
||||
workspace = await seedWorkspace({
|
||||
repoPrefix: "file-explorer-collapse-",
|
||||
repo: {
|
||||
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();
|
||||
await workspace?.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 gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
await expandFolder(page, "assets");
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
import { getE2EDaemonPort } from "./helpers/daemon-port";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./helpers/daemon-registry";
|
||||
import { createWithWorkspace, type WithWorkspace } from "./helpers/with-workspace";
|
||||
|
||||
// Extend base test to provide dynamic baseURL from global-setup
|
||||
const test = base.extend({
|
||||
// Test setup is wired through an `auto: true` fixture rather than `test.beforeEach`.
|
||||
// `test.beforeEach` declared at the top level of a non-test fixture file is unreliable
|
||||
// across spec-file boundaries — Playwright sometimes skips it for the first test of a
|
||||
// subsequent spec when multiple specs run in the same worker. Auto fixtures run
|
||||
// reliably for every test that uses this `test` object.
|
||||
const test = base.extend<{ paseoE2ESetup: void; withWorkspace: WithWorkspace }>({
|
||||
baseURL: async ({}, provide) => {
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
@@ -10,102 +16,87 @@ const test = base.extend({
|
||||
}
|
||||
await provide(`http://localhost:${metroPort}`);
|
||||
},
|
||||
});
|
||||
|
||||
const consoleEntries = new WeakMap<Page, string[]>();
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is not set. Refusing to run e2e against the default daemon (e.g. localhost:6767). " +
|
||||
"Ensure Playwright `globalSetup` starts the e2e daemon and exports E2E_DAEMON_PORT.",
|
||||
);
|
||||
}
|
||||
if (daemonPort === "6767") {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon. " +
|
||||
"Fix Playwright globalSetup to start an isolated test daemon and export its port.",
|
||||
);
|
||||
}
|
||||
if (!metroPort) {
|
||||
throw new Error(
|
||||
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
|
||||
);
|
||||
}
|
||||
|
||||
// Hard guardrail: never allow tests to hit the developer's default daemon.
|
||||
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
|
||||
await page.route(/:(6767)\b/, (route) => route.abort());
|
||||
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||
});
|
||||
|
||||
const entries: string[] = [];
|
||||
consoleEntries.set(page, entries);
|
||||
|
||||
page.on("console", (message) => {
|
||||
entries.push(`[console:${message.type()}] ${message.text()}`);
|
||||
});
|
||||
|
||||
page.on("pageerror", (error) => {
|
||||
entries.push(`[pageerror] ${error.message}`);
|
||||
});
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const seedNonce = Math.random().toString(36).slice(2);
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
|
||||
}
|
||||
const testDaemon = buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
nowIso,
|
||||
});
|
||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce: nonce }) => {
|
||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||
// setting this flag before the reload.
|
||||
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
||||
const disableValue = localStorage.getItem(disableOnceKey);
|
||||
if (disableValue) {
|
||||
localStorage.removeItem(disableOnceKey);
|
||||
if (disableValue === nonce) {
|
||||
return;
|
||||
}
|
||||
paseoE2ESetup: [
|
||||
async ({ page }, provide, testInfo) => {
|
||||
const daemonPort = getE2EDaemonPort();
|
||||
const metroPort = process.env.E2E_METRO_PORT;
|
||||
if (!metroPort) {
|
||||
throw new Error(
|
||||
"E2E_METRO_PORT is not set. Ensure Playwright `globalSetup` starts Metro and exports E2E_METRO_PORT.",
|
||||
);
|
||||
}
|
||||
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
||||
// Hard guardrail: never allow tests to hit the developer's default daemon.
|
||||
// This blocks both HTTP and WS attempts to :6767 (before any navigation).
|
||||
await page.route(/:(6767)\b/, (route) => route.abort());
|
||||
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||
});
|
||||
|
||||
// Hard-reset anything that could point to a developer's real daemon.
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
const entries: string[] = [];
|
||||
|
||||
page.on("console", (message) => {
|
||||
entries.push(`[console:${message.type()}] ${message.text()}`);
|
||||
});
|
||||
|
||||
page.on("pageerror", (error) => {
|
||||
entries.push(`[pageerror] ${error.message}`);
|
||||
});
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const seedNonce = Math.random().toString(36).slice(2);
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set - expected from Playwright globalSetup.");
|
||||
}
|
||||
const testDaemon = buildSeededHost({
|
||||
serverId,
|
||||
endpoint: `127.0.0.1:${daemonPort}`,
|
||||
nowIso,
|
||||
});
|
||||
const createAgentPreferences = buildCreateAgentPreferences(testDaemon.serverId);
|
||||
|
||||
await page.addInitScript(
|
||||
({ daemon, preferences, seedNonce: nonce }) => {
|
||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
||||
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||
// setting this flag before the reload.
|
||||
const disableOnceKey = "@paseo:e2e-disable-default-seed-once";
|
||||
const disableValue = localStorage.getItem(disableOnceKey);
|
||||
if (disableValue) {
|
||||
localStorage.removeItem(disableOnceKey);
|
||||
if (disableValue === nonce) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:e2e-seed-nonce", nonce);
|
||||
|
||||
// Hard-reset anything that could point to a developer's real daemon.
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
|
||||
);
|
||||
|
||||
await provide();
|
||||
|
||||
if (entries.length > 0 && testInfo.status !== testInfo.expectedStatus) {
|
||||
await testInfo.attach("browser-console", {
|
||||
body: entries.join("\n"),
|
||||
contentType: "text/plain",
|
||||
});
|
||||
}
|
||||
},
|
||||
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce },
|
||||
);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
const entries = consoleEntries.get(page);
|
||||
if (!entries || entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (testInfo.status === testInfo.expectedStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
await testInfo.attach("browser-console", {
|
||||
body: entries.join("\n"),
|
||||
contentType: "text/plain",
|
||||
});
|
||||
{ auto: true },
|
||||
],
|
||||
withWorkspace: async ({ page }, provide) => {
|
||||
const handle = createWithWorkspace(page);
|
||||
await provide(handle.withWorkspace);
|
||||
await handle.cleanup();
|
||||
},
|
||||
});
|
||||
|
||||
export { test, expect, type Page };
|
||||
|
||||
@@ -188,7 +188,7 @@ 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 fakeToolBinDir: string | null = null;
|
||||
let relayProcess: ChildProcess | null = null;
|
||||
|
||||
function resolveOptionalPaseoHomeEnv(value: string | undefined): string | null {
|
||||
@@ -209,14 +209,34 @@ interface OfferPayload {
|
||||
relay: { endpoint: string };
|
||||
}
|
||||
|
||||
async function createFakeGhBin(): Promise<string> {
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-gh-bin-"));
|
||||
async function createFakeToolBin(): Promise<string> {
|
||||
const binDir = await mkdtemp(path.join(tmpdir(), "paseo-e2e-tool-bin-"));
|
||||
const ghPath = path.join(binDir, "gh");
|
||||
await writeFile(
|
||||
ghPath,
|
||||
`#!/usr/bin/env node
|
||||
const { spawnSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function findRealGh() {
|
||||
const fakeBinDir = __dirname;
|
||||
for (const dir of (process.env.PATH || "").split(path.delimiter)) {
|
||||
if (dir === fakeBinDir) continue;
|
||||
const candidate = path.join(dir, "gh");
|
||||
try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch {}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function forwardToRealGh() {
|
||||
const realGh = findRealGh();
|
||||
if (!realGh) { console.error("[fake-gh] real gh not found in PATH"); process.exit(1); }
|
||||
const result = spawnSync(realGh, process.argv.slice(2), { stdio: "inherit", env: process.env });
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
if (args[0] === "auth" && args[1] === "status") {
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -238,8 +258,21 @@ if (args[0] === "pr" && args[1] === "list") {
|
||||
}
|
||||
|
||||
if (args[0] === "pr" && args[1] === "view" && args[2] === "--json" && args[3]) {
|
||||
console.error("no pull requests found for branch");
|
||||
process.exit(1);
|
||||
const fixture = path.join(process.cwd(), ".paseo-e2e-pr.json");
|
||||
if (fs.existsSync(fixture)) {
|
||||
console.log(fs.readFileSync(fixture, "utf8"));
|
||||
process.exit(0);
|
||||
}
|
||||
forwardToRealGh();
|
||||
}
|
||||
|
||||
if (args[0] === "api" && args[1] === "graphql") {
|
||||
const fixture = path.join(process.cwd(), ".paseo-e2e-timeline.json");
|
||||
if (fs.existsSync(fixture)) {
|
||||
console.log(fs.readFileSync(fixture, "utf8"));
|
||||
process.exit(0);
|
||||
}
|
||||
forwardToRealGh();
|
||||
}
|
||||
|
||||
if (args[0] === "issue" && args[1] === "list") {
|
||||
@@ -247,11 +280,31 @@ if (args[0] === "issue" && args[1] === "list") {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error("Unsupported fake gh invocation: " + args.join(" "));
|
||||
process.exit(1);
|
||||
forwardToRealGh();
|
||||
`,
|
||||
);
|
||||
await chmod(ghPath, 0o755);
|
||||
|
||||
const fakeEditorSource = `#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const recordPath = process.env.PASEO_E2E_EDITOR_RECORD_PATH;
|
||||
|
||||
if (recordPath) {
|
||||
fs.appendFileSync(recordPath, JSON.stringify({
|
||||
command: path.basename(process.argv[1]),
|
||||
args: process.argv.slice(2),
|
||||
cwd: process.cwd(),
|
||||
at: Date.now()
|
||||
}) + "\\n");
|
||||
}
|
||||
`;
|
||||
for (const editorCommand of ["cursor", "code"]) {
|
||||
const editorPath = path.join(binDir, editorCommand);
|
||||
await writeFile(editorPath, fakeEditorSource);
|
||||
await chmod(editorPath, 0o755);
|
||||
}
|
||||
|
||||
return binDir;
|
||||
}
|
||||
|
||||
@@ -268,7 +321,7 @@ function ensureRelayBuildArtifact(repoRoot: string): void {
|
||||
}
|
||||
|
||||
console.log("[e2e] Building @getpaseo/relay for daemon startup");
|
||||
execSync("npm run build --workspace=@getpaseo/relay", {
|
||||
execSync("npm run build:relay", {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
@@ -382,15 +435,19 @@ async function resolveDictationConfig(): Promise<DictationConfig> {
|
||||
);
|
||||
const hasDefaultLocalModelsDir =
|
||||
defaultLocalModelsDir.trim().length > 0 && existsSync(defaultLocalModelsDir);
|
||||
const dictationProvider = openAiUsable ? "openai" : "local";
|
||||
|
||||
if (dictationProvider === "local" && !hasDefaultLocalModelsDir) {
|
||||
throw new Error(
|
||||
"OpenAI key is not usable and local speech models are unavailable at ~/.paseo/models/local-speech. " +
|
||||
"Either provide a valid OPENAI_API_KEY or install local speech models before running app e2e tests.",
|
||||
// Fork PRs run without secrets and usually without local models. Don't crash
|
||||
// the whole Playwright run — disable dictation/voice and let tests that need
|
||||
// them gate on PASEO_DICTATION_ENABLED.
|
||||
if (!openAiUsable && !hasDefaultLocalModelsDir) {
|
||||
console.warn(
|
||||
"[e2e] Neither OPENAI_API_KEY nor local speech models found — running with dictation/voice disabled. " +
|
||||
"Tests that require dictation should gate on PASEO_DICTATION_ENABLED.",
|
||||
);
|
||||
return { openAiUsable: false, localModelsDir: null };
|
||||
}
|
||||
|
||||
const dictationProvider = openAiUsable ? "openai" : "local";
|
||||
const localModelsDir = dictationProvider === "local" ? defaultLocalModelsDir : null;
|
||||
console.log(
|
||||
`[e2e] Dictation STT provider: ${dictationProvider}${openAiUsable ? "" : " (OpenAI probe failed)"}`,
|
||||
@@ -480,13 +537,22 @@ async function awaitRelayReady(
|
||||
}
|
||||
}
|
||||
|
||||
async function startRelay(): Promise<number> {
|
||||
async function getAvailablePortExcluding(excludedPorts: Set<number>): Promise<number> {
|
||||
for (;;) {
|
||||
const port = await getAvailablePort();
|
||||
if (!excludedPorts.has(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startRelay(excludedPorts: Set<number>): 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 relayPort = await getAvailablePortExcluding(excludedPorts);
|
||||
const buffer = createLineBuffer();
|
||||
const state: RelayStreamState = { failureLine: null, readyForSelectedPort: false };
|
||||
|
||||
@@ -563,7 +629,8 @@ interface DaemonSpawnArgs {
|
||||
relayPort: number;
|
||||
metroPort: number;
|
||||
paseoHome: string;
|
||||
fakeGhBinDir: string;
|
||||
fakeToolBinDir: string;
|
||||
editorRecordPath: string;
|
||||
dictation: DictationConfig;
|
||||
buffer: ReturnType<typeof createLineBuffer>;
|
||||
}
|
||||
@@ -573,12 +640,13 @@ function startDaemon(args: DaemonSpawnArgs): ChildProcess {
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const { openAiUsable, localModelsDir } = args.dictation;
|
||||
|
||||
const child = spawn(tsxBin, ["src/server/index.ts"], {
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${args.fakeGhBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PATH: `${args.fakeToolBinDir}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||
PASEO_HOME: args.paseoHome,
|
||||
PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath,
|
||||
PASEO_SERVER_ID: "srv_e2e_test_daemon",
|
||||
PASEO_LISTEN: `0.0.0.0:${args.port}`,
|
||||
PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`,
|
||||
@@ -642,9 +710,9 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
|
||||
} else if (paseoHome) {
|
||||
console.log(`[e2e] Preserving PASEO_HOME: ${paseoHome}`);
|
||||
}
|
||||
if (fakeGhBinDir) {
|
||||
await rm(fakeGhBinDir, { recursive: true, force: true });
|
||||
fakeGhBinDir = null;
|
||||
if (fakeToolBinDir) {
|
||||
await rm(fakeToolBinDir, { recursive: true, force: true });
|
||||
fakeToolBinDir = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,7 +726,8 @@ export default async function globalSetup() {
|
||||
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 editorRecordPath = path.join(paseoHome, "editor-open-records.jsonl");
|
||||
fakeToolBinDir = await createFakeToolBin();
|
||||
const metroLineBuffer = createLineBuffer();
|
||||
const daemonLineBuffer = createLineBuffer();
|
||||
|
||||
@@ -669,14 +738,15 @@ export default async function globalSetup() {
|
||||
const dictation = await resolveDictationConfig();
|
||||
|
||||
try {
|
||||
const relayPort = await startRelay();
|
||||
const relayPort = await startRelay(new Set([port, metroPort]));
|
||||
metroProcess = startMetro(metroPort, metroLineBuffer);
|
||||
daemonProcess = startDaemon({
|
||||
port,
|
||||
relayPort,
|
||||
metroPort,
|
||||
paseoHome,
|
||||
fakeGhBinDir,
|
||||
fakeToolBinDir,
|
||||
editorRecordPath,
|
||||
dictation,
|
||||
buffer: daemonLineBuffer,
|
||||
});
|
||||
@@ -705,6 +775,8 @@ export default async function globalSetup() {
|
||||
process.env.E2E_SERVER_ID = offer.serverId;
|
||||
process.env.E2E_RELAY_DAEMON_PUBLIC_KEY = offer.daemonPublicKeyB64;
|
||||
process.env.E2E_METRO_PORT = String(metroPort);
|
||||
process.env.E2E_PASEO_HOME = paseoHome;
|
||||
process.env.E2E_EDITOR_RECORD_PATH = editorRecordPath;
|
||||
console.log(
|
||||
`[e2e] Test daemon started on port ${port}, Metro on port ${metroPort}, home: ${paseoHome}`,
|
||||
);
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
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;
|
||||
|
||||
@@ -14,157 +9,6 @@ export interface ScrollMetrics {
|
||||
distanceFromBottom: number;
|
||||
}
|
||||
|
||||
export interface SeededAgent {
|
||||
id: string;
|
||||
title: string;
|
||||
expectedTailText: string;
|
||||
url: string;
|
||||
workspaceUrl: string;
|
||||
}
|
||||
|
||||
export interface DaemonClientInstance {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
createAgent(options: {
|
||||
provider: string;
|
||||
model: string;
|
||||
thinkingOptionId: string;
|
||||
modeId: string;
|
||||
cwd: string;
|
||||
title: string;
|
||||
initialPrompt: string;
|
||||
}): Promise<{ id: string }>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
||||
}
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
return `ws://127.0.0.1:${daemonPort}/ws`;
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function buildReplyBlock(label: string, lineCount = 14): string {
|
||||
return Array.from({ length: lineCount }, (_, index) => {
|
||||
const line = (index + 1).toString().padStart(2, "0");
|
||||
return `${label} line ${line} anchor verification text keeps wrapping stable across resize and composer growth.`;
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
function buildProtocolMessage(label: string, lineCount = 14): string {
|
||||
return [
|
||||
"For every message in this chat, reply with exactly the text after the final line `REPLY:`.",
|
||||
"Do not add extra words, bullets, markdown fences, or tool calls.",
|
||||
"REPLY:",
|
||||
buildReplyBlock(label, lineCount),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildReplyMessage(label: string, lineCount = 14): string {
|
||||
return ["REPLY:", buildReplyBlock(label, lineCount)].join("\n");
|
||||
}
|
||||
|
||||
export function createReplyTurn(label: string): {
|
||||
message: string;
|
||||
expectedReply: string;
|
||||
} {
|
||||
return {
|
||||
message: buildReplyMessage(label),
|
||||
expectedReply: buildReplyBlock(label),
|
||||
};
|
||||
}
|
||||
|
||||
interface DaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: DaemonClientConfig) => DaemonClientInstance
|
||||
> {
|
||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: DaemonClientConfig) => DaemonClientInstance;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectDaemonClient(): Promise<DaemonClientInstance> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `app-e2e-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
webSocketFactory,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function seedBottomAnchorAgent(input: {
|
||||
client: DaemonClientInstance;
|
||||
cwd: string;
|
||||
title?: string;
|
||||
turnCount?: number;
|
||||
lineCount?: number;
|
||||
}): Promise<SeededAgent> {
|
||||
const title = input.title ?? `bottom-anchor-${Date.now()}`;
|
||||
const turnCount = Math.max(3, input.turnCount ?? 5);
|
||||
const lineCount = Math.max(14, input.lineCount ?? 14);
|
||||
const created = await input.client.createAgent({
|
||||
provider: "codex",
|
||||
model: "gpt-5.4-mini",
|
||||
thinkingOptionId: "low",
|
||||
modeId: "full-access",
|
||||
cwd: input.cwd,
|
||||
title,
|
||||
initialPrompt: buildProtocolMessage(`${title}-turn-00`, lineCount),
|
||||
});
|
||||
const initialFinish = await input.client.waitForFinish(created.id, 120000);
|
||||
if (initialFinish.status !== "idle") {
|
||||
throw new Error(
|
||||
`Expected seeded agent ${created.id} to become idle after initial prompt, got ${initialFinish.status}.`,
|
||||
);
|
||||
}
|
||||
|
||||
let expectedTailText = buildReplyBlock(`${title}-turn-00`, lineCount);
|
||||
for (let index = 1; index < turnCount; index += 1) {
|
||||
const label = `${title}-turn-${index.toString().padStart(2, "0")}`;
|
||||
expectedTailText = buildReplyBlock(label, lineCount);
|
||||
await input.client.sendAgentMessage(created.id, buildReplyMessage(label, lineCount));
|
||||
const finish = await input.client.waitForFinish(created.id, 120000);
|
||||
if (finish.status !== "idle") {
|
||||
throw new Error(
|
||||
`Expected seeded agent ${created.id} to become idle after turn ${index}, got ${finish.status}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
title,
|
||||
expectedTailText,
|
||||
url: `${buildHostWorkspaceRoute(getServerId(), input.cwd)}?open=${encodeURIComponent(`agent:${created.id}`)}`,
|
||||
workspaceUrl: buildHostWorkspaceRoute(getServerId(), input.cwd),
|
||||
};
|
||||
}
|
||||
|
||||
function getVisibleChatScroll(page: Page) {
|
||||
return page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
}
|
||||
@@ -201,48 +45,6 @@ export async function readScrollMetrics(page: Page): Promise<ScrollMetrics> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function scrollUpFromBottom(page: Page, pixels: number): Promise<void> {
|
||||
const scrollViewport = getVisibleChatScroll(page);
|
||||
await expect(scrollViewport).toHaveCount(1, { timeout: 30000 });
|
||||
let remaining = Math.max(0, pixels);
|
||||
while (remaining > 0) {
|
||||
const delta = Math.min(240, remaining);
|
||||
await scrollViewport.evaluate((element: Element, step: number) => {
|
||||
const scrollContainer = element as HTMLElement;
|
||||
scrollContainer.dispatchEvent(
|
||||
new WheelEvent("wheel", {
|
||||
deltaY: -step,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
scrollContainer.scrollTop = Math.max(0, scrollContainer.scrollTop - step);
|
||||
scrollContainer.dispatchEvent(new Event("scroll", { bubbles: true }));
|
||||
}, delta);
|
||||
remaining -= delta;
|
||||
|
||||
if ((await readScrollMetrics(page)).distanceFromBottom > NEAR_BOTTOM_THRESHOLD_PX) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForAgentReady(page: Page, expectedTailText?: string): Promise<void> {
|
||||
await expect(getVisibleChatScroll(page)).toBeVisible({ timeout: 60000 });
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible({
|
||||
timeout: 60000,
|
||||
});
|
||||
await expect(page.getByTestId("agent-loading")).toHaveCount(0, { timeout: 60000 });
|
||||
if (expectedTailText) {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.contentHeight;
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectNearBottom(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -252,15 +54,6 @@ export async function expectNearBottom(page: Page): Promise<void> {
|
||||
.toBeLessThanOrEqual(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function expectDetachedFromBottom(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const metrics = await readScrollMetrics(page);
|
||||
return metrics.distanceFromBottom;
|
||||
})
|
||||
.toBeGreaterThan(NEAR_BOTTOM_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
export async function waitForContentGrowth(
|
||||
page: Page,
|
||||
previousContentHeight: number,
|
||||
@@ -273,11 +66,3 @@ export async function waitForContentGrowth(
|
||||
.toBeGreaterThan(previousContentHeight);
|
||||
return readScrollMetrics(page);
|
||||
}
|
||||
|
||||
export async function getChatContainerKey(page: Page): Promise<string | null> {
|
||||
return getVisibleChatScroll(page).evaluate((element) => {
|
||||
const nativeId = (element as HTMLElement).id;
|
||||
const prefix = "agent-chat-scroll-";
|
||||
return nativeId.startsWith(prefix) ? nativeId.slice(prefix.length) : null;
|
||||
});
|
||||
}
|
||||
|
||||
35
packages/app/e2e/helpers/agent-stream.ts
Normal file
35
packages/app/e2e/helpers/agent-stream.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { readScrollMetrics, waitForContentGrowth, expectNearBottom } from "./agent-bottom-anchor";
|
||||
|
||||
export async function awaitAssistantMessage(page: Page, hasText?: string | RegExp): Promise<void> {
|
||||
const messages = page.getByTestId("assistant-message");
|
||||
const target = hasText === undefined ? messages.first() : messages.filter({ hasText }).first();
|
||||
await expect(target).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function awaitToolCall(page: Page, toolName: string | RegExp): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("tool-call-badge").filter({ hasText: toolName }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectAgentIdle(page: Page, timeout = 30_000): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, { timeout });
|
||||
}
|
||||
|
||||
// The working indicator is an animated spinner View — no semantic ARIA role, testId is correct.
|
||||
export async function expectInlineWorkingIndicator(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("turn-working-indicator")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectTurnCopyButton(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Copy turn" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectScrollFollowsNewContent(page: Page): Promise<void> {
|
||||
const { contentHeight } = await readScrollMetrics(page);
|
||||
await waitForContentGrowth(page, contentHeight);
|
||||
await expectNearBottom(page);
|
||||
}
|
||||
@@ -1,217 +1,33 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function getE2EDaemonPort(): string {
|
||||
const port = process.env.E2E_DAEMON_PORT;
|
||||
if (!port) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
if (port === "6767") {
|
||||
throw new Error(
|
||||
"E2E_DAEMON_PORT is 6767. Refusing to run e2e against the default local daemon.",
|
||||
);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
async function ensureE2EStorageSeeded(page: Page): Promise<void> {
|
||||
const port = getE2EDaemonPort();
|
||||
const expectedEndpoint = `127.0.0.1:${port}`;
|
||||
const expectedServerId = process.env.E2E_SERVER_ID;
|
||||
if (!expectedServerId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
|
||||
const needsReset = await page.evaluate(
|
||||
({ expectedEndpoint: endpoint, expectedServerId: serverId }) => {
|
||||
const raw = localStorage.getItem("@paseo:daemon-registry");
|
||||
if (!raw) return true;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || parsed.length !== 1) return true;
|
||||
const entry = parsed[0] as { serverId?: string; connections?: unknown };
|
||||
if (entry?.serverId !== serverId) return true;
|
||||
const connections = entry?.connections;
|
||||
if (!Array.isArray(connections)) return true;
|
||||
if (
|
||||
connections.some(
|
||||
(c: { type?: string; endpoint?: string }) =>
|
||||
c?.type === "directTcp" &&
|
||||
typeof c?.endpoint === "string" &&
|
||||
/:6767\b/.test(c.endpoint),
|
||||
)
|
||||
)
|
||||
return true;
|
||||
return !connections.some(
|
||||
(c: { type?: string; endpoint?: string }) =>
|
||||
c?.type === "directTcp" && c?.endpoint === endpoint,
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{ expectedEndpoint, expectedServerId },
|
||||
);
|
||||
|
||||
if (!needsReset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
const daemon = buildSeededHost({
|
||||
serverId: expectedServerId,
|
||||
endpoint: expectedEndpoint,
|
||||
nowIso,
|
||||
});
|
||||
const preferences = buildCreateAgentPreferences(expectedServerId);
|
||||
await page.evaluate(
|
||||
({ daemon: seededDaemon, preferences: seededPreferences }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededDaemon]));
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(seededPreferences));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
},
|
||||
{ daemon, preferences },
|
||||
);
|
||||
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
function parseRegistryEntry(registryRaw: string): { serverId: string; connections: unknown } {
|
||||
let registry: unknown;
|
||||
try {
|
||||
registry = JSON.parse(registryRaw);
|
||||
} catch {
|
||||
throw new Error("E2E expected @paseo:daemon-registry to be valid JSON.");
|
||||
}
|
||||
if (!Array.isArray(registry) || registry.length !== 1) {
|
||||
throw new Error(
|
||||
`E2E expected @paseo:daemon-registry to contain exactly 1 daemon (got ${Array.isArray(registry) ? registry.length : "non-array"}).`,
|
||||
);
|
||||
}
|
||||
const daemon = registry[0] as { serverId?: string; connections?: unknown };
|
||||
if (typeof daemon?.serverId !== "string" || daemon.serverId.length === 0) {
|
||||
throw new Error(
|
||||
`E2E expected seeded daemon to have a string serverId (got ${String(daemon?.serverId)}).`,
|
||||
);
|
||||
}
|
||||
return { serverId: daemon.serverId, connections: daemon.connections };
|
||||
}
|
||||
|
||||
function assertDaemonConnections(connections: unknown, expectedEndpoint: string): void {
|
||||
if (
|
||||
!Array.isArray(connections) ||
|
||||
!connections.some(
|
||||
(c: { type?: string; endpoint?: string }) =>
|
||||
c?.type === "directTcp" && c?.endpoint === expectedEndpoint,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`E2E expected seeded daemon connections to include directTcp ${expectedEndpoint} (got ${JSON.stringify(connections)}).`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
Array.isArray(connections) &&
|
||||
connections.some(
|
||||
(c: { type?: string; endpoint?: string }) =>
|
||||
c?.type === "directTcp" && typeof c?.endpoint === "string" && /:6767\b/.test(c.endpoint),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`E2E detected a daemon endpoint pointing at :6767 (${JSON.stringify(connections)}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPreferencesMatch(prefsRaw: string, serverId: string): void {
|
||||
try {
|
||||
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
|
||||
if (prefs?.serverId !== serverId) {
|
||||
throw new Error(
|
||||
`E2E expected create-agent-preferences.serverId to match seeded daemon serverId (${serverId}) (got ${String(prefs?.serverId)}).`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) throw error;
|
||||
throw new Error("E2E expected @paseo:create-agent-preferences to be valid JSON.", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
|
||||
const port = getE2EDaemonPort();
|
||||
const expectedEndpoint = `127.0.0.1:${port}`;
|
||||
const expectedServerId = process.env.E2E_SERVER_ID;
|
||||
if (!expectedServerId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
|
||||
const snapshot = await page.evaluate(() => {
|
||||
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
||||
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
||||
return { registryRaw, prefsRaw };
|
||||
});
|
||||
|
||||
if (!snapshot.registryRaw) {
|
||||
throw new Error("E2E expected @paseo:daemon-registry to be set before app load.");
|
||||
}
|
||||
|
||||
const { serverId, connections } = parseRegistryEntry(snapshot.registryRaw);
|
||||
if (serverId !== expectedServerId) {
|
||||
throw new Error(
|
||||
`E2E expected seeded daemon serverId to be ${expectedServerId} (got ${serverId}).`,
|
||||
);
|
||||
}
|
||||
assertDaemonConnections(connections, expectedEndpoint);
|
||||
|
||||
if (!snapshot.prefsRaw) {
|
||||
throw new Error("E2E expected @paseo:create-agent-preferences to be set before app load.");
|
||||
}
|
||||
assertPreferencesMatch(snapshot.prefsRaw, serverId);
|
||||
}
|
||||
import { escapeRegex } from "./regex";
|
||||
|
||||
export const gotoAppShell = async (page: Page) => {
|
||||
await page.goto("/");
|
||||
await ensureE2EStorageSeeded(page);
|
||||
};
|
||||
|
||||
export const gotoHome = async (page: Page) => {
|
||||
await gotoAppShell(page);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
if (
|
||||
!(await composer
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false))
|
||||
) {
|
||||
const addProjectCta = page.getByText("Add a project", { exact: true }).first();
|
||||
const addProjectSidebar = page.getByText("Add project", { exact: true }).first();
|
||||
const newAgentButton = page.getByText("New agent", { exact: true }).first();
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
const entryButton = page
|
||||
.getByText("Add a project", { exact: true })
|
||||
.or(page.getByText("Add project", { exact: true }))
|
||||
.or(page.getByText("New agent", { exact: true }))
|
||||
.first();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await addProjectCta.isVisible().catch(() => false)) ||
|
||||
(await addProjectSidebar.isVisible().catch(() => false)) ||
|
||||
(await newAgentButton.isVisible().catch(() => false)),
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await composer.isVisible().catch(() => false)) ||
|
||||
(await entryButton.isVisible().catch(() => false)),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
if (await addProjectCta.isVisible().catch(() => false)) {
|
||||
await addProjectCta.click();
|
||||
} else if (await addProjectSidebar.isVisible().catch(() => false)) {
|
||||
await addProjectSidebar.click();
|
||||
} else {
|
||||
await newAgentButton.click();
|
||||
}
|
||||
if (!(await composer.isVisible().catch(() => false))) {
|
||||
await entryButton.click();
|
||||
}
|
||||
await expect(composer.first()).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||
};
|
||||
|
||||
export const openSettings = async (page: Page) => {
|
||||
@@ -341,46 +157,6 @@ export const setWorkingDirectory = async (page: Page, directory: string) => {
|
||||
};
|
||||
|
||||
export const ensureHostSelected = async (page: Page) => {
|
||||
await ensureE2EStorageSeeded(page);
|
||||
|
||||
// Absolute verification that we're using the per-run e2e daemon (never :6767).
|
||||
// Also self-heal a rare case where app code rewrites daemon IDs after boot, by
|
||||
// realigning create-agent-preferences.serverId to the sole seeded daemon.
|
||||
try {
|
||||
await assertE2EUsesSeededTestDaemon(page);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!/create-agent-preferences\.serverId/i.test(message)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const fix = await page.evaluate(() => {
|
||||
const registryRaw = localStorage.getItem("@paseo:daemon-registry");
|
||||
const prefsRaw = localStorage.getItem("@paseo:create-agent-preferences");
|
||||
if (!registryRaw || !prefsRaw) return { ok: false, reason: "missing storage" } as const;
|
||||
const registry = JSON.parse(registryRaw) as Array<{ serverId?: string }>;
|
||||
const prefs = JSON.parse(prefsRaw) as { serverId?: string };
|
||||
if (!Array.isArray(registry) || registry.length !== 1)
|
||||
return { ok: false, reason: "registry shape" } as const;
|
||||
const serverId = registry[0]?.serverId;
|
||||
if (typeof serverId !== "string" || serverId.length === 0)
|
||||
return { ok: false, reason: "missing serverId" } as const;
|
||||
prefs.serverId = serverId;
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(prefs));
|
||||
// Prevent the fixture's init-script from overwriting the corrected prefs on reload.
|
||||
const nonce = localStorage.getItem("@paseo:e2e-seed-nonce") ?? "1";
|
||||
localStorage.setItem("@paseo:e2e-disable-default-seed-once", nonce);
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
if (!fix.ok) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await page.reload();
|
||||
await assertE2EUsesSeededTestDaemon(page);
|
||||
}
|
||||
|
||||
const input = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(input).toBeVisible();
|
||||
|
||||
@@ -392,7 +168,7 @@ export const ensureHostSelected = async (page: Page) => {
|
||||
if (await selectHostLabel.isVisible()) {
|
||||
await selectHostLabel.click();
|
||||
|
||||
// E2E safety: we enforce a single seeded daemon, so the option should be unambiguous.
|
||||
// We enforce a single seeded daemon, so the option should be unambiguous.
|
||||
const localhostOption = page.getByText("localhost", { exact: true }).first();
|
||||
const daemonIdOption = page
|
||||
.getByText(process.env.E2E_SERVER_ID ?? "srv_e2e_test_daemon", { exact: true })
|
||||
@@ -657,57 +433,3 @@ export const createAgentInRepo = async (
|
||||
await setWorkingDirectory(page, config.directory);
|
||||
await createAgent(page, config.prompt);
|
||||
};
|
||||
|
||||
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
|
||||
const promptText = page.getByTestId("permission-request-question").first();
|
||||
await expect(promptText).toBeVisible({ timeout });
|
||||
};
|
||||
|
||||
export const allowPermission = async (page: Page) => {
|
||||
const acceptButton = page.getByTestId("permission-request-accept").first();
|
||||
await expect(acceptButton).toBeVisible({ timeout: 5000 });
|
||||
await acceptButton.click();
|
||||
};
|
||||
|
||||
export const denyPermission = async (page: Page) => {
|
||||
const denyButton = page.getByTestId("permission-request-deny").first();
|
||||
await expect(denyButton).toBeVisible({ timeout: 5000 });
|
||||
await denyButton.click();
|
||||
};
|
||||
|
||||
export async function waitForAgentFinishUI(page: Page, timeout = 30000) {
|
||||
// Wait for the stop button to disappear
|
||||
const stopButton = page.getByRole("button", { name: /stop|cancel/i });
|
||||
|
||||
// First, let's debug what's happening - wait a bit to see the state
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Check if stop button is visible
|
||||
const isVisible = await stopButton.isVisible().catch(() => false);
|
||||
|
||||
if (isVisible) {
|
||||
// If stop button is still visible after permission denial,
|
||||
// it might be that the agent is waiting for something.
|
||||
// Let's check if there's a tool call result or other UI indication
|
||||
|
||||
// Look for any indication that the agent has processed the permission denial
|
||||
const toolCallResult = page.getByText(/permission.*denied|denied|blocked/i);
|
||||
|
||||
// Wait for the tool call result to appear
|
||||
await expect(toolCallResult)
|
||||
.toBeVisible({ timeout: 10000 })
|
||||
.catch(() => {
|
||||
// If no specific message, just wait for the button to disappear
|
||||
});
|
||||
|
||||
// Now wait for the stop button to disappear
|
||||
await expect(stopButton).not.toBeVisible({ timeout });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getToolCallCount(page: Page): Promise<number> {
|
||||
// Tool calls are rendered as ExpandableBadge components with tool names like Bash, Write, Read, etc.
|
||||
// They appear as pressable badges in the agent stream
|
||||
const toolCallBadges = page.locator('[data-testid="tool-call-badge"]');
|
||||
return toolCallBadges.count();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { getServerId } from "./server-id";
|
||||
import { waitForWorkspaceTabsVisible } from "./workspace-tabs";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
@@ -17,20 +16,31 @@ export interface ArchiveTabAgent {
|
||||
cwd: string;
|
||||
}
|
||||
|
||||
interface ArchiveTabDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
function buildSeededStoragePayload() {
|
||||
const nowIso = new Date().toISOString();
|
||||
return {
|
||||
daemon: buildSeededHost({
|
||||
serverId: getServerId(),
|
||||
endpoint: `127.0.0.1:${getE2EDaemonPort()}`,
|
||||
nowIso,
|
||||
}),
|
||||
preferences: buildCreateAgentPreferences(getServerId()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice of a daemon client `createIdleAgent` needs: spawn an agent and await
|
||||
* its idle upsert. The shared seed client satisfies it, so a spec can seed an
|
||||
* idle agent from the same client it uses for everything else.
|
||||
*/
|
||||
export interface IdleAgentSeedClient {
|
||||
createAgent(options: {
|
||||
provider: string;
|
||||
model: string;
|
||||
thinkingOptionId?: string;
|
||||
modeId: string;
|
||||
cwd: string;
|
||||
title: string;
|
||||
initialPrompt?: string;
|
||||
}): Promise<{ id: string }>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||
waitForFinish(agentId: string, timeout?: number): Promise<{ status: string }>;
|
||||
waitForAgentUpsert(
|
||||
agentId: string,
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
@@ -38,76 +48,8 @@ interface ArchiveTabDaemonClient {
|
||||
): Promise<{ status: string }>;
|
||||
}
|
||||
|
||||
function getDaemonPort(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
if (daemonPort === "6767") {
|
||||
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon.");
|
||||
}
|
||||
return daemonPort;
|
||||
}
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set.");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
return `ws://127.0.0.1:${getDaemonPort()}/ws`;
|
||||
}
|
||||
|
||||
function buildSeededStoragePayload() {
|
||||
const nowIso = new Date().toISOString();
|
||||
return {
|
||||
daemon: buildSeededHost({
|
||||
serverId: getServerId(),
|
||||
endpoint: `127.0.0.1:${getDaemonPort()}`,
|
||||
nowIso,
|
||||
}),
|
||||
preferences: buildCreateAgentPreferences(getServerId()),
|
||||
};
|
||||
}
|
||||
|
||||
interface ArchiveTabDaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: ArchiveTabDaemonClientConfig) => ArchiveTabDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectArchiveTabDaemonClient(): Promise<ArchiveTabDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const webSocketFactory = createNodeWebSocketFactory();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `app-e2e-archive-tab-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
webSocketFactory,
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function createIdleAgent(
|
||||
client: ArchiveTabDaemonClient,
|
||||
client: IdleAgentSeedClient,
|
||||
input: { cwd: string; title: string },
|
||||
): Promise<ArchiveTabAgent> {
|
||||
const created = await client.createAgent({
|
||||
@@ -133,7 +75,7 @@ export async function createIdleAgent(
|
||||
}
|
||||
|
||||
export async function archiveAgentFromDaemon(
|
||||
client: ArchiveTabDaemonClient,
|
||||
client: { archiveAgent(agentId: string): Promise<{ archivedAt: string }> },
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
await client.archiveAgent(agentId);
|
||||
@@ -265,8 +207,10 @@ export async function openSessions(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
const AGENT_ROW_SELECTOR = '[data-testid^="agent-row-"]';
|
||||
|
||||
function getSessionRowByTitle(page: Page, title: string) {
|
||||
return page.locator('[data-testid^="agent-row-"]').filter({ hasText: title }).first();
|
||||
return page.locator(AGENT_ROW_SELECTOR).filter({ hasText: title }).first();
|
||||
}
|
||||
|
||||
export async function expectSessionRowVisible(page: Page, title: string): Promise<void> {
|
||||
@@ -283,6 +227,12 @@ export async function clickSessionRow(page: Page, title: string): Promise<void>
|
||||
await row.click();
|
||||
}
|
||||
|
||||
export async function expectSessionsEmptyState(page: Page): Promise<void> {
|
||||
// Guard: if session rows appear, a prior spec polluted the shared daemon — see 00-sessions-empty.spec.ts.
|
||||
await expect(page.locator(AGENT_ROW_SELECTOR)).toHaveCount(0, { timeout: 5_000 });
|
||||
await expect(page.getByText("No sessions yet")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function archiveAgentFromSessions(
|
||||
page: Page,
|
||||
input: { agentId: string; title: string },
|
||||
|
||||
195
packages/app/e2e/helpers/composer.ts
Normal file
195
packages/app/e2e/helpers/composer.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
|
||||
import { connectWorkspaceSetupClient, openHomeWithProject } from "./workspace-setup";
|
||||
import { selectWorkspaceInSidebar } from "./sidebar";
|
||||
import { getServerId } from "./server-id";
|
||||
import { waitForTabBar } from "./launcher";
|
||||
|
||||
function composerInput(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
}
|
||||
|
||||
export function composerLocator(page: Page) {
|
||||
return composerInput(page);
|
||||
}
|
||||
|
||||
export async function expectComposerVisible(
|
||||
page: Page,
|
||||
options?: { timeout?: number },
|
||||
): Promise<void> {
|
||||
await expect(composerInput(page)).toBeVisible({ timeout: options?.timeout ?? 15_000 });
|
||||
}
|
||||
|
||||
export async function expectComposerDisabled(page: Page): Promise<void> {
|
||||
// React Native TextInput with editable={false} renders as <textarea readonly> on web,
|
||||
// not <textarea disabled>. Use not.toBeEditable() to match either form.
|
||||
await expect(composerInput(page)).not.toBeEditable({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function expectComposerDraft(page: Page, text: string): Promise<void> {
|
||||
await expect(composerInput(page)).toHaveValue(text, { timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function expectComposerEditable(page: Page): Promise<void> {
|
||||
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function submitMessage(page: Page, text: string): Promise<void> {
|
||||
const input = composerInput(page);
|
||||
await expect(input).toBeEditable({ timeout: 30_000 });
|
||||
await input.fill(text);
|
||||
await input.press("Enter");
|
||||
}
|
||||
|
||||
export async function fillComposerDraft(page: Page, text: string): Promise<void> {
|
||||
await composerInput(page).fill(text);
|
||||
}
|
||||
|
||||
export async function sendDraftToQueue(page: Page): Promise<void> {
|
||||
await composerInput(page).press("Control+Enter");
|
||||
}
|
||||
|
||||
export async function expectQueuedMessageButton(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Send queued message now" })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelAgent(page: Page): Promise<void> {
|
||||
const stopButton = page.getByRole("button", { name: /stop|cancel/i }).first();
|
||||
await expect(stopButton).toBeVisible({ timeout: 10_000 });
|
||||
await stopButton.click();
|
||||
}
|
||||
|
||||
/** Escape is bound to the "agent.interrupt" keyboard shortcut. */
|
||||
export async function pressInterruptShortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function openAttachmentMenu(page: Page): Promise<void> {
|
||||
await page.getByTestId("message-input-attach-button").filter({ visible: true }).first().click();
|
||||
await expect(page.getByTestId("message-input-attachment-menu")).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function expectAttachButtonDisabled(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("message-input-attach-button").filter({ visible: true }).first(),
|
||||
).toBeDisabled({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
export async function attachImageFromMenu(
|
||||
page: Page,
|
||||
file: { name: string; mimeType: string; buffer: Buffer },
|
||||
): Promise<void> {
|
||||
const chooserPromise = page.waitForEvent("filechooser", { timeout: 10_000 });
|
||||
await openAttachmentMenu(page);
|
||||
await page.getByTestId("message-input-attachment-menu-item-image").click();
|
||||
const chooser = await chooserPromise;
|
||||
await chooser.setFiles([file]);
|
||||
}
|
||||
|
||||
export async function expectAttachmentPill(page: Page, testID: string): Promise<void> {
|
||||
await expect(page.getByTestId(testID).first()).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
/** Hover to reveal the X button (hidden until hover on desktop web), then click by accessible label. */
|
||||
export async function removeAttachmentPill(
|
||||
page: Page,
|
||||
pillTestId: string,
|
||||
removeAccessibilityLabel: string,
|
||||
): Promise<void> {
|
||||
await page.getByTestId(pillTestId).first().hover();
|
||||
await page.getByRole("button", { name: removeAccessibilityLabel }).first().click();
|
||||
}
|
||||
|
||||
export async function expectGithubAttachmentPill(
|
||||
page: Page,
|
||||
input: { number: number; title: string },
|
||||
): Promise<void> {
|
||||
const pill = page.getByTestId("composer-github-attachment-pill").first();
|
||||
await expect(pill).toBeVisible({ timeout: 10_000 });
|
||||
await expect(pill).toContainText(`#${input.number}`);
|
||||
await expect(pill).toContainText(input.title);
|
||||
}
|
||||
|
||||
export async function openImageLightbox(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open image attachment" }).first().click();
|
||||
await expect(page.getByTestId("attachment-lightbox-close")).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function closeImageLightbox(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("attachment-lightbox-close")).not.toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function openGithubPickerFromMenu(page: Page): Promise<void> {
|
||||
await openAttachmentMenu(page);
|
||||
await page.getByTestId("message-input-attachment-menu-item-github").click();
|
||||
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
/** Open picker, type a query, wait for the matching option by id (e.g. "issue:3", "pr:1"), and click it. */
|
||||
export async function selectGithubOption(
|
||||
page: Page,
|
||||
searchTerm: string,
|
||||
optionId: string,
|
||||
): Promise<void> {
|
||||
await openGithubPickerFromMenu(page);
|
||||
const searchInput = page.getByPlaceholder("Search issues and PRs...");
|
||||
await expect(searchInput).toBeVisible({ timeout: 5_000 });
|
||||
await searchInput.fill(searchTerm);
|
||||
const option = page.getByTestId(`composer-github-option-${optionId}`);
|
||||
await expect(option).toBeVisible({ timeout: 15_000 });
|
||||
await option.click();
|
||||
}
|
||||
|
||||
export interface MockAgentSetup {
|
||||
client: SeedDaemonClient;
|
||||
repo: Awaited<ReturnType<typeof createTempGitRepo>>;
|
||||
}
|
||||
|
||||
/** Create a temp repo, start a mock agent, navigate to it, and wait for it to be running. */
|
||||
export async function startRunningMockAgent(
|
||||
page: Page,
|
||||
opts: { prefix: string; model: string; prompt: string },
|
||||
): Promise<MockAgentSetup> {
|
||||
const serverId = getServerId();
|
||||
|
||||
const repo = await createTempGitRepo(opts.prefix);
|
||||
const client = await connectSeedClient();
|
||||
const opened = await client.openProject(repo.path);
|
||||
if (!opened.workspace) throw new Error(opened.error ?? "Failed to open project");
|
||||
const agent = await client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: repo.path,
|
||||
model: opts.model,
|
||||
initialPrompt: opts.prompt,
|
||||
});
|
||||
const agentUrl = `${buildHostWorkspaceRoute(serverId, repo.path)}?open=${encodeURIComponent(`agent:${agent.id}`)}`;
|
||||
await page.goto(agentUrl);
|
||||
await expect(page.getByRole("button", { name: /stop|cancel/i }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expectComposerVisible(page);
|
||||
return { client, repo };
|
||||
}
|
||||
|
||||
export interface GithubWorkspaceHandle {
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Open a workspace backed by an existing repo path (e.g. a cloned GitHub repo). */
|
||||
export async function openGithubWorkspace(
|
||||
page: Page,
|
||||
repoPath: string,
|
||||
): Promise<GithubWorkspaceHandle> {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const opened = await client.openProject(repoPath);
|
||||
if (!opened.workspace) throw new Error(opened.error ?? `Failed to open project ${repoPath}`);
|
||||
await openHomeWithProject(page, repoPath);
|
||||
await selectWorkspaceInSidebar(page, opened.workspace.id);
|
||||
await waitForTabBar(page);
|
||||
return { cleanup: () => client.close().catch(() => undefined) };
|
||||
}
|
||||
55
packages/app/e2e/helpers/daemon-client-loader.ts
Normal file
55
packages/app/e2e/helpers/daemon-client-loader.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
|
||||
export async function loadDaemonClientConstructor<ClientConfig, ClientInstance>(): Promise<
|
||||
new (config: ClientConfig) => ClientInstance
|
||||
> {
|
||||
const repoRoot = path.resolve(__dirname, "../../../../");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/client/dist/daemon-client.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: ClientConfig) => ClientInstance;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
interface E2EDaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
appVersion?: string;
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
function resolveDaemonWsUrl(): string {
|
||||
return `ws://127.0.0.1:${getE2EDaemonPort()}/ws`;
|
||||
}
|
||||
|
||||
export interface ConnectDaemonClientOptions {
|
||||
clientIdPrefix: string;
|
||||
appVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects an in-test daemon client over the isolated E2E daemon's WebSocket.
|
||||
* The port-6767 guard keeps tests off the developer daemon. Each helper passes
|
||||
* its own typed client interface as the generic.
|
||||
*/
|
||||
export async function connectDaemonClient<ClientInstance extends { connect(): Promise<void> }>(
|
||||
options: ConnectDaemonClientOptions,
|
||||
): Promise<ClientInstance> {
|
||||
const DaemonClient = await loadDaemonClientConstructor<E2EDaemonClientConfig, ClientInstance>();
|
||||
const client = new DaemonClient({
|
||||
url: resolveDaemonWsUrl(),
|
||||
clientId: `${options.clientIdPrefix}-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
appVersion: options.appVersion,
|
||||
webSocketFactory: createNodeWebSocketFactory(),
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
38
packages/app/e2e/helpers/daemon-port.ts
Normal file
38
packages/app/e2e/helpers/daemon-port.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { escapeRegex } from "./regex";
|
||||
|
||||
/**
|
||||
* Resolves the isolated E2E daemon's port, which Playwright's globalSetup
|
||||
* publishes into the environment before any spec runs. Helpers and specs that
|
||||
* build daemon WebSocket URLs, route patterns, or host endpoints share this
|
||||
* accessor instead of re-reading the env var.
|
||||
*
|
||||
* The port-6767 guard is a hard guardrail: 6767 is the developer's default
|
||||
* daemon, which manages real agents. The e2e port is never legitimately 6767,
|
||||
* so refusing it here keeps every test off the developer daemon.
|
||||
*/
|
||||
export function getE2EDaemonPort(): string {
|
||||
const port = process.env.E2E_DAEMON_PORT;
|
||||
if (!port) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
if (port === "6767") {
|
||||
throw new Error("E2E_DAEMON_PORT must not point at the developer daemon (6767).");
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Playwright `routeWebSocket` matcher for a WebSocket on `port`. Matches the
|
||||
* `:<port>` segment at a word boundary, so it catches the URL regardless of
|
||||
* host or path. Use this when intercepting connections to an arbitrary port
|
||||
* (e.g. blocking an unreachable test host); for the E2E daemon itself, prefer
|
||||
* `daemonWsRoutePattern()`.
|
||||
*/
|
||||
export function wsRoutePatternForPort(port: string): RegExp {
|
||||
return new RegExp(`:${escapeRegex(port)}\\b`);
|
||||
}
|
||||
|
||||
/** `routeWebSocket` matcher for the isolated E2E daemon's WebSocket. */
|
||||
export function daemonWsRoutePattern(): RegExp {
|
||||
return wsRoutePatternForPort(getE2EDaemonPort());
|
||||
}
|
||||
@@ -30,10 +30,15 @@ export function buildSeededHost(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export const TEST_MOCK_PROVIDER_PREFERENCES = {
|
||||
...TEST_PROVIDER_PREFERENCES,
|
||||
mock: { model: "ten-second-stream" },
|
||||
} as const;
|
||||
|
||||
export function buildCreateAgentPreferences(serverId: string) {
|
||||
return {
|
||||
serverId,
|
||||
provider: "codex" as const,
|
||||
providerPreferences: TEST_PROVIDER_PREFERENCES,
|
||||
provider: "mock" as const,
|
||||
providerPreferences: TEST_MOCK_PROVIDER_PREFERENCES,
|
||||
};
|
||||
}
|
||||
|
||||
289
packages/app/e2e/helpers/desktop-updates.ts
Normal file
289
packages/app/e2e/helpers/desktop-updates.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { openSettings } from "./app";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
import { openSettingsHost } from "./settings";
|
||||
|
||||
interface DaemonApiStatus {
|
||||
version: string;
|
||||
serverId: string;
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
interface PidFileContent {
|
||||
pid: number;
|
||||
desktopManaged: boolean;
|
||||
}
|
||||
|
||||
export interface RealDaemonState {
|
||||
version: string;
|
||||
pid: number | null;
|
||||
logPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads live state from the running E2E test daemon: version from the HTTP
|
||||
* status endpoint, PID from the paseo.pid lock file, log path from the
|
||||
* E2E_PASEO_HOME directory. Call this in Node test code (not in the browser).
|
||||
*/
|
||||
export async function loadRealDaemonState(): Promise<RealDaemonState> {
|
||||
const port = getE2EDaemonPort();
|
||||
const paseoHome = process.env.E2E_PASEO_HOME;
|
||||
if (!paseoHome) throw new Error("E2E_PASEO_HOME not set — globalSetup must run first");
|
||||
|
||||
const resp = await fetch(`http://127.0.0.1:${port}/api/status`);
|
||||
const data: DaemonApiStatus = await resp.json();
|
||||
|
||||
let pid: number | null = null;
|
||||
try {
|
||||
const raw = readFileSync(`${paseoHome}/paseo.pid`, "utf8");
|
||||
const pidContent: PidFileContent = JSON.parse(raw);
|
||||
pid = pidContent.pid ?? null;
|
||||
} catch (err) {
|
||||
// PID file may not be present yet on a very fresh daemon start
|
||||
console.warn("[desktop-updates] paseo.pid not found:", err);
|
||||
}
|
||||
|
||||
return { version: data.version, pid, logPath: `${paseoHome}/daemon.log` };
|
||||
}
|
||||
|
||||
export interface DesktopBridgeConfig {
|
||||
serverId: string;
|
||||
updateAvailable?: boolean;
|
||||
latestVersion?: string;
|
||||
slowInstall?: boolean;
|
||||
/** Initial PID reported by desktop_daemon_status. Defaults to null. */
|
||||
daemonPid?: number | null;
|
||||
daemonVersion?: string | null;
|
||||
daemonLogPath?: string;
|
||||
/** Initial manageBuiltInDaemon setting. Defaults to false. */
|
||||
manageBuiltInDaemon?: boolean;
|
||||
/**
|
||||
* Controls what dialog.ask returns when the daemon management confirm dialog
|
||||
* fires. True = confirm (proceed with the action), false = cancel. Defaults to
|
||||
* false so tests that only assert copy don't inadvertently trigger state changes.
|
||||
*/
|
||||
confirmShouldAccept?: boolean;
|
||||
}
|
||||
|
||||
export interface ConfirmDialogCall {
|
||||
message: string;
|
||||
title: string | undefined;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__capturedDialogCall: ConfirmDialogCall | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects window.paseoDesktop before app load so all Electron-gated code
|
||||
* activates. The update-check IPC is mocked at the boundary so the real
|
||||
* auto-updater never fires. Daemon start/stop commands are stateful: the mock
|
||||
* tracks running state and assigns a fresh PID on each start, letting tests
|
||||
* observe PID changes without touching the real E2E daemon process.
|
||||
* dialog.ask captures call arguments on window.__capturedDialogCall so tests
|
||||
* can assert dialog copy without depending on window.confirm concatenation.
|
||||
*/
|
||||
export async function injectDesktopBridge(page: Page, config: DesktopBridgeConfig): Promise<void> {
|
||||
await page.addInitScript((cfg) => {
|
||||
// Mutable state shared across IPC calls within this page
|
||||
let manageDaemon = cfg.manageBuiltInDaemon ?? false;
|
||||
let daemonRunning = true;
|
||||
let currentPid: number | null = cfg.daemonPid ?? null;
|
||||
let startCount = 0;
|
||||
|
||||
function buildDaemonStatus() {
|
||||
return {
|
||||
serverId: cfg.serverId,
|
||||
status: daemonRunning ? "running" : "stopped",
|
||||
listen: null,
|
||||
hostname: null,
|
||||
pid: currentPid,
|
||||
home: "",
|
||||
version: cfg.daemonVersion ?? null,
|
||||
desktopManaged: manageDaemon,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
(window as unknown as { paseoDesktop: unknown }).paseoDesktop = {
|
||||
platform: "darwin",
|
||||
invoke: async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === "check_app_update") {
|
||||
return cfg.updateAvailable
|
||||
? {
|
||||
hasUpdate: true,
|
||||
readyToInstall: true,
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: cfg.latestVersion ?? "1.2.3",
|
||||
body: null,
|
||||
date: null,
|
||||
}
|
||||
: {
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
currentVersion: "1.0.0",
|
||||
latestVersion: null,
|
||||
body: null,
|
||||
date: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (command === "install_app_update") {
|
||||
if (cfg.slowInstall) {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
return {
|
||||
installed: true,
|
||||
version: cfg.latestVersion ?? "1.2.3",
|
||||
message: "App update installed. Restart required.",
|
||||
};
|
||||
}
|
||||
|
||||
if (command === "desktop_daemon_status") {
|
||||
return buildDaemonStatus();
|
||||
}
|
||||
|
||||
if (command === "desktop_daemon_logs") {
|
||||
return { logPath: cfg.daemonLogPath ?? "", contents: "" };
|
||||
}
|
||||
|
||||
if (command === "get_desktop_settings") {
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
||||
};
|
||||
}
|
||||
|
||||
if (command === "patch_desktop_settings") {
|
||||
const daemon = args?.daemon;
|
||||
if (
|
||||
daemon !== null &&
|
||||
typeof daemon === "object" &&
|
||||
"manageBuiltInDaemon" in daemon &&
|
||||
typeof daemon.manageBuiltInDaemon === "boolean"
|
||||
) {
|
||||
manageDaemon = daemon.manageBuiltInDaemon;
|
||||
}
|
||||
return {
|
||||
releaseChannel: "stable",
|
||||
daemon: { manageBuiltInDaemon: manageDaemon, keepRunningAfterQuit: true },
|
||||
};
|
||||
}
|
||||
|
||||
if (command === "stop_desktop_daemon") {
|
||||
daemonRunning = false;
|
||||
currentPid = null;
|
||||
return buildDaemonStatus();
|
||||
}
|
||||
|
||||
if (command === "start_desktop_daemon") {
|
||||
startCount += 1;
|
||||
daemonRunning = true;
|
||||
// First start (bootstrap) returns the configured PID; subsequent starts
|
||||
// (after a stop) get a fresh PID so tests can observe the change.
|
||||
currentPid = (cfg.daemonPid ?? 10000) + (startCount - 1) * 1000;
|
||||
return buildDaemonStatus();
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
dialog: {
|
||||
ask: async (message: string, options?: Record<string, unknown>) => {
|
||||
window.__capturedDialogCall = {
|
||||
message,
|
||||
title: typeof options?.title === "string" ? options.title : undefined,
|
||||
};
|
||||
return cfg.confirmShouldAccept ?? false;
|
||||
},
|
||||
},
|
||||
getPendingOpenProject: async () => null,
|
||||
events: { on: async () => () => undefined },
|
||||
};
|
||||
}, config);
|
||||
}
|
||||
|
||||
export async function openDesktopSettings(page: Page, serverId: string): Promise<void> {
|
||||
await openSettings(page);
|
||||
await openSettingsHost(page, serverId);
|
||||
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectUpdateBanner(page: Page, version: string): Promise<void> {
|
||||
const callout = page.getByTestId("update-callout");
|
||||
await expect(callout).toBeVisible({ timeout: 15_000 });
|
||||
await expect(callout).toContainText(`v${version.replace(/^v/i, "")}`);
|
||||
}
|
||||
|
||||
export async function clickInstallUpdate(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Install & restart" }).click();
|
||||
}
|
||||
|
||||
export async function expectInstallInProgress(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Installing..." })).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the daemon management switch and waits for dialog.ask to fire in the
|
||||
* mock, then returns the captured call args (message + title). The mock auto-
|
||||
* dismisses via confirmShouldAccept=false so callers can assert copy without
|
||||
* worrying about state changes.
|
||||
*/
|
||||
export async function interceptDaemonManagementConfirmDialog(
|
||||
page: Page,
|
||||
): Promise<ConfirmDialogCall> {
|
||||
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
|
||||
await page.waitForFunction(() => !!window.__capturedDialogCall, { timeout: 5_000 });
|
||||
return page.evaluate(() => window.__capturedDialogCall!);
|
||||
}
|
||||
|
||||
export async function toggleDaemonManagement(
|
||||
page: Page,
|
||||
_action: "enable" | "disable",
|
||||
): Promise<void> {
|
||||
await page.getByRole("switch", { name: "Manage built-in daemon" }).click();
|
||||
}
|
||||
|
||||
export function expectDaemonManagementConfirmDialog(args: ConfirmDialogCall): void {
|
||||
expect(args.title).toBe("Pause built-in daemon");
|
||||
expect(args.message).toContain("stop the built-in daemon immediately");
|
||||
}
|
||||
|
||||
export async function expectDaemonManagementEnabled(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).toBeChecked();
|
||||
}
|
||||
|
||||
export async function expectDaemonManagementDisabled(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("switch", { name: "Manage built-in daemon" })).not.toBeChecked();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the daemon status card shows the given PID. Pass null to assert
|
||||
* the cleared state (shown as "PID —" when the daemon is stopped).
|
||||
*/
|
||||
export async function expectDaemonStatusPid(page: Page, pid: number | null): Promise<void> {
|
||||
const expected = pid !== null ? `PID ${pid}` : "PID —";
|
||||
await expect(
|
||||
page.getByTestId("host-page-daemon-lifecycle-card").getByText(expected),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectDaemonStatusLogPath(page: Page, logPath: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("host-page-daemon-lifecycle-card").getByText(logPath),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the host page identity badge shows the given version string.
|
||||
* The badge is populated from the live WebSocket session's serverInfo.version.
|
||||
*/
|
||||
export async function expectDaemonStatusVersion(page: Page, version: string): Promise<void> {
|
||||
await expect(
|
||||
page.getByTestId("host-page-identity").getByText(version, { exact: false }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
249
packages/app/e2e/helpers/github-fixtures.ts
Normal file
249
packages/app/e2e/helpers/github-fixtures.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export function hasGithubAuth(): boolean {
|
||||
try {
|
||||
execSync("gh auth status", { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CheckSpec {
|
||||
context: string;
|
||||
state: "success" | "failure" | "pending";
|
||||
}
|
||||
|
||||
export interface PrSpec {
|
||||
title: string;
|
||||
state: "open" | "merged" | "closed" | "draft";
|
||||
checks?: CheckSpec[];
|
||||
commentCount?: number;
|
||||
}
|
||||
|
||||
export interface IssueSpec {
|
||||
title: string;
|
||||
body?: string;
|
||||
labels?: string[];
|
||||
state?: "open" | "closed";
|
||||
}
|
||||
|
||||
export interface GhPrFixture {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
branch: string;
|
||||
localPath: string;
|
||||
}
|
||||
|
||||
export interface GhIssueFixture {
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface GhRepoFixture {
|
||||
owner: string;
|
||||
name: string;
|
||||
fullName: string;
|
||||
prs: GhPrFixture[];
|
||||
issues: GhIssueFixture[];
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
function gh(args: string[], opts?: { cwd?: string }): string {
|
||||
return execFileSync("gh", args, {
|
||||
cwd: opts?.cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
function git(args: string[], cwd: string): string {
|
||||
return execFileSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
}).trim();
|
||||
}
|
||||
|
||||
async function seedPr(args: {
|
||||
spec: PrSpec;
|
||||
branch: string;
|
||||
index: number;
|
||||
basePath: string;
|
||||
authedUrl: string;
|
||||
fullName: string;
|
||||
repoName: string;
|
||||
}): Promise<{ fixture: GhPrFixture; localPath: string }> {
|
||||
const { spec, branch, index, basePath, authedUrl, fullName, repoName } = args;
|
||||
|
||||
const createArgs = [
|
||||
"pr",
|
||||
"create",
|
||||
"--title",
|
||||
spec.title,
|
||||
"--base",
|
||||
"main",
|
||||
"--head",
|
||||
branch,
|
||||
"--body",
|
||||
"",
|
||||
];
|
||||
if (spec.state === "draft") createArgs.push("--draft");
|
||||
|
||||
const prUrl = gh(createArgs, { cwd: basePath });
|
||||
const prNumber = parseInt(prUrl.split("/").pop() ?? "0", 10);
|
||||
|
||||
if (spec.checks && spec.checks.length > 0) {
|
||||
const sha = git(["rev-parse", branch], basePath);
|
||||
for (const check of spec.checks) {
|
||||
gh([
|
||||
"api",
|
||||
`repos/${fullName}/statuses/${sha}`,
|
||||
"--method",
|
||||
"POST",
|
||||
"-f",
|
||||
`state=${check.state}`,
|
||||
"-f",
|
||||
`context=${check.context}`,
|
||||
"-f",
|
||||
`target_url=https://example.com/${encodeURIComponent(check.context)}`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
for (let j = 0; j < (spec.commentCount ?? 0); j++) {
|
||||
gh(["pr", "comment", String(prNumber), "--body", `Test comment ${j + 1}`], { cwd: basePath });
|
||||
}
|
||||
|
||||
if (spec.state === "merged") {
|
||||
gh(["pr", "merge", String(prNumber), "--merge"], { cwd: basePath });
|
||||
} else if (spec.state === "closed") {
|
||||
gh(["pr", "close", String(prNumber)], { cwd: basePath });
|
||||
}
|
||||
|
||||
const localPath = await mkdtemp(path.join("/tmp", `${repoName}-ws-${index}-`));
|
||||
git(["clone", authedUrl, localPath, "--quiet", "-b", branch], basePath);
|
||||
// Clean remote URL (no embedded token) so gh can parse owner/repo
|
||||
git(["remote", "set-url", "origin", `https://github.com/${fullName}.git`], localPath);
|
||||
git(["config", "user.email", "e2e@paseo.test"], localPath);
|
||||
git(["config", "user.name", "Paseo E2E"], localPath);
|
||||
git(["config", "commit.gpgsign", "false"], localPath);
|
||||
|
||||
return {
|
||||
fixture: { number: prNumber, title: spec.title, url: prUrl, branch, localPath },
|
||||
localPath,
|
||||
};
|
||||
}
|
||||
|
||||
function seedIssue(args: { spec: IssueSpec; basePath: string }): GhIssueFixture {
|
||||
const { spec, basePath } = args;
|
||||
const createArgs = ["issue", "create", "--title", spec.title, "--body", spec.body ?? ""];
|
||||
for (const label of spec.labels ?? []) {
|
||||
createArgs.push("--label", label);
|
||||
}
|
||||
const issueUrl = gh(createArgs, { cwd: basePath });
|
||||
const issueNumber = parseInt(issueUrl.split("/").pop() ?? "0", 10);
|
||||
if (spec.state === "closed") {
|
||||
gh(["issue", "close", String(issueNumber)], { cwd: basePath });
|
||||
}
|
||||
return { number: issueNumber, title: spec.title, url: issueUrl };
|
||||
}
|
||||
|
||||
// Single namespace for temporary GitHub repos created by Paseo tests.
|
||||
// Bulk cleanup relies on this prefix being unmistakable — never reuse `paseo-`
|
||||
// (collides with real repos like `paseo`, `paseo-website`).
|
||||
const TEMP_GITHUB_REPO_PREFIX = "paseotmp-";
|
||||
|
||||
export async function createTempGithubRepo(options: {
|
||||
category: string;
|
||||
prs?: PrSpec[];
|
||||
issues?: IssueSpec[];
|
||||
}): Promise<GhRepoFixture> {
|
||||
const { category, prs = [], issues = [] } = options;
|
||||
const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const repoName = `${TEMP_GITHUB_REPO_PREFIX}${category}-${uniqueSuffix}`;
|
||||
|
||||
// Bootstrap local git repo
|
||||
const basePath = await mkdtemp(path.join("/tmp", `${repoName}-base-`));
|
||||
git(["init", "-b", "main"], basePath);
|
||||
git(["config", "user.email", "e2e@paseo.test"], basePath);
|
||||
git(["config", "user.name", "Paseo E2E"], basePath);
|
||||
git(["config", "commit.gpgsign", "false"], basePath);
|
||||
await writeFile(path.join(basePath, "README.md"), "# E2E Test Repo\n");
|
||||
git(["add", "README.md"], basePath);
|
||||
git(["commit", "-m", "Initial commit"], basePath);
|
||||
|
||||
// Create GitHub repo and push initial commit
|
||||
gh(["repo", "create", repoName, "--private", `--source=${basePath}`, "--push"]);
|
||||
|
||||
const owner = gh(["api", "user", "--jq", ".login"]);
|
||||
const fullName = `${owner}/${repoName}`;
|
||||
const token = gh(["auth", "token"]);
|
||||
const authedUrl = `https://x-access-token:${token}@github.com/${fullName}.git`;
|
||||
|
||||
// Switch remote to authed URL for subsequent pushes
|
||||
git(["remote", "set-url", "origin", authedUrl], basePath);
|
||||
|
||||
// Create a branch + commit for each PR spec
|
||||
const branches: string[] = [];
|
||||
for (let i = 0; i < prs.length; i++) {
|
||||
const branch = `pr-branch-${i + 1}`;
|
||||
branches.push(branch);
|
||||
git(["checkout", "-b", branch], basePath);
|
||||
await writeFile(path.join(basePath, `pr-${i + 1}.txt`), `PR ${i + 1}\n`);
|
||||
git(["add", `pr-${i + 1}.txt`], basePath);
|
||||
git(["commit", "-m", `Add PR ${i + 1}`], basePath);
|
||||
git(["checkout", "main"], basePath);
|
||||
}
|
||||
|
||||
if (branches.length > 0) {
|
||||
git(["push", "origin", ...branches], basePath);
|
||||
}
|
||||
|
||||
// Create PRs, seed checks/comments, apply state changes, clone workspaces
|
||||
const prFixtures: GhPrFixture[] = [];
|
||||
const localPaths: string[] = [];
|
||||
|
||||
for (let i = 0; i < prs.length; i++) {
|
||||
const { fixture, localPath } = await seedPr({
|
||||
spec: prs[i],
|
||||
branch: branches[i],
|
||||
index: i,
|
||||
basePath,
|
||||
authedUrl,
|
||||
fullName,
|
||||
repoName,
|
||||
});
|
||||
localPaths.push(localPath);
|
||||
prFixtures.push(fixture);
|
||||
}
|
||||
|
||||
// Create issues
|
||||
const issueFixtures: GhIssueFixture[] = [];
|
||||
for (const spec of issues) {
|
||||
issueFixtures.push(seedIssue({ spec, basePath }));
|
||||
}
|
||||
|
||||
return {
|
||||
owner,
|
||||
name: repoName,
|
||||
fullName,
|
||||
prs: prFixtures,
|
||||
issues: issueFixtures,
|
||||
cleanup: async () => {
|
||||
try {
|
||||
gh(["repo", "delete", fullName, "--yes"]);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
await Promise.all([
|
||||
rm(basePath, { recursive: true, force: true }),
|
||||
...localPaths.map((p) => rm(p, { recursive: true, force: true })),
|
||||
]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,17 +1,10 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
// ─── 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);
|
||||
@@ -68,20 +61,6 @@ export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
||||
|
||||
// ─── Tab actions ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Click the new agent tab button in the tab bar. Creates a draft/chat tab directly. */
|
||||
export async function clickNewTabButton(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-agent-tab").filter({ visible: true }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/** Click the new terminal button in the workspace tab bar. Creates a terminal tab directly. */
|
||||
export async function clickNewTerminalButton(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/** Press Cmd+T (macOS) or Ctrl+T (Linux/Windows) to open a new tab. */
|
||||
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
@@ -90,11 +69,6 @@ export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||
|
||||
// ─── Tab bar assertions ───────────────────────────────────────────────────
|
||||
|
||||
/** @deprecated The launcher panel was removed. Actions go directly to their target. */
|
||||
export async function waitForLauncherPanel(_page: Page): Promise<void> {
|
||||
// No-op: the launcher panel no longer exists.
|
||||
}
|
||||
|
||||
/** Assert the new agent tab button is visible in the tab bar. */
|
||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||
await expect(
|
||||
@@ -119,7 +93,7 @@ export async function clickNewChat(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
/** Click the new terminal button to create a terminal tab. */
|
||||
export async function clickTerminal(page: Page): Promise<void> {
|
||||
export async function clickNewTerminal(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-terminal").filter({ visible: true }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
@@ -193,6 +167,19 @@ export async function sampleTabsDuringTransition(
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
export function terminalSurfaceLocator(page: Page) {
|
||||
return page.locator('[data-testid="terminal-surface"]').first();
|
||||
}
|
||||
|
||||
export async function expectAgentTabActive(page: Page, agentId: string): Promise<void> {
|
||||
const tabTestId = `workspace-tab-agent_${agentId}`;
|
||||
await expect(page.getByTestId(tabTestId).filter({ visible: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(getActiveTabTestId(page)).resolves.toBe(tabTestId);
|
||||
}
|
||||
|
||||
// ─── Workspace setup ───────────────────────────────────────────────────────
|
||||
|
||||
/** Create a temp git repo and return its path with a cleanup function. */
|
||||
|
||||
69
packages/app/e2e/helpers/mock-agent.ts
Normal file
69
packages/app/e2e/helpers/mock-agent.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "./seed-client";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export interface MockAgentWorkspace {
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
client: SeedDaemonClient;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MockAgentOptions {
|
||||
repoPrefix: string;
|
||||
title: string;
|
||||
initialPrompt?: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds a temp git repo, opens it as a project, and creates a ready mock-provider
|
||||
* agent in it via the daemon. Returns the agent id plus a cleanup that closes the
|
||||
* client and removes the repo. Pair with {@link openAgentRoute} to drive the UI.
|
||||
*/
|
||||
export async function seedMockAgentWorkspace(
|
||||
options: MockAgentOptions,
|
||||
): Promise<MockAgentWorkspace> {
|
||||
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix });
|
||||
try {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
title: options.title,
|
||||
modeId: options.modeId ?? "load-test",
|
||||
model: options.model ?? "ten-second-stream",
|
||||
initialPrompt: options.initialPrompt,
|
||||
featureValues: options.featureValues,
|
||||
});
|
||||
return {
|
||||
agentId: agent.id,
|
||||
cwd: workspace.repoPath,
|
||||
client: workspace.client,
|
||||
cleanup: workspace.cleanup,
|
||||
};
|
||||
} catch (error) {
|
||||
await workspace.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAgentRoute(cwd: string, agentId: string): string {
|
||||
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
|
||||
`agent:${agentId}`,
|
||||
)}`;
|
||||
}
|
||||
|
||||
/** Boots the app directly at the agent's workspace route and waits for the open intent to settle. */
|
||||
export async function openAgentRoute(
|
||||
page: Page,
|
||||
input: { cwd: string; agentId: string },
|
||||
): Promise<void> {
|
||||
await page.goto(buildAgentRoute(input.cwd, input.agentId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
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 type { DaemonClient as InternalDaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
import { expectWorkspaceHeader, workspaceLabelFromPath } from "./workspace-ui";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./node-ws-factory";
|
||||
|
||||
type NewWorkspaceDaemonClient = Pick<
|
||||
ServerDaemonClient,
|
||||
InternalDaemonClient,
|
||||
| "archivePaseoWorktree"
|
||||
| "archiveWorkspace"
|
||||
| "close"
|
||||
@@ -17,13 +15,6 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "openProject"
|
||||
>;
|
||||
|
||||
interface NewWorkspaceDaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
webSocketFactory?: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
|
||||
|
||||
export interface OpenedProject {
|
||||
@@ -33,34 +24,6 @@ export interface OpenedProject {
|
||||
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);
|
||||
@@ -83,16 +46,9 @@ function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | nul
|
||||
}
|
||||
|
||||
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,
|
||||
return connectDaemonClient<NewWorkspaceDaemonClient>({
|
||||
clientIdPrefix: "app-e2e-new-workspace",
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function openProjectViaDaemon(
|
||||
@@ -170,9 +126,12 @@ export async function openNewWorkspaceComposer(
|
||||
|
||||
export async function clickNewWorkspaceButton(
|
||||
page: Page,
|
||||
input: { projectKey: string; projectDisplayName: string },
|
||||
input: { projectKey: string; projectDisplayName: string; prompt?: string },
|
||||
): Promise<void> {
|
||||
await openNewWorkspaceComposer(page, input);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer).toBeVisible({ timeout: 30_000 });
|
||||
await composer.fill(input.prompt ?? "Hello from e2e");
|
||||
const createButton = page
|
||||
.getByTestId("message-input-root")
|
||||
.getByRole("button", { name: "Create" });
|
||||
@@ -202,12 +161,45 @@ export async function expectStartingRefPickerTriggerPr(
|
||||
page: Page,
|
||||
input: { number: number; title: string; headRef: string },
|
||||
): Promise<void> {
|
||||
const trigger = page.getByTestId("new-workspace-ref-picker-trigger");
|
||||
const trigger = page.getByRole("button", { name: "Starting ref" });
|
||||
await expect(trigger).toContainText(`#${input.number}`);
|
||||
await expect(trigger).toContainText(input.title);
|
||||
await expect(trigger).not.toContainText(input.headRef);
|
||||
}
|
||||
|
||||
export async function openBranchPicker(page: Page): Promise<void> {
|
||||
const trigger = page.getByRole("button", { name: "Starting ref" });
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
await trigger.click();
|
||||
}
|
||||
|
||||
export async function selectPickerOptionByKeyboard(page: Page, label: string): Promise<void> {
|
||||
const searchInput = page.getByPlaceholder("Search branches and PRs");
|
||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||
await page.keyboard.type(label);
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
|
||||
export async function closeBranchPicker(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
export async function expectPickerOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectPickerClosed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("combobox-desktop-container")).not.toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectPickerSelected(page: Page, label: string): Promise<void> {
|
||||
const trigger = page.getByRole("button", { name: "Starting ref" });
|
||||
await expect(trigger).toContainText(label);
|
||||
}
|
||||
|
||||
export async function expectComposerGithubAttachmentPill(
|
||||
page: Page,
|
||||
input: { number: number; title: string },
|
||||
@@ -250,3 +242,105 @@ export async function assertNewWorkspaceSidebarAndHeader(
|
||||
|
||||
return { workspaceId };
|
||||
}
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
function parseWebSocketJson(message: WebSocketMessage): unknown {
|
||||
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
|
||||
try {
|
||||
return JSON.parse(rawMessage);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionMessage(message: WebSocketMessage): Record<string, unknown> | null {
|
||||
const envelope = parseWebSocketJson(message);
|
||||
if (!envelope || typeof envelope !== "object") {
|
||||
return null;
|
||||
}
|
||||
const maybeEnvelope = envelope as { type?: unknown; message?: unknown };
|
||||
if (maybeEnvelope.type !== "session" || !maybeEnvelope.message) {
|
||||
return null;
|
||||
}
|
||||
if (typeof maybeEnvelope.message !== "object") {
|
||||
return null;
|
||||
}
|
||||
return maybeEnvelope.message as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getStringField(input: Record<string, unknown>, key: string): string | null {
|
||||
const value = input[key];
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
export interface AgentCreatedDelayControl {
|
||||
release(): void;
|
||||
waitForCreateRequest(): Promise<void>;
|
||||
waitForDelayedCreatedStatus(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function delayBrowserAgentCreatedStatus(
|
||||
page: Page,
|
||||
): Promise<AgentCreatedDelayControl> {
|
||||
const daemonPortPattern = daemonWsRoutePattern();
|
||||
const createRequestIds = new Set<string>();
|
||||
const delayedForwards: Array<() => void> = [];
|
||||
let releaseRequested = false;
|
||||
let resolveCreateRequest: (() => void) | null = null;
|
||||
let resolveDelayedCreatedStatus: (() => void) | null = null;
|
||||
const createRequestSeen = new Promise<void>((resolve) => {
|
||||
resolveCreateRequest = resolve;
|
||||
});
|
||||
const delayedCreatedStatusSeen = new Promise<void>((resolve) => {
|
||||
resolveDelayedCreatedStatus = resolve;
|
||||
});
|
||||
|
||||
await page.routeWebSocket(daemonPortPattern, (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
if (sessionMessage?.type === "create_agent_request") {
|
||||
const requestId = getStringField(sessionMessage, "requestId");
|
||||
if (requestId) {
|
||||
createRequestIds.add(requestId);
|
||||
resolveCreateRequest?.();
|
||||
}
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
const sessionMessage = getSessionMessage(message);
|
||||
const payload =
|
||||
sessionMessage?.type === "status" && typeof sessionMessage.payload === "object"
|
||||
? (sessionMessage.payload as Record<string, unknown>)
|
||||
: null;
|
||||
const requestId = payload ? getStringField(payload, "requestId") : null;
|
||||
|
||||
if (payload?.status === "agent_created" && requestId && createRequestIds.has(requestId)) {
|
||||
resolveDelayedCreatedStatus?.();
|
||||
if (releaseRequested) {
|
||||
ws.send(message);
|
||||
return;
|
||||
}
|
||||
delayedForwards.push(() => ws.send(message));
|
||||
return;
|
||||
}
|
||||
|
||||
ws.send(message);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
release() {
|
||||
releaseRequested = true;
|
||||
for (const forward of delayedForwards.splice(0)) {
|
||||
forward();
|
||||
}
|
||||
},
|
||||
waitForCreateRequest: () => createRequestSeen,
|
||||
waitForDelayedCreatedStatus: () => delayedCreatedStatusSeen,
|
||||
};
|
||||
}
|
||||
|
||||
17
packages/app/e2e/helpers/permissions.ts
Normal file
17
packages/app/e2e/helpers/permissions.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export async function waitForPermissionPrompt(page: Page, timeout = 30_000): Promise<void> {
|
||||
await expect(page.getByTestId("permission-request-question").first()).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
export async function allowPermission(page: Page): Promise<void> {
|
||||
const acceptButton = page.getByTestId("permission-request-accept").first();
|
||||
await expect(acceptButton).toBeVisible({ timeout: 5_000 });
|
||||
await acceptButton.click();
|
||||
}
|
||||
|
||||
export async function denyPermission(page: Page): Promise<void> {
|
||||
const denyButton = page.getByTestId("permission-request-deny").first();
|
||||
await expect(denyButton).toBeVisible({ timeout: 5_000 });
|
||||
await denyButton.click();
|
||||
}
|
||||
42
packages/app/e2e/helpers/pr-pane.ts
Normal file
42
packages/app/e2e/helpers/pr-pane.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { getStateLabel } from "@/git/pr-pane-data";
|
||||
|
||||
export async function openPrPane(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open explorer" }).click();
|
||||
await page.getByTestId("explorer-tab-pr").click();
|
||||
await expect(page.getByTestId("pr-pane")).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function expectPrPaneTitle(page: Page, title: string): Promise<void> {
|
||||
await expect(page.getByTestId("pr-pane-title")).toContainText(title, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function expectPrPaneState(
|
||||
page: Page,
|
||||
state: "open" | "merged" | "closed" | "draft",
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("pr-pane-state")).toHaveText(getStateLabel(state), {
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function assertCheckPill(page: Page, testId: string, count: number): Promise<void> {
|
||||
const locator = page.getByTestId(testId);
|
||||
await expect(locator).toHaveCount(count > 0 ? 1 : 0, { timeout: 15_000 });
|
||||
if (count > 0) {
|
||||
await expect(locator).toContainText(String(count));
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectPrPaneCheckSummary(
|
||||
page: Page,
|
||||
counts: { passed: number; failed: number; pending: number },
|
||||
): Promise<void> {
|
||||
await assertCheckPill(page, "pr-pane-check-passed", counts.passed);
|
||||
await assertCheckPill(page, "pr-pane-check-failed", counts.failed);
|
||||
await assertCheckPill(page, "pr-pane-check-pending", counts.pending);
|
||||
}
|
||||
|
||||
export async function expectPrPaneActivityCount(page: Page, count: number): Promise<void> {
|
||||
await expect(page.getByTestId("pr-pane-activity-row")).toHaveCount(count, { timeout: 15_000 });
|
||||
}
|
||||
264
packages/app/e2e/helpers/project-settings.ts
Normal file
264
packages/app/e2e/helpers/project-settings.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { chmod, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import type { WebSocketRoute } from "@playwright/test";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { daemonWsRoutePattern } from "./daemon-port";
|
||||
|
||||
// --- Navigation ---
|
||||
|
||||
export async function openProjects(page: Page): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await page.getByTestId("settings-projects").click();
|
||||
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||
}
|
||||
|
||||
export async function openProjectSettings(page: Page, projectName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function navigateToProjectSettings(page: Page, projectName: string): Promise<void> {
|
||||
await page.getByRole("button", { name: `Edit ${projectName}`, exact: true }).click();
|
||||
}
|
||||
|
||||
// --- Form interactions ---
|
||||
|
||||
export async function editWorktreeSetup(page: Page, setupCommands: string[]): Promise<void> {
|
||||
await page
|
||||
.getByRole("textbox", { name: "Worktree setup commands" })
|
||||
.fill(setupCommands.join("\n"));
|
||||
}
|
||||
|
||||
export async function clickSaveProjectSettings(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Save project config" }).click();
|
||||
}
|
||||
|
||||
export async function clickRetryProjectSettingsSave(page: Page): Promise<void> {
|
||||
// action-0 is always "Try again"; action-1 is always "Reload".
|
||||
// The write-failed callout renders these two buttons in a fixed order.
|
||||
await page.getByTestId("write-failed-callout-action-0").click();
|
||||
}
|
||||
|
||||
export async function clickReloadProjectSettings(page: Page): Promise<void> {
|
||||
// Scope to the active error callout so the locator is unambiguous.
|
||||
// At most one error callout renders at a time.
|
||||
await page.locator('[data-testid$="-callout"]').getByRole("button", { name: "Reload" }).click();
|
||||
}
|
||||
|
||||
// --- Error-state assertions ---
|
||||
|
||||
type ErrorKind = "stale" | "invalid" | "write_failed" | "transport" | "read_failed";
|
||||
|
||||
const errorCalloutTestId: Record<ErrorKind, string> = {
|
||||
stale: "stale-callout",
|
||||
invalid: "invalid-callout",
|
||||
write_failed: "write-failed-callout",
|
||||
transport: "read-transport-callout",
|
||||
read_failed: "read-failed-callout",
|
||||
};
|
||||
|
||||
export async function expectProjectSettingsError(page: Page, kind: ErrorKind): Promise<void> {
|
||||
await expect(page.getByTestId(errorCalloutTestId[kind])).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function expectNoProjectSettingsError(
|
||||
page: Page,
|
||||
kind: ErrorKind,
|
||||
timeout = 15_000,
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId(errorCalloutTestId[kind])).not.toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
export async function expectWriteFailedCalloutActions(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("write-failed-callout-action-0")).toHaveText("Try again");
|
||||
await expect(page.getByTestId("write-failed-callout-action-1")).toHaveText("Reload");
|
||||
}
|
||||
|
||||
export async function expectSaveButtonDisabled(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Save project config" })).toBeDisabled();
|
||||
}
|
||||
|
||||
// --- Form-state assertions ---
|
||||
|
||||
export async function expectProjectSettingsFormVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectProjectSettingsFormHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("textbox", { name: "Worktree setup commands" })).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectNoEditableTarget(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("project-settings-back-button")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
// --- Host-section assertions ---
|
||||
|
||||
export async function expectHostIndicatorVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-indicator")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostPickerHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-picker")).not.toBeVisible();
|
||||
}
|
||||
|
||||
// --- Script-list assertions and interactions ---
|
||||
|
||||
// Counts only row Views, not kebab-trigger elements (which share the "script-row-"
|
||||
// prefix but contain "-menu-").
|
||||
export async function expectScriptRowCount(page: Page, count: number): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("scripts-list")
|
||||
.locator('[data-testid^="script-row-"]:not([data-testid*="-menu-"])'),
|
||||
).toHaveCount(count);
|
||||
}
|
||||
|
||||
export async function expectEmptyScriptList(page: Page): Promise<void> {
|
||||
await expect(page.getByText("No scripts yet.")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function removeProjectScript(page: Page, scriptName: string): Promise<void> {
|
||||
const row = page
|
||||
.getByTestId("scripts-list")
|
||||
.locator('[data-testid^="script-row-"]:not([data-testid*="-menu-"])')
|
||||
.filter({ hasText: scriptName })
|
||||
.first();
|
||||
// DropdownMenuTrigger renders as a Pressable (no role="button"); derive its testID
|
||||
// from the row's testID to avoid scoped locator unreliability.
|
||||
const id = (await row.getAttribute("data-testid"))!.replace("script-row-", "");
|
||||
await page.getByTestId(`script-row-menu-${id}`).click();
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await page.getByRole("button", { name: "Remove" }).click();
|
||||
}
|
||||
|
||||
// --- File manipulation ---
|
||||
|
||||
export async function corruptPaseoConfig(repoPath: string): Promise<void> {
|
||||
await writeFile(path.join(repoPath, "paseo.json"), "{not valid json}");
|
||||
}
|
||||
|
||||
export async function bumpPaseoConfigOnDisk(repoPath: string): Promise<void> {
|
||||
const configPath = path.join(repoPath, "paseo.json");
|
||||
const raw = await readFile(configPath, "utf8");
|
||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
||||
config._bump = Date.now();
|
||||
await writeFile(configPath, JSON.stringify(config, null, 2) + "\n");
|
||||
}
|
||||
|
||||
export async function restorePaseoConfig(
|
||||
repoPath: string,
|
||||
config: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await writeFile(path.join(repoPath, "paseo.json"), JSON.stringify(config, null, 2) + "\n");
|
||||
}
|
||||
|
||||
// The daemon writes atomically via a temp file + rename, so blocking writes requires
|
||||
// removing write permission from the *directory*, not just the file.
|
||||
export async function blockPaseoConfigWrites(repoPath: string): Promise<void> {
|
||||
await chmod(repoPath, 0o555);
|
||||
}
|
||||
|
||||
export async function unblockPaseoConfigWrites(repoPath: string): Promise<void> {
|
||||
await chmod(repoPath, 0o755);
|
||||
}
|
||||
|
||||
// --- WebSocket helpers ---
|
||||
|
||||
// Proxies all daemon WS traffic transparently until a read_project_config_request
|
||||
// is seen, then closes that connection (triggering readQuery.isError). Subsequent
|
||||
// connections pass through so the Reload action can succeed.
|
||||
export async function installReadTransportFailure(page: Page): Promise<void> {
|
||||
let armed = true;
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (armed && typeof message === "string") {
|
||||
try {
|
||||
const envelope = JSON.parse(message) as {
|
||||
type?: string;
|
||||
message?: { type?: string };
|
||||
};
|
||||
if (
|
||||
envelope.type === "session" &&
|
||||
envelope.message?.type === "read_project_config_request"
|
||||
) {
|
||||
armed = false;
|
||||
void ws.close({ code: 1001 });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// binary or malformed frame — pass through
|
||||
}
|
||||
}
|
||||
try {
|
||||
server.send(message);
|
||||
} catch {
|
||||
// server socket already closed
|
||||
}
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
try {
|
||||
ws.send(message);
|
||||
} catch {
|
||||
// client socket already closed
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Installs a transparent WS proxy that can later drop all active daemon connections
|
||||
// and block new ones. Code 1001 (Going Away) without reason triggers "error" state
|
||||
// in DaemonClient due to describeTransportClose returning a non-empty string.
|
||||
export async function installDaemonConnectionGate(
|
||||
page: Page,
|
||||
): Promise<{ drop: () => Promise<void> }> {
|
||||
let acceptingConnections = true;
|
||||
const activeSockets = new Set<WebSocketRoute>();
|
||||
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
||||
if (!acceptingConnections) {
|
||||
void ws.close({ code: 1001 });
|
||||
return;
|
||||
}
|
||||
|
||||
activeSockets.add(ws);
|
||||
const server = ws.connectToServer();
|
||||
|
||||
ws.onMessage((message) => {
|
||||
if (!acceptingConnections) return;
|
||||
try {
|
||||
server.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
});
|
||||
|
||||
server.onMessage((message) => {
|
||||
if (!acceptingConnections) return;
|
||||
try {
|
||||
ws.send(message);
|
||||
} catch {
|
||||
activeSockets.delete(ws);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
async drop(): Promise<void> {
|
||||
acceptingConnections = false;
|
||||
const sockets = Array.from(activeSockets);
|
||||
activeSockets.clear();
|
||||
await Promise.all(sockets.map((ws) => ws.close({ code: 1001 }).catch(() => undefined)));
|
||||
},
|
||||
};
|
||||
}
|
||||
8
packages/app/e2e/helpers/regex.ts
Normal file
8
packages/app/e2e/helpers/regex.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Escape a literal string so it can be embedded safely inside a `RegExp`.
|
||||
* Used across the suite to build dynamic patterns from daemon ports, URLs,
|
||||
* workspace routes, and user-visible text without regex injection.
|
||||
*/
|
||||
export function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
40
packages/app/e2e/helpers/rename.ts
Normal file
40
packages/app/e2e/helpers/rename.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Listens for outbound WebSocket "session" frames of a given inner message type
|
||||
* and accumulates them. The returned array is populated in-place as frames arrive.
|
||||
*/
|
||||
export function captureWsSessionFrames<T extends Record<string, unknown>>(
|
||||
page: Page,
|
||||
messageType: string,
|
||||
extract: (inner: Record<string, unknown>) => T,
|
||||
): T[] {
|
||||
const captured: T[] = [];
|
||||
page.on("websocket", (ws) => {
|
||||
ws.on("framesent", (frame) => {
|
||||
const raw = frame.payload;
|
||||
const text = typeof raw === "string" ? raw : raw.toString("utf8");
|
||||
try {
|
||||
const outer = JSON.parse(text) as { type?: string; message?: Record<string, unknown> };
|
||||
if (outer.type === "session" && outer.message?.type === messageType) {
|
||||
captured.push(extract(outer.message));
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON and binary frames.
|
||||
}
|
||||
});
|
||||
});
|
||||
return captured;
|
||||
}
|
||||
|
||||
export function renameModalInput(page: Page, testIdPrefix: string) {
|
||||
return page.getByTestId(`${testIdPrefix}-input`);
|
||||
}
|
||||
|
||||
export function renameModalSubmit(page: Page, testIdPrefix: string) {
|
||||
return page.getByTestId(`${testIdPrefix}-submit`);
|
||||
}
|
||||
|
||||
export function renameModalError(page: Page, testIdPrefix: string) {
|
||||
return page.getByTestId(`${testIdPrefix}-error`);
|
||||
}
|
||||
341
packages/app/e2e/helpers/rewind-flow.ts
Normal file
341
packages/app/e2e/helpers/rewind-flow.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { expectComposerEditable, submitMessage } from "./composer";
|
||||
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export type RewindFlowProvider = "claude" | "codex" | "opencode" | "pi";
|
||||
export type RewindFlowMode = "conversation" | "files" | "both";
|
||||
|
||||
export interface AgentHandle {
|
||||
page: Page;
|
||||
client: SeedDaemonClient;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
provider: RewindFlowProvider;
|
||||
}
|
||||
|
||||
export interface TranscriptMessage {
|
||||
role: "user" | "assistant";
|
||||
text: string | RegExp;
|
||||
}
|
||||
|
||||
interface ProviderLaunchConfig {
|
||||
provider: RewindFlowProvider;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
modeId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const SEND_TIMEOUT_MS = 240_000;
|
||||
const REWIND_TIMEOUT_MS = 120_000;
|
||||
|
||||
function fullAccessConfig(provider: RewindFlowProvider): ProviderLaunchConfig {
|
||||
switch (provider) {
|
||||
case "claude":
|
||||
return { provider, model: "haiku", modeId: "bypassPermissions" };
|
||||
case "codex":
|
||||
return {
|
||||
provider,
|
||||
model: "gpt-5.4-mini",
|
||||
thinkingOptionId: "low",
|
||||
modeId: "full-access",
|
||||
};
|
||||
case "opencode":
|
||||
return {
|
||||
provider,
|
||||
model: "opencode/big-pickle",
|
||||
modeId: "build",
|
||||
featureValues: { auto_accept: true },
|
||||
};
|
||||
case "pi":
|
||||
return {
|
||||
provider,
|
||||
model: "openrouter/google/gemini-2.5-flash-lite",
|
||||
thinkingOptionId: "medium",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function agentRoute(cwd: string, agentId: string): string {
|
||||
return `${buildHostWorkspaceRoute(getServerId(), cwd)}?open=${encodeURIComponent(
|
||||
`agent:${agentId}`,
|
||||
)}`;
|
||||
}
|
||||
|
||||
async function openAgent(page: Page, input: { cwd: string; agentId: string }): Promise<void> {
|
||||
await page.goto(agentRoute(input.cwd, input.agentId));
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"),
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
await assertComposerIdle({ page });
|
||||
}
|
||||
|
||||
function visibleChatMessages(page: Page) {
|
||||
return page
|
||||
.locator('[data-testid="agent-chat-scroll"]:visible')
|
||||
.first()
|
||||
.locator('[data-testid="user-message"], [data-testid="assistant-message"]');
|
||||
}
|
||||
|
||||
async function transcript(
|
||||
page: Page,
|
||||
): Promise<Array<{ role: "user" | "assistant"; text: string }>> {
|
||||
const rawMessages = await visibleChatMessages(page).evaluateAll((elements) =>
|
||||
elements.map((element) => ({
|
||||
role: (element.getAttribute("data-testid") === "user-message" ? "user" : "assistant") as
|
||||
| "user"
|
||||
| "assistant",
|
||||
text: (element.textContent ?? "")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\d{1,2}:\d{2}\s?(?:AM|PM)$/u, "")
|
||||
.trim(),
|
||||
})),
|
||||
);
|
||||
|
||||
return coalesceAssistantTurnSegments(rawMessages);
|
||||
}
|
||||
|
||||
function coalesceAssistantTurnSegments(
|
||||
messages: Array<{ role: "user" | "assistant"; text: string }>,
|
||||
): Array<{ role: "user" | "assistant"; text: string }> {
|
||||
const transcriptMessages: Array<{ role: "user" | "assistant"; text: string }> = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const previous = transcriptMessages.at(-1);
|
||||
if (message.role === "assistant" && previous?.role === "assistant") {
|
||||
const joinedText =
|
||||
previous.text && message.text
|
||||
? `${previous.text}\n${message.text}`
|
||||
: previous.text || message.text;
|
||||
transcriptMessages[transcriptMessages.length - 1] = {
|
||||
role: "assistant",
|
||||
text: joinedText,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
transcriptMessages.push(message);
|
||||
}
|
||||
|
||||
return transcriptMessages.filter(
|
||||
(message) => message.role !== "assistant" || message.text.length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function expectedTextMatches(actual: string, expected: string | RegExp): boolean {
|
||||
if (typeof expected === "string") {
|
||||
return actual === expected;
|
||||
}
|
||||
return expected.test(actual);
|
||||
}
|
||||
|
||||
function formatExpectedMessage(message: TranscriptMessage): string {
|
||||
const text = typeof message.text === "string" ? JSON.stringify(message.text) : message.text;
|
||||
return `${message.role}:${text}`;
|
||||
}
|
||||
|
||||
function escapeRegExp(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
||||
}
|
||||
|
||||
export async function launchAgent(input: {
|
||||
page: Page;
|
||||
provider: RewindFlowProvider;
|
||||
cwd: string;
|
||||
mode: "full-access";
|
||||
}): Promise<AgentHandle> {
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" });
|
||||
execFileSync("git", ["config", "user.email", "paseo-test@example.com"], {
|
||||
cwd: input.cwd,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Paseo Test"], {
|
||||
cwd: input.cwd,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execFileSync("git", ["config", "commit.gpgsign", "false"], {
|
||||
cwd: input.cwd,
|
||||
stdio: "ignore",
|
||||
});
|
||||
writeFileSync(`${input.cwd}/README.md`, "# Paseo rewind flow\n", "utf8");
|
||||
execFileSync("git", ["add", "README.md"], { cwd: input.cwd, stdio: "ignore" });
|
||||
execFileSync("git", ["commit", "-m", "Initial commit"], { cwd: input.cwd, stdio: "ignore" });
|
||||
const client = await connectSeedClient();
|
||||
const opened = await client.openProject(input.cwd);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${input.cwd}`);
|
||||
}
|
||||
const agent = await client.createAgent({
|
||||
...fullAccessConfig(input.provider),
|
||||
cwd: input.cwd,
|
||||
title: `rewind-flow-${input.provider}-${randomUUID()}`,
|
||||
});
|
||||
const handle = {
|
||||
page: input.page,
|
||||
client,
|
||||
agentId: agent.id,
|
||||
cwd: input.cwd,
|
||||
provider: input.provider,
|
||||
};
|
||||
await openAgent(input.page, { cwd: input.cwd, agentId: agent.id });
|
||||
return handle;
|
||||
}
|
||||
|
||||
export async function closeAgent(handle: AgentHandle): Promise<void> {
|
||||
await handle.client.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
export async function sendMessage(handle: AgentHandle, text: string): Promise<void> {
|
||||
const before = await transcript(handle.page);
|
||||
await submitMessage(handle.page, text);
|
||||
const finish = await handle.client.waitForFinish(handle.agentId, SEND_TIMEOUT_MS);
|
||||
if (finish.status !== "idle") {
|
||||
const suffix = finish.final?.lastError ? `: ${finish.final.lastError}` : "";
|
||||
throw new Error(
|
||||
`Expected agent ${handle.agentId} to become idle, got ${finish.status}${suffix}`,
|
||||
);
|
||||
}
|
||||
if (finish.final?.lastError) {
|
||||
throw new Error(finish.final.lastError);
|
||||
}
|
||||
await expect
|
||||
.poll(async () => transcript(handle.page), { timeout: 30_000 })
|
||||
.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: "user", text }),
|
||||
expect.objectContaining({ role: "assistant" }),
|
||||
]),
|
||||
);
|
||||
await expect
|
||||
.poll(async () => transcript(handle.page).then((messages) => messages.length), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(before.length + 2);
|
||||
await assertComposerIdle(handle);
|
||||
}
|
||||
|
||||
export async function assertChatTranscript(
|
||||
handle: Pick<AgentHandle, "page">,
|
||||
expected: TranscriptMessage[],
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const actual = await transcript(handle.page);
|
||||
if (actual.length !== expected.length) {
|
||||
return JSON.stringify(actual);
|
||||
}
|
||||
const matches = actual.every(
|
||||
(message, index) =>
|
||||
message.role === expected[index]?.role &&
|
||||
expectedTextMatches(message.text, expected[index]!.text),
|
||||
);
|
||||
return matches ? "match" : JSON.stringify(actual);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe("match");
|
||||
|
||||
const actual = await transcript(handle.page);
|
||||
if (actual.length !== expected.length) {
|
||||
throw new Error(
|
||||
`Expected ${expected.length} chat messages (${expected
|
||||
.map(formatExpectedMessage)
|
||||
.join(", ")}), found ${actual.length}: ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function rewindMessage(
|
||||
handle: AgentHandle,
|
||||
userMessageIndex: number,
|
||||
mode: RewindFlowMode,
|
||||
): Promise<void> {
|
||||
const beforeEpoch = await fetchTimelineEpoch(handle);
|
||||
const userMessage = handle.page.getByTestId("user-message").nth(userMessageIndex);
|
||||
await expect(userMessage).toBeVisible({ timeout: 30_000 });
|
||||
const userMessages = (await transcript(handle.page)).filter((message) => message.role === "user");
|
||||
const userText = userMessages[userMessageIndex]?.text;
|
||||
if (!userText) {
|
||||
throw new Error(`No user message found at index ${userMessageIndex}`);
|
||||
}
|
||||
await userMessage
|
||||
.getByText(new RegExp(escapeRegExp(userText)))
|
||||
.first()
|
||||
.hover();
|
||||
const trigger = userMessage.getByTestId("rewind-menu-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
await trigger.click();
|
||||
await expect(handle.page.getByTestId("rewind-menu-content")).toBeVisible({ timeout: 10_000 });
|
||||
const modeItem = handle.page.getByTestId(`rewind-menu-${mode}`);
|
||||
await expect(modeItem).toBeVisible({ timeout: 10_000 });
|
||||
await modeItem.click();
|
||||
await expect(handle.page.getByTestId("rewind-menu-content")).toHaveCount(0, { timeout: 10_000 });
|
||||
if (mode !== "files") {
|
||||
await waitForNextTimelineEpoch(handle, beforeEpoch);
|
||||
}
|
||||
await assertComposerIdle(handle);
|
||||
}
|
||||
|
||||
export async function assertFileExists(filePath: string): Promise<void> {
|
||||
await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(true);
|
||||
}
|
||||
|
||||
export async function assertFileMissing(filePath: string): Promise<void> {
|
||||
await expect.poll(() => existsSync(filePath), { timeout: 10_000 }).toBe(false);
|
||||
}
|
||||
|
||||
export async function assertFileContains(filePath: string, text: string): Promise<void> {
|
||||
await assertFileExists(filePath);
|
||||
await expect.poll(() => readFileSync(filePath, "utf8"), { timeout: 10_000 }).toContain(text);
|
||||
}
|
||||
|
||||
export async function assertComposerIdle(handle: Pick<AgentHandle, "page">): Promise<void> {
|
||||
await expectComposerEditable(handle.page);
|
||||
await expect(handle.page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(handle.page.getByTestId("turn-working-indicator")).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function cleanupRewindFlow(input: {
|
||||
handle?: AgentHandle;
|
||||
cwd: string;
|
||||
}): Promise<void> {
|
||||
if (input.handle) {
|
||||
await closeAgent(input.handle);
|
||||
}
|
||||
rmSync(input.cwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function fetchTimelineEpoch(handle: AgentHandle): Promise<string | undefined> {
|
||||
const client = handle.client as SeedDaemonClient & {
|
||||
fetchAgentTimeline: (
|
||||
agentId: string,
|
||||
options?: { direction?: "head" | "tail"; projection?: "canonical"; limit?: number },
|
||||
) => Promise<{ epoch?: string }>;
|
||||
};
|
||||
const timeline = await client.fetchAgentTimeline(handle.agentId, {
|
||||
direction: "tail",
|
||||
projection: "canonical",
|
||||
limit: 0,
|
||||
});
|
||||
return timeline.epoch;
|
||||
}
|
||||
|
||||
async function waitForNextTimelineEpoch(
|
||||
handle: AgentHandle,
|
||||
beforeEpoch: string | undefined,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => fetchTimelineEpoch(handle), { timeout: REWIND_TIMEOUT_MS })
|
||||
.not.toBe(beforeEpoch);
|
||||
}
|
||||
145
packages/app/e2e/helpers/seed-client.ts
Normal file
145
packages/app/e2e/helpers/seed-client.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import path from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { connectDaemonClient } from "./daemon-client-loader";
|
||||
import { createTempDirectory, createTempGitRepo } from "./workspace";
|
||||
|
||||
/**
|
||||
* The general-purpose E2E daemon client used to seed and drive state out of
|
||||
* band (workspaces, agents, terminals) while the UI is exercised through the
|
||||
* browser. Domain-specific helpers wrap it for their own flows; specs should
|
||||
* prefer those wrappers over reaching for this client directly.
|
||||
*/
|
||||
export interface SeedDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
openProject(cwd: string): Promise<{
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
projectRootPath: string;
|
||||
workspaceDirectory: string;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
createTerminal(
|
||||
cwd: string,
|
||||
name?: string,
|
||||
): Promise<{
|
||||
terminal: { id: string; name: string; cwd: string } | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
createAgent(options: {
|
||||
provider: string;
|
||||
cwd: string;
|
||||
title?: string;
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
initialPrompt?: string;
|
||||
}): Promise<{ id: string; status: string }>;
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>;
|
||||
}>;
|
||||
updateAgent(agentId: string, updates: { name?: string }): Promise<void>;
|
||||
waitForAgentUpsert(
|
||||
agentId: string,
|
||||
predicate: (snapshot: { status: string }) => boolean,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string }>;
|
||||
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
||||
waitForFinish(
|
||||
agentId: string,
|
||||
timeout?: number,
|
||||
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
||||
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
||||
fetchAgentHistory(options?: {
|
||||
page?: { limit: number };
|
||||
}): Promise<{ entries: Array<{ id: string }> }>;
|
||||
subscribeTerminal(
|
||||
terminalId: string,
|
||||
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
||||
sendTerminalInput(
|
||||
terminalId: string,
|
||||
message: { type: "input"; data: string } | { type: "resize"; rows: number; cols: number },
|
||||
): void;
|
||||
onTerminalStreamEvent(
|
||||
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
|
||||
): () => void;
|
||||
killTerminal(terminalId: string): Promise<{ error: string | null }>;
|
||||
}
|
||||
|
||||
export async function connectSeedClient(): Promise<SeedDaemonClient> {
|
||||
return connectDaemonClient<SeedDaemonClient>({
|
||||
clientIdPrefix: "seed",
|
||||
appVersion: loadAppVersion(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A temp project opened as a workspace, with a seed client connected to drive
|
||||
* it out of band. `cleanup` closes the client and removes the project. This is
|
||||
* the canonical bootstrap for specs that need a real workspace plus daemon
|
||||
* access; domain helpers (e.g. mock-agent) build on it rather than re-rolling
|
||||
* the trio. `repoPath` is the project root on disk (git repo or plain dir).
|
||||
*/
|
||||
export interface SeededWorkspace {
|
||||
client: SeedDaemonClient;
|
||||
repoPath: string;
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
workspaceDirectory: string;
|
||||
/** Stable project identity the daemon groups workspaces under. */
|
||||
projectId: string;
|
||||
/** Project label the UI shows (owner/repo for known remotes, else basename). */
|
||||
projectDisplayName: string;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function seedWorkspace(options: {
|
||||
repoPrefix: string;
|
||||
/** Repo fixture options; only applies to git projects (the default). */
|
||||
repo?: Parameters<typeof createTempGitRepo>[1];
|
||||
/** Set to false to seed a plain non-git directory instead of a git repo. */
|
||||
git?: boolean;
|
||||
}): Promise<SeededWorkspace> {
|
||||
const project =
|
||||
options.git === false
|
||||
? await createTempDirectory(options.repoPrefix)
|
||||
: await createTempGitRepo(options.repoPrefix, options.repo);
|
||||
const client = await connectSeedClient();
|
||||
try {
|
||||
const opened = await client.openProject(project.path);
|
||||
if (!opened.workspace) {
|
||||
throw new Error(opened.error ?? `Failed to open project ${project.path}`);
|
||||
}
|
||||
return {
|
||||
client,
|
||||
repoPath: project.path,
|
||||
workspaceId: opened.workspace.id,
|
||||
workspaceName: opened.workspace.name,
|
||||
workspaceDirectory: opened.workspace.workspaceDirectory,
|
||||
projectId: opened.workspace.projectId,
|
||||
projectDisplayName: opened.workspace.projectDisplayName,
|
||||
cleanup: async () => {
|
||||
await client.close().catch(() => undefined);
|
||||
await project.cleanup().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await client.close().catch(() => undefined);
|
||||
await project.cleanup().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function loadAppVersion(): string {
|
||||
const packageJsonPath = path.resolve(__dirname, "../../package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
|
||||
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
||||
throw new Error(`Missing app version in ${packageJsonPath}`);
|
||||
}
|
||||
return packageJson.version;
|
||||
}
|
||||
13
packages/app/e2e/helpers/server-id.ts
Normal file
13
packages/app/e2e/helpers/server-id.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Resolves the isolated E2E daemon's server id, which Playwright's globalSetup
|
||||
* publishes into the environment before any spec runs. Helpers and specs that
|
||||
* build host routes or `sidebar-workspace-row-${serverId}:${id}` selectors share
|
||||
* this accessor instead of re-reading the env var.
|
||||
*/
|
||||
export 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;
|
||||
}
|
||||
270
packages/app/e2e/helpers/settings.ts
Normal file
270
packages/app/e2e/helpers/settings.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { escapeRegex } from "./regex";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
const SECTION_LABELS = {
|
||||
general: "General",
|
||||
shortcuts: "Shortcuts",
|
||||
integrations: "Integrations",
|
||||
permissions: "Permissions",
|
||||
diagnostics: "Diagnostics",
|
||||
about: "About",
|
||||
} as const;
|
||||
|
||||
export type SettingsSection = keyof typeof SECTION_LABELS | "projects";
|
||||
|
||||
export async function openSettingsSection(page: Page, section: SettingsSection): Promise<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
if (section === "projects") {
|
||||
await page.getByTestId("settings-projects").click();
|
||||
await expect(page).toHaveURL(/\/settings\/projects$/);
|
||||
return;
|
||||
}
|
||||
|
||||
await sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/settings/${section}$`));
|
||||
}
|
||||
|
||||
export async function openSettingsHost(page: Page, serverId: string): Promise<void> {
|
||||
await page.getByTestId(`settings-host-entry-${serverId}`).click();
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectSettingsHeader(page: Page, title: string): Promise<void> {
|
||||
await expect(page.getByTestId("settings-detail-header-title")).toHaveText(title);
|
||||
}
|
||||
|
||||
export async function openAddHostFlow(page: Page): Promise<void> {
|
||||
await page.getByTestId("settings-add-host").click();
|
||||
await expect(page.getByText("Add connection", { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function selectHostConnectionType(
|
||||
page: Page,
|
||||
type: "direct" | "relay",
|
||||
): Promise<void> {
|
||||
const label = type === "direct" ? "Direct connection" : "Paste pairing link";
|
||||
await page.getByRole("button", { name: label }).click();
|
||||
}
|
||||
|
||||
export async function toggleHostAdvanced(page: Page): Promise<void> {
|
||||
await page.getByTestId("direct-host-advanced-toggle").click();
|
||||
}
|
||||
|
||||
export async function openCompactSettings(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/h\/|\/welcome/, { timeout: 15000 });
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
const settingsButton = page.locator('[data-testid="sidebar-settings"]:visible').first();
|
||||
await expect(settingsButton).toBeVisible();
|
||||
await settingsButton.click();
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectCompactSettingsList(page: Page): Promise<void> {
|
||||
await expect(page).toHaveURL(/\/settings$/);
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
await expect(page.getByText("Theme", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toHaveCount(0);
|
||||
await expect(page.locator('[data-testid^="settings-host-page-"]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectSettingsSidebarVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("settings-sidebar")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectSettingsSidebarHidden(page: Page): Promise<void> {
|
||||
await expect(page.locator('[data-testid="settings-sidebar"]:visible')).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectSettingsSidebarSections(
|
||||
page: Page,
|
||||
sections: Array<Exclude<SettingsSection, "projects">>,
|
||||
): Promise<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
for (const section of sections) {
|
||||
await expect(
|
||||
sidebar.getByRole("button", { name: SECTION_LABELS[section], exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
export async function goBackInSettings(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Back", exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectSettingsBackButton(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function clickSettingsBackToWorkspace(page: Page): Promise<void> {
|
||||
await page.getByTestId("settings-back-to-workspace").click();
|
||||
}
|
||||
|
||||
export async function expectHostSettingsUrl(page: Page, serverId: string): Promise<void> {
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`/settings/hosts/${escapeRegex(encodeURIComponent(serverId))}$`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function verifyLegacyHostSettingsRedirect(page: Page): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await page.goto(`/h/${encodeURIComponent(serverId)}/settings`);
|
||||
await expectHostSettingsUrl(page, serverId);
|
||||
}
|
||||
|
||||
export async function openCompactSettingsHost(page: Page): Promise<void> {
|
||||
const serverId = getServerId();
|
||||
await openSettingsHost(page, serverId);
|
||||
await expectHostSettingsUrl(page, serverId);
|
||||
}
|
||||
|
||||
export async function expectAddHostMethodOptions(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Direct connection" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Paste pairing link" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function fillDirectHostUri(page: Page, uri: string): Promise<void> {
|
||||
await page.getByTestId("direct-host-uri-input").fill(uri);
|
||||
}
|
||||
|
||||
export async function expectDirectHostFormValues(
|
||||
page: Page,
|
||||
fields: { host: string; port: string; password: string },
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("direct-host-input")).toHaveValue(fields.host);
|
||||
await expect(page.getByTestId("direct-port-input")).toHaveValue(fields.port);
|
||||
await expect(page.getByTestId("direct-password-input")).toHaveValue(fields.password);
|
||||
}
|
||||
|
||||
export async function expectDirectHostSslEnabled(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectDirectHostUriValue(page: Page, uri: string): Promise<void> {
|
||||
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(uri);
|
||||
}
|
||||
|
||||
export async function expectDirectHostUriHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("direct-host-uri-input")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectDiagnosticsContent(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Play test" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectAboutContent(page: Page): Promise<void> {
|
||||
await expect(page.getByText("App version", { exact: true }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectGeneralContent(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Theme", { exact: true }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostLabelDisplayed(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-rename-modal-input")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function clickEditHostLabel(page: Page): Promise<void> {
|
||||
await page.getByTestId("host-page-label-edit-button").click();
|
||||
}
|
||||
|
||||
export async function expectHostLabelEditMode(page: Page, expectedLabel: string): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-rename-modal-input")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-rename-modal-input")).toHaveValue(expectedLabel);
|
||||
await expect(page.getByTestId("host-page-rename-modal-submit")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostConnectionsCard(page: Page, port: string): Promise<void> {
|
||||
const card = page.getByTestId("host-page-connections-card");
|
||||
await expect(card).toBeVisible();
|
||||
await expect(page.getByText("Connections", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
card.getByText(new RegExp(`TCP \\((localhost|127\\.0\\.0\\.1):${port}\\)`)),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostInjectMcpCard(page: Page): Promise<void> {
|
||||
const card = page.getByTestId("host-page-inject-mcp-card");
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.getByRole("switch", { name: "Inject Paseo tools" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostActionCards(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-restart-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-restart-button")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-providers-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-remove-host-card")).toBeVisible();
|
||||
await expect(page.getByTestId("host-page-remove-host-button")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function serveJson(page: Page, url: string, body: unknown): Promise<void> {
|
||||
await page.route(url, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function openAddProviderModal(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Add provider", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Search providers" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function findAcpCatalogProvider(page: Page, providerName: string): Promise<void> {
|
||||
await page.getByRole("textbox", { name: "Search providers" }).fill(providerName);
|
||||
await expect(page.getByText(providerName, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function installAcpCatalogProvider(page: Page, providerName: string): Promise<void> {
|
||||
await findAcpCatalogProvider(page, providerName);
|
||||
await page.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await expect(page.getByRole("textbox", { name: "Search providers" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectProviderInstalledInSettings(
|
||||
page: Page,
|
||||
providerName: string,
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.getByRole("button", { name: `${providerName} provider details`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostNoLocalOnlyRows(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("host-page-pair-device-row")).toHaveCount(0);
|
||||
await expect(page.getByTestId("host-page-daemon-lifecycle-card")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectRetiredSidebarSectionsAbsent(page: Page): Promise<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "Hosts", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "Providers", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "Pair device", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "Daemon", exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole("button", { name: "General", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "Diagnostics", exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole("button", { name: "About", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectHostPageVisible(page: Page, serverId: string): Promise<void> {
|
||||
await expect(page.getByTestId(`settings-host-page-${serverId}`)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectLocalHostEntryFirst(page: Page, serverId: string): Promise<void> {
|
||||
const sidebar = page.getByTestId("settings-sidebar");
|
||||
await expect(sidebar).toBeVisible({ timeout: 15_000 });
|
||||
await expect(sidebar.locator('[data-testid^="settings-host-entry-"]').first()).toHaveAttribute(
|
||||
"data-testid",
|
||||
`settings-host-entry-${serverId}`,
|
||||
);
|
||||
const localHostEntry = page.getByTestId(`settings-host-entry-${serverId}`);
|
||||
await expect(localHostEntry.getByTestId("settings-host-local-marker")).toBeVisible();
|
||||
await expect(localHostEntry.getByText("Local", { exact: true })).toBeVisible();
|
||||
}
|
||||
33
packages/app/e2e/helpers/sidebar.ts
Normal file
33
packages/app/e2e/helpers/sidebar.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { getServerId } from "./server-id";
|
||||
|
||||
export async function selectWorkspaceInSidebar(page: Page, workspaceId: string): Promise<void> {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
}
|
||||
|
||||
export async function expectWorkspaceListed(page: Page, name: string): Promise<void> {
|
||||
await expect(
|
||||
page.locator('[data-testid^="sidebar-workspace-row-"]').filter({ hasText: name }).first(),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function openMobileAgentSidebar(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Open menu" }).click();
|
||||
}
|
||||
|
||||
export async function closeMobileAgentSidebar(page: Page): Promise<void> {
|
||||
const closeButton = page.getByTestId("sidebar-close");
|
||||
await expect(closeButton).toBeInViewport({ timeout: 5_000 });
|
||||
await closeButton.click({ force: true });
|
||||
}
|
||||
|
||||
// The mobile sidebar panel animates via translateX; toBeInViewport reflects the rendered position.
|
||||
export async function expectMobileAgentSidebarVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
export async function expectMobileAgentSidebarHidden(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport({ timeout: 5_000 });
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, type Page } from "../fixtures";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
import { wsRoutePatternForPort } from "./daemon-port";
|
||||
|
||||
const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once";
|
||||
const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce";
|
||||
@@ -78,7 +79,7 @@ class StartupScenario {
|
||||
}
|
||||
|
||||
for (const port of this.blockedEndpointPorts) {
|
||||
await this.page.routeWebSocket(new RegExp(`:${escapeRegex(port)}\\b`), async (ws) => {
|
||||
await this.page.routeWebSocket(wsRoutePatternForPort(port), async (ws) => {
|
||||
await ws.close({ code: 1008, reason: "Blocked unreachable startup test host." });
|
||||
});
|
||||
}
|
||||
@@ -227,7 +228,3 @@ function buildStoredHost(input: {
|
||||
function buildStoredCreateAgentPreferences(serverId: string) {
|
||||
return buildCreateAgentPreferences(serverId);
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
import {
|
||||
connectTerminalClient,
|
||||
navigateToTerminal,
|
||||
setupDeterministicPrompt,
|
||||
type TerminalPerfDaemonClient,
|
||||
} from "./terminal-perf";
|
||||
import { navigateToTerminal, setupDeterministicPrompt } from "./terminal-perf";
|
||||
import { connectSeedClient, type SeedDaemonClient } from "./seed-client";
|
||||
|
||||
interface TempRepo {
|
||||
path: string;
|
||||
@@ -19,12 +15,12 @@ export interface TerminalInstance {
|
||||
}
|
||||
|
||||
export class TerminalE2EHarness {
|
||||
readonly client: TerminalPerfDaemonClient;
|
||||
readonly client: SeedDaemonClient;
|
||||
readonly tempRepo: TempRepo;
|
||||
readonly workspaceId: string;
|
||||
|
||||
private constructor(input: {
|
||||
client: TerminalPerfDaemonClient;
|
||||
client: SeedDaemonClient;
|
||||
tempRepo: TempRepo;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
@@ -35,7 +31,7 @@ export class TerminalE2EHarness {
|
||||
|
||||
static async create(input: { tempPrefix: string }): Promise<TerminalE2EHarness> {
|
||||
const tempRepo = await createTempGitRepo(input.tempPrefix);
|
||||
const client = await connectTerminalClient();
|
||||
const client = await connectSeedClient();
|
||||
const seedResult = await client.openProject(tempRepo.path);
|
||||
if (!seedResult.workspace) {
|
||||
await client.close().catch(() => {});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user