Compare commits
902 Commits
auto-accep
...
dev
| Author | SHA1 | Date |
|---|---|---|
|
|
ae614d919f | |
|
|
65cde7f494 | |
|
|
98325dcdc6 | |
|
|
0788a535e2 | |
|
|
b7fab49b64 | |
|
|
463318486f | |
|
|
7afb517a1a | |
|
|
c589724729 | |
|
|
9385714373 | |
|
|
c90fc6a486 | |
|
|
bc1840b196 | |
|
|
095aeba0a7 | |
|
|
e945436b6f | |
|
|
6bfa82de65 | |
|
|
d83fe4b540 | |
|
|
81bdffc81c | |
|
|
2549a38a71 | |
|
|
5d48e7bd44 | |
|
|
ec8b9810b4 | |
|
|
65318a80f7 | |
|
|
6a5aae9a84 | |
|
|
1f94c48bdd | |
|
|
01c5eb679c | |
|
|
41612b3dbe | |
|
|
c2d2ca3522 | |
|
|
3a1ec27feb | |
|
|
3c96bf8468 | |
|
|
3ea6413407 | |
|
|
885df8eb54 | |
|
|
f4975ef32a | |
|
|
37883a9f3a | |
|
|
3c31d04666 | |
|
|
e64548fb4d | |
|
|
31f6f43cfc | |
|
|
090ad8290e | |
|
|
d1258ac19c | |
|
|
48c1b6b338 | |
|
|
40e4cd27a1 | |
|
|
5a6d10cd53 | |
|
|
527b51477d | |
|
|
535343bf56 | |
|
|
4394e42615 | |
|
|
2e4c43c1cf | |
|
|
965c751522 | |
|
|
24bdd3c9fb | |
|
|
01f0319192 | |
|
|
517e6c9aa4 | |
|
|
a4a9ea4ab0 | |
|
|
eaa272ef7f | |
|
|
70b636a360 | |
|
|
a8fd0159be | |
|
|
342436dfc4 | |
|
|
77a462c930 | |
|
|
9965d385de | |
|
|
f0f1e51c5c | |
|
|
4712c18a58 | |
|
|
9e156ea168 | |
|
|
68f4aa220e | |
|
|
3a0e00dd7f | |
|
|
66b4e5e020 | |
|
|
8b8d4fa066 | |
|
|
6253ef0c27 | |
|
|
c6ebc7ff7c | |
|
|
985663620f | |
|
|
c796b9a19e | |
|
|
6ea108a03b | |
|
|
280eb16e77 | |
|
|
930e94a3ea | |
|
|
629e866ff0 | |
|
|
c08fa5675f | |
|
|
cc50b778eb | |
|
|
00fa68b3a7 | |
|
|
288eb044cb | |
|
|
59ca4543d8 | |
|
|
650d0dbe54 | |
|
|
a5ec741cff | |
|
|
fff98636f7 | |
|
|
c72642dd35 | |
|
|
f2d4ced8ea | |
|
|
ae7e2eb3fb | |
|
|
a32ffaba35 | |
|
|
a4e75a0794 | |
|
|
35350b1d25 | |
|
|
263dcf75b5 | |
|
|
7994dce0f2 | |
|
|
fbfa148e4e | |
|
|
9d57f21f9f | |
|
|
3deee3a02b | |
|
|
2002f08f2e | |
|
|
c307505f8b | |
|
|
6359d00fb4 | |
|
|
b969066a20 | |
|
|
500dcfc586 | |
|
|
7b8dc8065e | |
|
|
e89527c9f0 | |
|
|
aa2239d5de | |
|
|
8daeacc989 | |
|
|
81d3ac3bf0 | |
|
|
eb6f1dada8 | |
|
|
8e9e79d276 | |
|
|
38014fe448 | |
|
|
8942fc21aa | |
|
|
7f45943a9e | |
|
|
6e1400fc45 | |
|
|
bf26c08d51 | |
|
|
29f7dc073b | |
|
|
5e1b513527 | |
|
|
f549fde874 | |
|
|
6dfb30448c | |
|
|
b5b5f7e019 | |
|
|
ae7b49b034 | |
|
|
f151c660b1 | |
|
|
c3ef69c866 | |
|
|
363891126c | |
|
|
1989704abe | |
|
|
f0a9ebfed4 | |
|
|
7e32f80d82 | |
|
|
966d9cfa41 | |
|
|
92e820fdc8 | |
|
|
c4b3971548 | |
|
|
3faabdadb7 | |
|
|
93a139315c | |
|
|
10ca1ace6b | |
|
|
c3dfd08ba8 | |
|
|
510a1e8140 | |
|
|
159ede2d5c | |
|
|
291a857fb8 | |
|
|
57a5236e71 | |
|
|
23c8656080 | |
|
|
ec3ae17e4d | |
|
|
69d047ae7d | |
|
|
327f62526a | |
|
|
d540d363a7 | |
|
|
db93891373 | |
|
|
0f488996b3 | |
|
|
a6f524ca08 | |
|
|
811c7e2494 | |
|
|
ebaa99aba2 | |
|
|
d66e6dc25f | |
|
|
336d28f112 | |
|
|
916afb5220 | |
|
|
5daf2fa7f0 | |
|
|
733a3bd031 | |
|
|
2e8e278441 | |
|
|
0bae38c062 | |
|
|
a09b086729 | |
|
|
df1c6c9e8d | |
|
|
789d86f7b0 | |
|
|
e148b318b7 | |
|
|
0cad775427 | |
|
|
00d6841f84 | |
|
|
8a8f7b3e90 | |
|
|
c526caae7b | |
|
|
b1c07488bd | |
|
|
92f8e03160 | |
|
|
f6fd43e574 | |
|
|
854484babf | |
|
|
e4ff1ea778 | |
|
|
26fb6b8788 | |
|
|
4214ae205d | |
|
|
d9d4f895bc | |
|
|
48db7cf07a | |
|
|
802d165572 | |
|
|
f7f41dc3a0 | |
|
|
1fcfb69bf7 | |
|
|
fa96cb9c6e | |
|
|
cc30bfc94b | |
|
|
880c0a7477 | |
|
|
eabf3caeb9 | |
|
|
c9326fc199 | |
|
|
d7481f4593 | |
|
|
f3f728ec27 | |
|
|
c619caefdd | |
|
|
c559af51ce | |
|
|
d1e0a4640c | |
|
|
f9e71ec515 | |
|
|
ef538c9707 | |
|
|
2f405daa98 | |
|
|
a9c85b7c27 | |
|
|
897d83c589 | |
|
|
0a125e5d4d | |
|
|
38d2276592 | |
|
|
d58004a864 | |
|
|
5fd833aa18 | |
|
|
44f83015cd | |
|
|
9a1c9ae15a | |
|
|
a3a6cf1c07 | |
|
|
47a676111a | |
|
|
1df5ad470a | |
|
|
506dd75818 | |
|
|
c8ecd64022 | |
|
|
ca376a4cff | |
|
|
7532d99e5b | |
|
|
181b5f6236 | |
|
|
6314f09c14 | |
|
|
4b4b7832aa | |
|
|
4280307013 | |
|
|
9b09a7e766 | |
|
|
3fc0367b93 | |
|
|
954a6ca88e | |
|
|
0c03a3ee10 | |
|
|
53330a518f | |
|
|
892bdebaac | |
|
|
18121300f3 | |
|
|
d6d4446f46 | |
|
|
26cc924ea2 | |
|
|
4dd866d5c4 | |
|
|
beab4cc2c2 | |
|
|
567a91191a | |
|
|
434d82bbe2 | |
|
|
2929774acb | |
|
|
6e61a46a84 | |
|
|
2daf4b805a | |
|
|
7342e650c0 | |
|
|
8c2e2ecc95 | |
|
|
25a2b739e6 | |
|
|
85c16926c4 | |
|
|
2e78fdec43 | |
|
|
1fcb920eb4 | |
|
|
b1e89c344b | |
|
|
befbedacdc | |
|
|
2cc738fb17 | |
|
|
71b20698bb | |
|
|
3df18dcde1 | |
|
|
a898c2ea3a | |
|
|
bf777298c8 | |
|
|
93fad99f7f | |
|
|
057848deb8 | |
|
|
1de06452d3 | |
|
|
58f60629a1 | |
|
|
39a47c9b8c | |
|
|
ea88044f2e | |
|
|
e6f6f7aff1 | |
|
|
48e97b47af | |
|
|
fe120e3cbf | |
|
|
f2dd774660 | |
|
|
e7ff0f17c8 | |
|
|
2ed756c72c | |
|
|
054f4be185 | |
|
|
e3e1e9af50 | |
|
|
c8389cf96d | |
|
|
c5442d418d | |
|
|
fa95a61c4e | |
|
|
9f3c2bd861 | |
|
|
c2f78224ae | |
|
|
14f9e21d5c | |
|
|
8e4bab5181 | |
|
|
3c32013eb1 | |
|
|
47d2ab120a | |
|
|
186af2723d | |
|
|
6926fe1c74 | |
|
|
ee018d5c82 | |
|
|
0465579d6b | |
|
|
196a03caff | |
|
|
b234370080 | |
|
|
5d2dc8888c | |
|
|
0b1018f6dd | |
|
|
afb6abff73 | |
|
|
e7f94f9b9a | |
|
|
72c77d0e7b | |
|
|
5c15755a10 | |
|
|
3a4bfeb5b5 | |
|
|
1037c72d99 | |
|
|
ba00e9a993 | |
|
|
963dad75ef | |
|
|
7e9b721e97 | |
|
|
a5b1dc081d | |
|
|
0bc2f99f2d | |
|
|
55895d0663 | |
|
|
72cb9dfa31 | |
|
|
f0a9075fdf | |
|
|
fee1e25ab4 | |
|
|
a94ac5aa2c | |
|
|
62ac45a9c9 | |
|
|
f7c2ef876f | |
|
|
6639f92739 | |
|
|
36aeb32159 | |
|
|
ff37d7c2df | |
|
|
4f96eb239f | |
|
|
38af99dcb4 | |
|
|
772059acb5 | |
|
|
1f290fc1ba | |
|
|
77d4f99497 | |
|
|
aa2d753e7e | |
|
|
860531c275 | |
|
|
2b86b36c8c | |
|
|
8ac2fbbd12 | |
|
|
26382c6216 | |
|
|
0981b8eb71 | |
|
|
aa9ed001d3 | |
|
|
6086072567 | |
|
|
6c14ea1d22 | |
|
|
c3a9ec4a99 | |
|
|
41b0d03f6a | |
|
|
81eb6e670b | |
|
|
8446719b13 | |
|
|
15a8c22a26 | |
|
|
48326e8d9c | |
|
|
43bc5551e8 | |
|
|
f736116967 | |
|
|
82fc493520 | |
|
|
2145d97f18 | |
|
|
f3997d8082 | |
|
|
02b19bc3d7 | |
|
|
5cd54ec345 | |
|
|
c8909908f5 | |
|
|
4b9660b211 | |
|
|
e5f0e813b6 | |
|
|
c33d9996f0 | |
|
|
7a7643c86a | |
|
|
6f5b70e681 | |
|
|
ff13524a53 | |
|
|
e973bbf54a | |
|
|
d36b38e4a6 | |
|
|
bdd7829c68 | |
|
|
a93374c48f | |
|
|
af2ccc94eb | |
|
|
a76be695c7 | |
|
|
e528ed5d86 | |
|
|
bb8d2cdd10 | |
|
|
decb5e68ee | |
|
|
21023337fa | |
|
|
6274b0677c | |
|
|
d8ad8338f5 | |
|
|
7b44918149 | |
|
|
d2bfa92e74 | |
|
|
3fb60d05e5 | |
|
|
d341499684 | |
|
|
771525270a | |
|
|
e96eead32e | |
|
|
b242a8d8e4 | |
|
|
9c6f1edfd7 | |
|
|
ef7d1f7efa | |
|
|
b7a06e1939 | |
|
|
311ba4179a | |
|
|
ad3b350672 | |
|
|
590523dcd1 | |
|
|
b8fb75a94a | |
|
|
98a31e30cc | |
|
|
c333e914ee | |
|
|
c7760b433b | |
|
|
2e6ac8ff49 | |
|
|
1ebc92fd36 | |
|
|
9f94bdb496 | |
|
|
28f5176ffd | |
|
|
38450443b1 | |
|
|
da1d37274f | |
|
|
17e8f577d6 | |
|
|
c7d23098d1 | |
|
|
bcf18edde4 | |
|
|
9a2482ac09 | |
|
|
54443bfb7e | |
|
|
ec20efc11a | |
|
|
83ed1c4414 | |
|
|
1d363fa19f | |
|
|
1b028d0632 | |
|
|
d500a8432a | |
|
|
2d502d6ffe | |
|
|
2ad190e482 | |
|
|
16742af7f3 | |
|
|
652313e036 | |
|
|
1a4a6eabe2 | |
|
|
ba244a6e62 | |
|
|
7cb690d7e5 | |
|
|
31ad6e85ba | |
|
|
ea04b23745 | |
|
|
05c3cfb2aa | |
|
|
f54e4b60cc | |
|
|
97c15a087d | |
|
|
b90de755f9 | |
|
|
8864fdce2f | |
|
|
5179b87aef | |
|
|
66a56551be | |
|
|
7123aad5a8 | |
|
|
d6fc5f414b | |
|
|
77fc88c8ad | |
|
|
cafc2b204b | |
|
|
36709aae5f | |
|
|
fac0dd8862 | |
|
|
73e107250d | |
|
|
b746aec493 | |
|
|
ad40b65b0b | |
|
|
971383661a | |
|
|
b0017bf1b9 | |
|
|
0c0c6f3bdb | |
|
|
b480a38d31 | |
|
|
4167e25c7e | |
|
|
1041ae91d1 | |
|
|
898456a25c | |
|
|
53d0b58ebf | |
|
|
2b0baf97bd | |
|
|
0dbfefa080 | |
|
|
d1c49ba210 | |
|
|
3ea72aec21 | |
|
|
9717383823 | |
|
|
5d9e780029 | |
|
|
aa11fa865d | |
|
|
9a64bdb539 | |
|
|
71693cc24b | |
|
|
700f57112a | |
|
|
0a80ef4278 | |
|
|
4f9667c4bb | |
|
|
be142b00bd | |
|
|
45c2573979 | |
|
|
79e9d19019 | |
|
|
958a80cc05 | |
|
|
4647aa80ac | |
|
|
a379eb3867 | |
|
|
cbe1337f24 | |
|
|
50f6aa3763 | |
|
|
0dcdf5f529 | |
|
|
4586b41ffd | |
|
|
35884defd8 | |
|
|
15dc33d1a3 | |
|
|
1398674e53 | |
|
|
afc4c831eb | |
|
|
ec64ceabec | |
|
|
56644be95a | |
|
|
00d3b831fc | |
|
|
b848b7ebae | |
|
|
e837dcc1c5 | |
|
|
024979f3fd | |
|
|
bc608fb081 | |
|
|
9838f56a6f | |
|
|
98b3340cee | |
|
|
5e684c6e80 | |
|
|
2c1d8a90d5 | |
|
|
8994cbfc0f | |
|
|
42a773481e | |
|
|
539b01f20f | |
|
|
814a515a8a | |
|
|
235a82aea9 | |
|
|
9330bc5339 | |
|
|
1238d1f61a | |
|
|
1d3232b388 | |
|
|
5c1bb5de86 | |
|
|
7c5ed771c3 | |
|
|
31c4a4fb47 | |
|
|
037077285a | |
|
|
41c77ccb33 | |
|
|
546748a461 | |
|
|
c9c93eac00 | |
|
|
3f1a4abe6d | |
|
|
431e0586ad | |
|
|
fde201c286 | |
|
|
d3debc191f | |
|
|
34f43fff89 | |
|
|
49623aa519 | |
|
|
f1340472ec | |
|
|
a8b28826a0 | |
|
|
a03a2b6eab | |
|
|
ad78b79b8a | |
|
|
9a006d8700 | |
|
|
3a0bf2f39f | |
|
|
b556979634 | |
|
|
691644eeeb | |
|
|
4aebaaf067 | |
|
|
77b3b46788 | |
|
|
36dfe1646b | |
|
|
6926dc26d1 | |
|
|
eb74e4a6d2 | |
|
|
85d8e143bf | |
|
|
8e1b53b32c | |
|
|
0a7dfc03ee | |
|
|
4c27e7fc64 | |
|
|
0f5626d2e4 | |
|
|
5ea95451dd | |
|
|
9239d877b9 | |
|
|
fc68c24433 | |
|
|
db9619dad6 | |
|
|
84d9b38873 | |
|
|
8035c3435b | |
|
|
71e7603d71 | |
|
|
40e49c5b49 | |
|
|
afe9b97274 | |
|
|
3b3549902d | |
|
|
e9a9c75c1f | |
|
|
2b171828b0 | |
|
|
8dd817023a | |
|
|
0d6c601365 | |
|
|
5460bf9989 | |
|
|
eb3bfffad4 | |
|
|
e2d03ce38c | |
|
|
32f9dc6383 | |
|
|
c529529f84 | |
|
|
13bac9c91a | |
|
|
fe53af4819 | |
|
|
e82c5a9a28 | |
|
|
3236f228fb | |
|
|
0e0e7a4a4b | |
|
|
10a3d6c54e | |
|
|
832b8e252e | |
|
|
040f551c57 | |
|
|
cc818f8032 | |
|
|
d5337b41f4 | |
|
|
9f7a76d6c0 | |
|
|
6a16db4b92 | |
|
|
9ad6588f3e | |
|
|
fb6bf0b35e | |
|
|
f80343b875 | |
|
|
9b805e1cc4 | |
|
|
2e0d5d2308 | |
|
|
38e0dc9ccd | |
|
|
40aeaa120d | |
|
|
6a64177589 | |
|
|
5dc47905a9 | |
|
|
dc0044882c | |
|
|
45ae7dc653 | |
|
|
129fe1e350 | |
|
|
214a6c6cf1 | |
|
|
3f249aba6d | |
|
|
5c6ec1caac | |
|
|
24f9df5463 | |
|
|
12b8e1c2be | |
|
|
d70099b059 | |
|
|
ce845a0b1b | |
|
|
05d3e65f76 | |
|
|
51618e9cef | |
|
|
e78944e9a4 | |
|
|
bfdc38e421 | |
|
|
83023e4f0f | |
|
|
d0a57305ef | |
|
|
27a70ad70f | |
|
|
0bbf26a1ce | |
|
|
83cdb4de64 | |
|
|
4989632245 | |
|
|
d460614cd7 | |
|
|
7866dbcfcc | |
|
|
e71a21e0a8 | |
|
|
1071aca91f | |
|
|
b3d0446d13 | |
|
|
949191ab74 | |
|
|
92cd908fb5 | |
|
|
6fcc970def | |
|
|
52a7a04ad8 | |
|
|
37b8662a9d | |
|
|
ddcb32ae0b | |
|
|
2c056c90da | |
|
|
812d1bb32a | |
|
|
9a58c43ef4 | |
|
|
63585db6a7 | |
|
|
bd44489ada | |
|
|
a6ef9e9206 | |
|
|
6e09a1d904 | |
|
|
4f21757e0d | |
|
|
2dbcd79fd2 | |
|
|
48a7f0fd93 | |
|
|
d69962b0f7 | |
|
|
a6f23cb08e | |
|
|
0540751897 | |
|
|
baa204193c | |
|
|
aeece6166b | |
|
|
0d7e62a532 | |
|
|
41aa254db4 | |
|
|
d178d8249f | |
|
|
e6f5214779 | |
|
|
84f60d97a0 | |
|
|
cbf4b68fee | |
|
|
bd4527b4f2 | |
|
|
f4a9fe29a3 | |
|
|
5a0bfa7061 | |
|
|
1ac1a0287c | |
|
|
8e09e8c612 | |
|
|
84e62fc662 | |
|
|
a7ea93528b | |
|
|
d90e3a2833 | |
|
|
1c74c2741a | |
|
|
5d2f8d77f9 | |
|
|
81be544981 | |
|
|
773c1192dc | |
|
|
5ddfe4ada5 | |
|
|
a93d98bd94 | |
|
|
54ed87d53c | |
|
|
8ee939c741 | |
|
|
1b0096bf61 | |
|
|
8006c29db3 | |
|
|
3f1c96a0bb | |
|
|
3558deba4a | |
|
|
c3ddc85cca | |
|
|
a800583aea | |
|
|
171e69c2fc | |
|
|
822bb7b336 | |
|
|
47cf267c23 | |
|
|
976aae7e42 | |
|
|
0ca51eebcf | |
|
|
3256886e25 | |
|
|
d2194f6dde | |
|
|
bfd4787fcd | |
|
|
58dce0148a | |
|
|
79635b8b41 | |
|
|
331dacf9db | |
|
|
4ba7d3b406 | |
|
|
a43783a6d4 | |
|
|
37c5295111 | |
|
|
56102ff642 | |
|
|
1b86c27fb8 | |
|
|
fe43bdb699 | |
|
|
a849a17e93 | |
|
|
0292f1b559 | |
|
|
5dfe86dcb1 | |
|
|
4b4dd2b882 | |
|
|
bc949af623 | |
|
|
9e7c136de7 | |
|
|
fee3c196c5 | |
|
|
6c047391bb | |
|
|
350df0b261 | |
|
|
fbabc97c4c | |
|
|
7daea69e13 | |
|
|
0772a95918 | |
|
|
dadddc9c8c | |
|
|
6708c3f6cf | |
|
|
ba22976568 | |
|
|
0afeaea21f | |
|
|
b07b5a9b7f | |
|
|
dbbe931a18 | |
|
|
e14e874e51 | |
|
|
544315dff7 | |
|
|
f13da808ff | |
|
|
e416e59ea6 | |
|
|
cb69501098 | |
|
|
a64f604d54 | |
|
|
d7093abf61 | |
|
|
60af447908 | |
|
|
1cdc558ac0 | |
|
|
3849822769 | |
|
|
e9a17e4480 | |
|
|
68809365df | |
|
|
8da511dfa8 | |
|
|
69381f6aea | |
|
|
df6508530f | |
|
|
335356280c | |
|
|
03d84f49c2 | |
|
|
2cbdf04ec9 | |
|
|
410fbd8a00 | |
|
|
e5cbecf17c | |
|
|
ca3af5dc6a | |
|
|
9e740d9947 | |
|
|
d4694d058c | |
|
|
469c3a4204 | |
|
|
4cb29967f6 | |
|
|
e718db624f | |
|
|
15b27e0d18 | |
|
|
c523aac586 | |
|
|
51fcd04a70 | |
|
|
4d7cbdcbef | |
|
|
59c530cc6c | |
|
|
c2ca1494e5 | |
|
|
4ee426ba54 | |
|
|
510374207d | |
|
|
aedbecedf7 | |
|
|
9c00669927 | |
|
|
b9f6b40e3a | |
|
|
ad06d8f496 | |
|
|
2fc06c5a17 | |
|
|
52877d8765 | |
|
|
8f957b8f90 | |
|
|
0befa1e57e | |
|
|
f015154314 | |
|
|
689d9e14ea | |
|
|
66e8c57ed1 | |
|
|
b698f14e55 | |
|
|
cec1255b36 | |
|
|
88226f3061 | |
|
|
8c53b2b470 | |
|
|
f2d3a4c70f | |
|
|
4b9b86b544 | |
|
|
f54abe58cf | |
|
|
d954026dd8 | |
|
|
4ad8116ce3 | |
|
|
5c7088338c | |
|
|
389daa03df | |
|
|
1cbe7b0854 | |
|
|
050d71bcf9 | |
|
|
ffde837e83 | |
|
|
536abea2e2 | |
|
|
c7a52b6a2d | |
|
|
c4ccb50c37 | |
|
|
5aaf1ddfb7 | |
|
|
f5f07310e0 | |
|
|
c9e9dbeee1 | |
|
|
b88b323049 | |
|
|
6653f868ae | |
|
|
af29d91dca | |
|
|
1a3735b619 | |
|
|
d4ae13f2a0 | |
|
|
f4804dac85 | |
|
|
843f188aaa | |
|
|
05cb3c87ca | |
|
|
270cb0b8b4 | |
|
|
46ba9c8170 | |
|
|
80f91d3fd9 | |
|
|
a564231caf | |
|
|
9457493696 | |
|
|
ff748b82ca | |
|
|
9fafa57562 | |
|
|
f8475649da | |
|
|
b94e110a4c | |
|
|
f0bba10b12 | |
|
|
d961981e25 | |
|
|
5576662200 | |
|
|
4a2a046d79 | |
|
|
8f8c74cfb8 | |
|
|
092f654f63 | |
|
|
96b1d8f639 | |
|
|
dcb17c6a67 | |
|
|
dd68b85f58 | |
|
|
84df96eaef | |
|
|
d9dd33aeeb | |
|
|
0a281c7390 | |
|
|
3016efba47 | |
|
|
3998df8112 | |
|
|
7066e2a25e | |
|
|
c173988aaa | |
|
|
268855dc5a | |
|
|
bfb736e94a | |
|
|
df8464f89c | |
|
|
3ea387f364 | |
|
|
9d3c42c8c4 | |
|
|
f2cad046e6 | |
|
|
d722026a8d | |
|
|
42a5af6c8f | |
|
|
f0542fae7a | |
|
|
02c75821a8 | |
|
|
3ba9ab2c0a | |
|
|
184732fc20 | |
|
|
b66222baf7 | |
|
|
dce7eceb28 | |
|
|
0e077f7483 | |
|
|
776e7a9c15 | |
|
|
c455d41876 | |
|
|
a776a3ee12 | |
|
|
64fb9233bf | |
|
|
3533f33ecb | |
|
|
1cb7df7159 | |
|
|
a4f8d66a9b | |
|
|
12efbbfa4c | |
|
|
13402529ce | |
|
|
fc678ef36c | |
|
|
03cd891ea9 | |
|
|
6314d741e7 | |
|
|
c45467964c | |
|
|
2eeba53b07 | |
|
|
d4107d51f1 | |
|
|
d8fbe0af01 | |
|
|
b76ead3fe8 | |
|
|
51835ecf90 | |
|
|
328c6de80d | |
|
|
c9c0318e0e | |
|
|
d481f64bde | |
|
|
54e7baa6cf | |
|
|
1d7fcd40b4 | |
|
|
db7bafe917 | |
|
|
b1ef501207 | |
|
|
9fb12a906e | |
|
|
fafbc29316 | |
|
|
7b0def4b81 | |
|
|
1d9c83b576 | |
|
|
2c825c3223 | |
|
|
2a4dedc210 | |
|
|
b0bca6342e | |
|
|
547eb7676d | |
|
|
83f083ee0d | |
|
|
090f636354 | |
|
|
d26c6f80e1 | |
|
|
16a6d6feba | |
|
|
f1c3a44190 | |
|
|
34fa5de9c5 | |
|
|
cb67465675 | |
|
|
4e73473119 | |
|
|
cc18fa599c | |
|
|
aa81c1c4cb | |
|
|
8569fc1f0e | |
|
|
78de287bcc | |
|
|
bbc7052c7a | |
|
|
502d6db6d0 | |
|
|
0b0ad5de99 | |
|
|
9e6c4a01aa | |
|
|
4a81df190c | |
|
|
75cae81f75 | |
|
|
ed3bb3ea8f | |
|
|
fac23a1afc | |
|
|
f89696509e | |
|
|
604ab1bde1 | |
|
|
fbd9b7cf4f | |
|
|
58f45ae22b | |
|
|
440405dbdd | |
|
|
a1cda29012 | |
|
|
f96e2d4222 | |
|
|
387ab78bf6 | |
|
|
dbc00aa8e0 | |
|
|
c37f7b9d99 | |
|
|
cf7ca9b2f7 | |
|
|
981c7b9e37 | |
|
|
2aae0d3493 | |
|
|
bcc0d19867 | |
|
|
9c585bb58b | |
|
|
0f6bc8ae71 | |
|
|
7291e28273 | |
|
|
db57fe6193 | |
|
|
802416639b | |
|
|
7ec398d855 | |
|
|
4ab35d2c5c | |
|
|
b4ae030fc2 | |
|
|
0843964eb3 | |
|
|
a1b06d63c9 | |
|
|
1b6820bab5 | |
|
|
89bf199c07 | |
|
|
5acfdd1c5d | |
|
|
556703f8ab | |
|
|
6b9f8fb9b3 | |
|
|
f77e5cf8fb | |
|
|
e6cdc21f2d | |
|
|
1fe8d4d7ad | |
|
|
e44320980d | |
|
|
f5d7fe3072 | |
|
|
835a27cf51 | |
|
|
85afaaa13d | |
|
|
490615169e | |
|
|
bb232247d0 | |
|
|
94c128f73b | |
|
|
613562f504 | |
|
|
9c4325bcf8 | |
|
|
ad08fd57df | |
|
|
54ba59d3e1 | |
|
|
a4330a225d | |
|
|
69ddc91c35 | |
|
|
4c4aed5a87 | |
|
|
5a40158abf | |
|
|
4dce485854 | |
|
|
5ec5d1dace | |
|
|
d2c765e2b3 | |
|
|
d036c57d59 | |
|
|
e7493e2204 | |
|
|
3500bf64b8 | |
|
|
4f982ddb94 | |
|
|
ff3bb7424d | |
|
|
89d6f60d25 | |
|
|
ee18c9976e | |
|
|
794532928f | |
|
|
7b773c65ec | |
|
|
e53aa79dc6 | |
|
|
d9a97249c0 | |
|
|
86cef16940 | |
|
|
ce38997c76 | |
|
|
7e10c728d4 | |
|
|
3627c67cf2 | |
|
|
2518fd81f6 | |
|
|
39ef7fc90e | |
|
|
37ae0a4051 | |
|
|
b312928e9f | |
|
|
2f2856e20a | |
|
|
831eb6881b | |
|
|
f20ee2fad2 | |
|
|
8b9710e56c | |
|
|
c6262f9d40 | |
|
|
b749fa90f2 | |
|
|
8a51cbd253 | |
|
|
399b8f0701 | |
|
|
3742e42fdf | |
|
|
0388ec6862 | |
|
|
366b8a8034 | |
|
|
ef9bc4ec9e | |
|
|
5838b58913 | |
|
|
2712244ad3 | |
|
|
6388cbaf92 | |
|
|
5cc61e1b53 | |
|
|
0243be86a7 | |
|
|
9154cd64e7 | |
|
|
c71d1bde5e | |
|
|
f27ef595f6 | |
|
|
34328828ae | |
|
|
18fb19da3b | |
|
|
849e1ac543 | |
|
|
656a8d8f55 | |
|
|
b976f339e8 | |
|
|
7d7837e5b6 | |
|
|
1db292f4df | |
|
|
49a3a9fe36 | |
|
|
e51ed460a6 | |
|
|
d15c2ce349 | |
|
|
5cc4bb4089 | |
|
|
6e9e027886 | |
|
|
f9a3d129a4 | |
|
|
c53d1d3ad8 | |
|
|
f386137fba | |
|
|
c797b60069 | |
|
|
a139e9297d | |
|
|
050f99ec54 | |
|
|
23ed652901 | |
|
|
13a68f3de3 | |
|
|
fdad35aaa7 | |
|
|
a2ce4eb650 | |
|
|
8fa04986cf | |
|
|
a5710ed3e1 | |
|
|
2efdc9df93 | |
|
|
0c245886fe | |
|
|
f03288b411 | |
|
|
09388c98f3 | |
|
|
ae25c1e7b7 | |
|
|
0813c14cc6 | |
|
|
b5151c421f | |
|
|
e66fd079db |
|
|
@ -10,6 +10,10 @@
|
||||||
adamdotdevin
|
adamdotdevin
|
||||||
-agusbasari29 AI PR slop
|
-agusbasari29 AI PR slop
|
||||||
ariane-emory
|
ariane-emory
|
||||||
|
-atharvau AI review spamming literally every PR
|
||||||
|
-borealbytes
|
||||||
|
-danieljoshuanazareth
|
||||||
|
-danieljoshuanazareth
|
||||||
edemaine
|
edemaine
|
||||||
-florianleibert
|
-florianleibert
|
||||||
fwang
|
fwang
|
||||||
|
|
@ -17,7 +21,11 @@ iamdavidhill
|
||||||
jayair
|
jayair
|
||||||
kitlangton
|
kitlangton
|
||||||
kommander
|
kommander
|
||||||
|
-opencode2026
|
||||||
|
-opencodeengineer bot that spams issues
|
||||||
r44vc0rp
|
r44vc0rp
|
||||||
rekram1-node
|
rekram1-node
|
||||||
|
-robinmordasiewicz
|
||||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||||
thdxr
|
thdxr
|
||||||
|
-toastythebot
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,6 @@ description: "Setup Bun with caching and install dependencies"
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
- name: Cache Bun dependencies
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.bun/install/cache
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
- name: Get baseline download URL
|
- name: Get baseline download URL
|
||||||
id: bun-url
|
id: bun-url
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
@ -31,6 +23,31 @@ runs:
|
||||||
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
|
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
|
||||||
bun-download-url: ${{ steps.bun-url.outputs.url }}
|
bun-download-url: ${{ steps.bun-url.outputs.url }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Get cache directory
|
||||||
run: bun install
|
id: cache
|
||||||
|
shell: bash
|
||||||
|
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Cache Bun dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ steps.cache.outputs.dir }}
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
|
- name: Install setuptools for distutils compatibility
|
||||||
|
run: python3 -m pip install setuptools || pip install setuptools || true
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
# Workaround for patched peer variants
|
||||||
|
# e.g. ./patches/ for standard-openapi
|
||||||
|
# https://github.com/oven-sh/bun/issues/28147
|
||||||
|
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||||
|
bun install --linker hoisted
|
||||||
|
else
|
||||||
|
bun install
|
||||||
|
fi
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
name: close-issues
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 2 * * *" # Daily at 2:00 AM
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
close:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Close stale issues
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
run: bun script/github/close-issues.ts
|
||||||
|
|
@ -11,7 +11,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
sync-locales:
|
sync-locales:
|
||||||
if: github.actor != 'opencode-agent[bot]'
|
if: false
|
||||||
|
#if: github.actor != 'opencode-agent[bot]'
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
@ -34,7 +35,7 @@ jobs:
|
||||||
- name: Compute changed English docs
|
- name: Compute changed English docs
|
||||||
id: changes
|
id: changes
|
||||||
run: |
|
run: |
|
||||||
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'packages/web/src/content/docs/*.mdx' || true)
|
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- ':(glob)packages/web/src/content/docs/*.mdx' || true)
|
||||||
if [ -z "$FILES" ]; then
|
if [ -z "$FILES" ]; then
|
||||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||||
echo "No English docs changed in push range"
|
echo "No English docs changed in push range"
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ on:
|
||||||
- "patches/**"
|
- "patches/**"
|
||||||
- ".github/workflows/nix-hashes.yml"
|
- ".github/workflows/nix-hashes.yml"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Native runners required: bun install cross-compilation flags (--os/--cpu)
|
# Native runners required: bun install cross-compilation flags (--os/--cpu)
|
||||||
# do not produce byte-identical node_modules as native installs.
|
# do not produce byte-identical node_modules as native installs.
|
||||||
|
|
@ -56,7 +60,7 @@ jobs:
|
||||||
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
|
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
|
||||||
|
|
||||||
# Extract hash from build log with portability
|
# Extract hash from build log with portability
|
||||||
HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
|
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
|
||||||
|
|
||||||
if [ -z "$HASH" ]; then
|
if [ -z "$HASH" ]; then
|
||||||
echo "::error::Failed to compute hash for ${SYSTEM}"
|
echo "::error::Failed to compute hash for ${SYSTEM}"
|
||||||
|
|
|
||||||
|
|
@ -98,15 +98,129 @@ jobs:
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: opencode-cli
|
name: opencode-cli
|
||||||
path: packages/opencode/dist
|
path: |
|
||||||
|
packages/opencode/dist/opencode-darwin*
|
||||||
|
packages/opencode/dist/opencode-linux*
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: opencode-cli-windows
|
||||||
|
path: packages/opencode/dist/opencode-windows*
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ needs.version.outputs.version }}
|
version: ${{ needs.version.outputs.version }}
|
||||||
|
|
||||||
|
sign-cli-windows:
|
||||||
|
needs:
|
||||||
|
- build-cli
|
||||||
|
- version
|
||||||
|
runs-on: blacksmith-4vcpu-windows-2025
|
||||||
|
if: github.repository == 'anomalyco/opencode'
|
||||||
|
env:
|
||||||
|
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
|
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||||
|
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||||
|
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||||
|
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
|
||||||
|
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: opencode-cli-windows
|
||||||
|
path: packages/opencode/dist
|
||||||
|
|
||||||
|
- name: Setup git committer
|
||||||
|
id: committer
|
||||||
|
uses: ./.github/actions/setup-git-committer
|
||||||
|
with:
|
||||||
|
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||||
|
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||||
|
|
||||||
|
- name: Azure login
|
||||||
|
uses: azure/login@v2
|
||||||
|
with:
|
||||||
|
client-id: ${{ env.AZURE_CLIENT_ID }}
|
||||||
|
tenant-id: ${{ env.AZURE_TENANT_ID }}
|
||||||
|
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
|
||||||
|
|
||||||
|
- uses: azure/artifact-signing-action@v1
|
||||||
|
with:
|
||||||
|
endpoint: ${{ env.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||||
|
signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||||
|
certificate-profile-name: ${{ env.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
|
||||||
|
files: |
|
||||||
|
${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe
|
||||||
|
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe
|
||||||
|
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe
|
||||||
|
exclude-environment-credential: true
|
||||||
|
exclude-workload-identity-credential: true
|
||||||
|
exclude-managed-identity-credential: true
|
||||||
|
exclude-shared-token-cache-credential: true
|
||||||
|
exclude-visual-studio-credential: true
|
||||||
|
exclude-visual-studio-code-credential: true
|
||||||
|
exclude-azure-cli-credential: false
|
||||||
|
exclude-azure-powershell-credential: true
|
||||||
|
exclude-azure-developer-cli-credential: true
|
||||||
|
exclude-interactive-browser-credential: true
|
||||||
|
|
||||||
|
- name: Verify Windows CLI signatures
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$files = @(
|
||||||
|
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe",
|
||||||
|
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe",
|
||||||
|
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe"
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($file in $files) {
|
||||||
|
$sig = Get-AuthenticodeSignature $file
|
||||||
|
if ($sig.Status -ne "Valid") {
|
||||||
|
throw "Invalid signature for ${file}: $($sig.Status)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Repack Windows CLI archives
|
||||||
|
working-directory: packages/opencode/dist
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
Compress-Archive -Path "opencode-windows-arm64\bin\*" -DestinationPath "opencode-windows-arm64.zip" -Force
|
||||||
|
Compress-Archive -Path "opencode-windows-x64\bin\*" -DestinationPath "opencode-windows-x64.zip" -Force
|
||||||
|
Compress-Archive -Path "opencode-windows-x64-baseline\bin\*" -DestinationPath "opencode-windows-x64-baseline.zip" -Force
|
||||||
|
|
||||||
|
- name: Upload signed Windows CLI release assets
|
||||||
|
if: needs.version.outputs.release != ''
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
|
run: |
|
||||||
|
gh release upload "v${{ needs.version.outputs.version }}" `
|
||||||
|
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64.zip" `
|
||||||
|
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64.zip" `
|
||||||
|
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline.zip" `
|
||||||
|
--clobber `
|
||||||
|
--repo "${{ needs.version.outputs.repo }}"
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: opencode-cli-signed-windows
|
||||||
|
path: |
|
||||||
|
packages/opencode/dist/opencode-windows-arm64
|
||||||
|
packages/opencode/dist/opencode-windows-x64
|
||||||
|
packages/opencode/dist/opencode-windows-x64-baseline
|
||||||
|
|
||||||
build-tauri:
|
build-tauri:
|
||||||
needs:
|
needs:
|
||||||
- build-cli
|
- build-cli
|
||||||
- version
|
- version
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
|
env:
|
||||||
|
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
|
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||||
|
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||||
|
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||||
|
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
|
||||||
|
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
|
@ -115,6 +229,9 @@ jobs:
|
||||||
target: x86_64-apple-darwin
|
target: x86_64-apple-darwin
|
||||||
- host: macos-latest
|
- host: macos-latest
|
||||||
target: aarch64-apple-darwin
|
target: aarch64-apple-darwin
|
||||||
|
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
|
||||||
|
- host: windows-2025
|
||||||
|
target: aarch64-pc-windows-msvc
|
||||||
- host: blacksmith-4vcpu-windows-2025
|
- host: blacksmith-4vcpu-windows-2025
|
||||||
target: x86_64-pc-windows-msvc
|
target: x86_64-pc-windows-msvc
|
||||||
- host: blacksmith-4vcpu-ubuntu-2404
|
- host: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
|
@ -149,6 +266,18 @@ jobs:
|
||||||
|
|
||||||
- uses: ./.github/actions/setup-bun
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Azure login
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
uses: azure/login@v2
|
||||||
|
with:
|
||||||
|
client-id: ${{ env.AZURE_CLIENT_ID }}
|
||||||
|
tenant-id: ${{ env.AZURE_TENANT_ID }}
|
||||||
|
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
|
||||||
- name: Cache apt packages
|
- name: Cache apt packages
|
||||||
if: contains(matrix.settings.host, 'ubuntu')
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
|
|
@ -183,6 +312,7 @@ jobs:
|
||||||
env:
|
env:
|
||||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
|
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
|
||||||
RUST_TARGET: ${{ matrix.settings.target }}
|
RUST_TARGET: ${{ matrix.settings.target }}
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||||
|
|
@ -239,11 +369,34 @@ jobs:
|
||||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||||
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
||||||
|
|
||||||
|
- name: Verify signed Windows desktop artifacts
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$files = @(
|
||||||
|
"${{ github.workspace }}\packages\desktop\src-tauri\sidecars\opencode-cli-${{ matrix.settings.target }}.exe"
|
||||||
|
)
|
||||||
|
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop\src-tauri\target\${{ matrix.settings.target }}\release\bundle\nsis\*.exe" | Select-Object -ExpandProperty FullName
|
||||||
|
|
||||||
|
foreach ($file in $files) {
|
||||||
|
$sig = Get-AuthenticodeSignature $file
|
||||||
|
if ($sig.Status -ne "Valid") {
|
||||||
|
throw "Invalid signature for ${file}: $($sig.Status)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
build-electron:
|
build-electron:
|
||||||
needs:
|
needs:
|
||||||
- build-cli
|
- build-cli
|
||||||
- version
|
- version
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
|
env:
|
||||||
|
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
|
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||||
|
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||||
|
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||||
|
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
|
||||||
|
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
|
@ -254,6 +407,10 @@ jobs:
|
||||||
- host: macos-latest
|
- host: macos-latest
|
||||||
target: aarch64-apple-darwin
|
target: aarch64-apple-darwin
|
||||||
platform_flag: --mac --arm64
|
platform_flag: --mac --arm64
|
||||||
|
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
|
||||||
|
- host: "windows-2025"
|
||||||
|
target: aarch64-pc-windows-msvc
|
||||||
|
platform_flag: --win --arm64
|
||||||
- host: "blacksmith-4vcpu-windows-2025"
|
- host: "blacksmith-4vcpu-windows-2025"
|
||||||
target: x86_64-pc-windows-msvc
|
target: x86_64-pc-windows-msvc
|
||||||
platform_flag: --win
|
platform_flag: --win
|
||||||
|
|
@ -281,6 +438,14 @@ jobs:
|
||||||
|
|
||||||
- uses: ./.github/actions/setup-bun
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Azure login
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
uses: azure/login@v2
|
||||||
|
with:
|
||||||
|
client-id: ${{ env.AZURE_CLIENT_ID }}
|
||||||
|
tenant-id: ${{ env.AZURE_TENANT_ID }}
|
||||||
|
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "24"
|
node-version: "24"
|
||||||
|
|
@ -315,6 +480,7 @@ jobs:
|
||||||
env:
|
env:
|
||||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
|
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
|
||||||
RUST_TARGET: ${{ matrix.settings.target }}
|
RUST_TARGET: ${{ matrix.settings.target }}
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||||
|
|
@ -347,6 +513,22 @@ jobs:
|
||||||
env:
|
env:
|
||||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
|
|
||||||
|
- name: Verify signed Windows Electron artifacts
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$files = @()
|
||||||
|
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*.exe" | Select-Object -ExpandProperty FullName
|
||||||
|
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\*.exe" | Select-Object -ExpandProperty FullName
|
||||||
|
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\resources\opencode-cli.exe" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName
|
||||||
|
|
||||||
|
foreach ($file in $files | Select-Object -Unique) {
|
||||||
|
$sig = Get-AuthenticodeSignature $file
|
||||||
|
if ($sig.Status -ne "Valid") {
|
||||||
|
throw "Invalid signature for ${file}: $($sig.Status)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: opencode-electron-${{ matrix.settings.target }}
|
name: opencode-electron-${{ matrix.settings.target }}
|
||||||
|
|
@ -362,6 +544,7 @@ jobs:
|
||||||
needs:
|
needs:
|
||||||
- version
|
- version
|
||||||
- build-cli
|
- build-cli
|
||||||
|
- sign-cli-windows
|
||||||
- build-tauri
|
- build-tauri
|
||||||
- build-electron
|
- build-electron
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
|
@ -400,6 +583,16 @@ jobs:
|
||||||
name: opencode-cli
|
name: opencode-cli
|
||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: opencode-cli-windows
|
||||||
|
path: packages/opencode/dist
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: opencode-cli-signed-windows
|
||||||
|
path: packages/opencode/dist
|
||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v4
|
||||||
if: needs.version.outputs.release
|
if: needs.version.outputs.release
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
name: sign-cli
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- brendan/desktop-signpath
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
actions: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
sign-cli:
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
||||||
if: github.repository == 'anomalyco/opencode'
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
with:
|
|
||||||
fetch-tags: true
|
|
||||||
|
|
||||||
- uses: ./.github/actions/setup-bun
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: |
|
|
||||||
./packages/opencode/script/build.ts
|
|
||||||
|
|
||||||
- name: Upload unsigned Windows CLI
|
|
||||||
id: upload_unsigned_windows_cli
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: unsigned-opencode-windows-cli
|
|
||||||
path: packages/opencode/dist/opencode-windows-x64/bin/opencode.exe
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
- name: Submit SignPath signing request
|
|
||||||
id: submit_signpath_signing_request
|
|
||||||
uses: signpath/github-action-submit-signing-request@v1
|
|
||||||
with:
|
|
||||||
api-token: ${{ secrets.SIGNPATH_API_KEY }}
|
|
||||||
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
|
|
||||||
project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
|
|
||||||
signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
|
|
||||||
artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
|
|
||||||
github-artifact-id: ${{ steps.upload_unsigned_windows_cli.outputs.artifact-id }}
|
|
||||||
wait-for-completion: true
|
|
||||||
output-artifact-directory: signed-opencode-cli
|
|
||||||
|
|
||||||
- name: Upload signed Windows CLI
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: signed-opencode-windows-cli
|
|
||||||
path: signed-opencode-cli/*.exe
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
name: stale-issues
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "30 1 * * *" # Daily at 1:30 AM
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
DAYS_BEFORE_STALE: 90
|
|
||||||
DAYS_BEFORE_CLOSE: 7
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
stale:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/stale@v10
|
|
||||||
with:
|
|
||||||
days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
|
|
||||||
days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
|
|
||||||
stale-issue-label: "stale"
|
|
||||||
close-issue-message: |
|
|
||||||
[automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity.
|
|
||||||
|
|
||||||
Feel free to reopen if you still need this!
|
|
||||||
stale-issue-message: |
|
|
||||||
[automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days.
|
|
||||||
|
|
||||||
It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity.
|
|
||||||
remove-stale-when-updated: true
|
|
||||||
exempt-issue-labels: "pinned,security,feature-request,on-hold"
|
|
||||||
start-date: "2025-12-27"
|
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
name: storybook
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/storybook.yml"
|
||||||
|
- "package.json"
|
||||||
|
- "bun.lock"
|
||||||
|
- "packages/storybook/**"
|
||||||
|
- "packages/ui/**"
|
||||||
|
pull_request:
|
||||||
|
branches: [dev]
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/storybook.yml"
|
||||||
|
- "package.json"
|
||||||
|
- "bun.lock"
|
||||||
|
- "packages/storybook/**"
|
||||||
|
- "packages/ui/**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: storybook build
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Build Storybook
|
||||||
|
run: bun --cwd packages/storybook build
|
||||||
|
|
@ -6,6 +6,17 @@ on:
|
||||||
- dev
|
- dev
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
# Keep every run on dev so cancelled checks do not pollute the default branch
|
||||||
|
# commit history. PRs and other branches still share a group and cancel stale runs.
|
||||||
|
group: ${{ case(github.ref == 'refs/heads/dev', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
checks: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
unit:
|
unit:
|
||||||
name: unit (${{ matrix.settings.name }})
|
name: unit (${{ matrix.settings.name }})
|
||||||
|
|
@ -35,25 +46,53 @@ jobs:
|
||||||
git config --global user.email "bot@opencode.ai"
|
git config --global user.email "bot@opencode.ai"
|
||||||
git config --global user.name "opencode"
|
git config --global user.name "opencode"
|
||||||
|
|
||||||
|
- name: Cache Turbo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules/.cache/turbo
|
||||||
|
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-
|
||||||
|
turbo-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: bun turbo test
|
run: bun turbo test:ci
|
||||||
|
env:
|
||||||
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||||
|
|
||||||
|
- name: Publish unit reports
|
||||||
|
if: always()
|
||||||
|
uses: mikepenz/action-junit-report@v6
|
||||||
|
with:
|
||||||
|
report_paths: packages/*/.artifacts/unit/junit.xml
|
||||||
|
check_name: "unit results (${{ matrix.settings.name }})"
|
||||||
|
detailed_summary: true
|
||||||
|
include_time_in_summary: true
|
||||||
|
fail_on_failure: false
|
||||||
|
|
||||||
|
- name: Upload unit artifacts
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
||||||
|
include-hidden-files: true
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
|
path: packages/*/.artifacts/unit/junit.xml
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: e2e (${{ matrix.settings.name }})
|
name: e2e (${{ matrix.settings.name }})
|
||||||
needs: unit
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
settings:
|
settings:
|
||||||
- name: linux
|
- name: linux
|
||||||
host: blacksmith-4vcpu-ubuntu-2404
|
host: blacksmith-4vcpu-ubuntu-2404
|
||||||
playwright: bunx playwright install --with-deps
|
|
||||||
- name: windows
|
- name: windows
|
||||||
host: blacksmith-4vcpu-windows-2025
|
host: blacksmith-4vcpu-windows-2025
|
||||||
playwright: bunx playwright install
|
|
||||||
runs-on: ${{ matrix.settings.host }}
|
runs-on: ${{ matrix.settings.host }}
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_BROWSERS_PATH: 0
|
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
@ -66,38 +105,44 @@ jobs:
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: ./.github/actions/setup-bun
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Read Playwright version
|
||||||
|
id: playwright-version
|
||||||
|
run: |
|
||||||
|
version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Cache Playwright browsers
|
||||||
|
id: playwright-cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ github.workspace }}/.playwright-browsers
|
||||||
|
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
|
||||||
|
|
||||||
|
- name: Install Playwright system dependencies
|
||||||
|
if: runner.os == 'Linux'
|
||||||
working-directory: packages/app
|
working-directory: packages/app
|
||||||
run: ${{ matrix.settings.playwright }}
|
run: bunx playwright install-deps chromium
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
|
working-directory: packages/app
|
||||||
|
run: bunx playwright install chromium
|
||||||
|
|
||||||
- name: Run app e2e tests
|
- name: Run app e2e tests
|
||||||
run: bun --cwd packages/app test:e2e:local
|
run: bun --cwd packages/app test:e2e:local
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
|
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|
||||||
- name: Upload Playwright artifacts
|
- name: Upload Playwright artifacts
|
||||||
if: failure()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
path: |
|
path: |
|
||||||
|
packages/app/e2e/junit-*.xml
|
||||||
packages/app/e2e/test-results
|
packages/app/e2e/test-results
|
||||||
packages/app/e2e/playwright-report
|
packages/app/e2e/playwright-report
|
||||||
|
|
||||||
required:
|
|
||||||
name: test (linux)
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
||||||
needs:
|
|
||||||
- unit
|
|
||||||
- e2e
|
|
||||||
if: always()
|
|
||||||
steps:
|
|
||||||
- name: Verify upstream test jobs passed
|
|
||||||
run: |
|
|
||||||
echo "unit=${{ needs.unit.result }}"
|
|
||||||
echo "e2e=${{ needs.e2e.result }}"
|
|
||||||
test "${{ needs.unit.result }}" = "success"
|
|
||||||
test "${{ needs.e2e.result }}" = "success"
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,6 @@ jobs:
|
||||||
with:
|
with:
|
||||||
issue-id: ${{ github.event.issue.number }}
|
issue-id: ${{ github.event.issue.number }}
|
||||||
comment-id: ${{ github.event.comment.id }}
|
comment-id: ${{ github.event.comment.id }}
|
||||||
roles: admin,maintain
|
roles: admin,maintain,write
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ ts-dist
|
||||||
/result
|
/result
|
||||||
refs
|
refs
|
||||||
Session.vim
|
Session.vim
|
||||||
opencode.json
|
/opencode.json
|
||||||
a.out
|
a.out
|
||||||
target
|
target
|
||||||
.scripts
|
.scripts
|
||||||
|
|
@ -25,6 +25,7 @@ target
|
||||||
|
|
||||||
# Local dev files
|
# Local dev files
|
||||||
opencode-dev
|
opencode-dev
|
||||||
|
UPCOMING_CHANGELOG.md
|
||||||
logs/
|
logs/
|
||||||
*.bun-build
|
*.bun-build
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
plans/
|
node_modules
|
||||||
bun.lock
|
plans
|
||||||
package.json
|
package.json
|
||||||
|
bun.lock
|
||||||
|
.gitignore
|
||||||
|
package-lock.json
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
---
|
|
||||||
description: ALWAYS use this when writing docs
|
|
||||||
color: "#38A3EE"
|
|
||||||
---
|
|
||||||
|
|
||||||
You are an expert technical documentation writer
|
|
||||||
|
|
||||||
You are not verbose
|
|
||||||
|
|
||||||
Use a relaxed and friendly tone
|
|
||||||
|
|
||||||
The title of the page should be a word or a 2-3 word phrase
|
|
||||||
|
|
||||||
The description should be one short line, should not start with "The", should
|
|
||||||
avoid repeating the title of the page, should be 5-10 words long
|
|
||||||
|
|
||||||
Chunks of text should not be more than 2 sentences long
|
|
||||||
|
|
||||||
Each section is separated by a divider of 3 dashes
|
|
||||||
|
|
||||||
The section titles are short with only the first letter of the word capitalized
|
|
||||||
|
|
||||||
The section titles are in the imperative mood
|
|
||||||
|
|
||||||
The section titles should not repeat the term used in the page title, for
|
|
||||||
example, if the page title is "Models", avoid using a section title like "Add
|
|
||||||
new models". This might be unavoidable in some cases, but try to avoid it.
|
|
||||||
|
|
||||||
Check out the /packages/web/src/content/docs/docs/index.mdx as an example.
|
|
||||||
|
|
||||||
For JS or TS code snippets remove trailing semicolons and any trailing commas
|
|
||||||
that might not be needed.
|
|
||||||
|
|
||||||
If you are making a commit prefix the commit message with `docs:`
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
---
|
---
|
||||||
description: Translate content for a specified locale while preserving technical terms
|
description: Translate content for a specified locale while preserving technical terms
|
||||||
mode: subagent
|
mode: subagent
|
||||||
model: opencode/gemini-3-pro
|
model: opencode/gpt-5.4
|
||||||
---
|
---
|
||||||
|
|
||||||
You are a professional translator and localization specialist.
|
You are a professional translator and localization specialist.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
---
|
||||||
|
model: opencode/gpt-5.4
|
||||||
|
---
|
||||||
|
|
||||||
|
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.
|
||||||
|
If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely.
|
||||||
|
Do not preserve, merge, or reuse text from the existing file.
|
||||||
|
|
||||||
|
The input already contains the exact commit range since the last non-draft release.
|
||||||
|
The commits are already filtered to the release-relevant packages and grouped into
|
||||||
|
the release sections. Do not fetch GitHub releases, PRs, or build your own commit list.
|
||||||
|
The input may also include a `## Community Contributors Input` section.
|
||||||
|
|
||||||
|
Before writing any entry you keep, inspect the real diff with
|
||||||
|
`git show --stat --format='' <hash>` or `git show --format='' <hash>` so you can
|
||||||
|
understand the actual code changes and not just the commit message (they may be misleading).
|
||||||
|
Do not use `git log` or author metadata when deciding attribution.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Write the final file with sections in this order:
|
||||||
|
`## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions`
|
||||||
|
- Only include sections that have at least one notable entry
|
||||||
|
- Keep one bullet per commit you keep
|
||||||
|
- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing
|
||||||
|
- Start each bullet with a capital letter
|
||||||
|
- Prefer what changed for users over what code changed internally
|
||||||
|
- Do not copy raw commit prefixes like `fix:` or `feat:` or trailing PR numbers like `(#123)`
|
||||||
|
- Community attribution is deterministic: only preserve an existing `(@username)` suffix from the changelog input
|
||||||
|
- If an input bullet has no `(@username)` suffix, do not add one
|
||||||
|
- Never add a new `(@username)` suffix from `git show`, commit authors, names, or email addresses
|
||||||
|
- If no notable entries remain and there is no contributor block, write exactly `No notable changes.`
|
||||||
|
- If no notable entries remain but there is a contributor block, omit all release sections and return only the contributor block
|
||||||
|
- If the input contains `## Community Contributors Input`, append the block below that heading to the end of the final file verbatim
|
||||||
|
- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block
|
||||||
|
- Do not derive the thank-you section from the main summary bullets
|
||||||
|
- Do not include the heading `## Community Contributors Input` in the final file
|
||||||
|
- Focus on writing the least words to get your point across - users will skim read the changelog, so we should be precise
|
||||||
|
|
||||||
|
**Importantly, the changelog is for users (who are at least slightly technical), they may use the TUI, Desktop, SDK, Plugins and so forth. Be thorough in understanding flow on effects may not be immediately apparent. e.g. a package upgrade looks internal but may patch a bug. Or a refactor may also stabilise some race condition that fixes bugs for users. The PR title/body + commit message will give you the authors context, usually containing the outcome not just technical detail**
|
||||||
|
|
||||||
|
<changelog_input>
|
||||||
|
|
||||||
|
!`bun script/raw-changelog.ts $ARGUMENTS`
|
||||||
|
|
||||||
|
</changelog_input>
|
||||||
|
|
@ -5,6 +5,11 @@
|
||||||
"options": {},
|
"options": {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"permission": {
|
||||||
|
"edit": {
|
||||||
|
"packages/opencode/migration/*": "deny",
|
||||||
|
},
|
||||||
|
},
|
||||||
"mcp": {},
|
"mcp": {},
|
||||||
"tools": {
|
"tools": {
|
||||||
"github-triage": false,
|
"github-triage": false,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/theme.json",
|
||||||
|
"defs": {
|
||||||
|
"nord0": "#2E3440",
|
||||||
|
"nord1": "#3B4252",
|
||||||
|
"nord2": "#434C5E",
|
||||||
|
"nord3": "#4C566A",
|
||||||
|
"nord4": "#D8DEE9",
|
||||||
|
"nord5": "#E5E9F0",
|
||||||
|
"nord6": "#ECEFF4",
|
||||||
|
"nord7": "#8FBCBB",
|
||||||
|
"nord8": "#88C0D0",
|
||||||
|
"nord9": "#81A1C1",
|
||||||
|
"nord10": "#5E81AC",
|
||||||
|
"nord11": "#BF616A",
|
||||||
|
"nord12": "#D08770",
|
||||||
|
"nord13": "#EBCB8B",
|
||||||
|
"nord14": "#A3BE8C",
|
||||||
|
"nord15": "#B48EAD"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"primary": {
|
||||||
|
"dark": "nord10",
|
||||||
|
"light": "nord9"
|
||||||
|
},
|
||||||
|
"secondary": {
|
||||||
|
"dark": "nord9",
|
||||||
|
"light": "nord9"
|
||||||
|
},
|
||||||
|
"accent": {
|
||||||
|
"dark": "nord7",
|
||||||
|
"light": "nord7"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"dark": "nord11",
|
||||||
|
"light": "nord11"
|
||||||
|
},
|
||||||
|
"warning": {
|
||||||
|
"dark": "nord12",
|
||||||
|
"light": "nord12"
|
||||||
|
},
|
||||||
|
"success": {
|
||||||
|
"dark": "nord14",
|
||||||
|
"light": "nord14"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"dark": "nord8",
|
||||||
|
"light": "nord10"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"dark": "nord6",
|
||||||
|
"light": "nord0"
|
||||||
|
},
|
||||||
|
"textMuted": {
|
||||||
|
"dark": "#8B95A7",
|
||||||
|
"light": "nord1"
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"dark": "nord0",
|
||||||
|
"light": "nord6"
|
||||||
|
},
|
||||||
|
"backgroundPanel": {
|
||||||
|
"dark": "nord1",
|
||||||
|
"light": "nord5"
|
||||||
|
},
|
||||||
|
"backgroundElement": {
|
||||||
|
"dark": "nord2",
|
||||||
|
"light": "nord4"
|
||||||
|
},
|
||||||
|
"border": {
|
||||||
|
"dark": "nord2",
|
||||||
|
"light": "nord3"
|
||||||
|
},
|
||||||
|
"borderActive": {
|
||||||
|
"dark": "nord3",
|
||||||
|
"light": "nord2"
|
||||||
|
},
|
||||||
|
"borderSubtle": {
|
||||||
|
"dark": "nord2",
|
||||||
|
"light": "nord3"
|
||||||
|
},
|
||||||
|
"diffAdded": {
|
||||||
|
"dark": "nord14",
|
||||||
|
"light": "nord14"
|
||||||
|
},
|
||||||
|
"diffRemoved": {
|
||||||
|
"dark": "nord11",
|
||||||
|
"light": "nord11"
|
||||||
|
},
|
||||||
|
"diffContext": {
|
||||||
|
"dark": "#8B95A7",
|
||||||
|
"light": "nord3"
|
||||||
|
},
|
||||||
|
"diffHunkHeader": {
|
||||||
|
"dark": "#8B95A7",
|
||||||
|
"light": "nord3"
|
||||||
|
},
|
||||||
|
"diffHighlightAdded": {
|
||||||
|
"dark": "nord14",
|
||||||
|
"light": "nord14"
|
||||||
|
},
|
||||||
|
"diffHighlightRemoved": {
|
||||||
|
"dark": "nord11",
|
||||||
|
"light": "nord11"
|
||||||
|
},
|
||||||
|
"diffAddedBg": {
|
||||||
|
"dark": "#36413C",
|
||||||
|
"light": "#E6EBE7"
|
||||||
|
},
|
||||||
|
"diffRemovedBg": {
|
||||||
|
"dark": "#43393D",
|
||||||
|
"light": "#ECE6E8"
|
||||||
|
},
|
||||||
|
"diffContextBg": {
|
||||||
|
"dark": "nord1",
|
||||||
|
"light": "nord5"
|
||||||
|
},
|
||||||
|
"diffLineNumber": {
|
||||||
|
"dark": "nord2",
|
||||||
|
"light": "nord4"
|
||||||
|
},
|
||||||
|
"diffAddedLineNumberBg": {
|
||||||
|
"dark": "#303A35",
|
||||||
|
"light": "#DDE4DF"
|
||||||
|
},
|
||||||
|
"diffRemovedLineNumberBg": {
|
||||||
|
"dark": "#3C3336",
|
||||||
|
"light": "#E4DDE0"
|
||||||
|
},
|
||||||
|
"markdownText": {
|
||||||
|
"dark": "nord4",
|
||||||
|
"light": "nord0"
|
||||||
|
},
|
||||||
|
"markdownHeading": {
|
||||||
|
"dark": "nord8",
|
||||||
|
"light": "nord10"
|
||||||
|
},
|
||||||
|
"markdownLink": {
|
||||||
|
"dark": "nord9",
|
||||||
|
"light": "nord9"
|
||||||
|
},
|
||||||
|
"markdownLinkText": {
|
||||||
|
"dark": "nord7",
|
||||||
|
"light": "nord7"
|
||||||
|
},
|
||||||
|
"markdownCode": {
|
||||||
|
"dark": "nord14",
|
||||||
|
"light": "nord14"
|
||||||
|
},
|
||||||
|
"markdownBlockQuote": {
|
||||||
|
"dark": "#8B95A7",
|
||||||
|
"light": "nord3"
|
||||||
|
},
|
||||||
|
"markdownEmph": {
|
||||||
|
"dark": "nord12",
|
||||||
|
"light": "nord12"
|
||||||
|
},
|
||||||
|
"markdownStrong": {
|
||||||
|
"dark": "nord13",
|
||||||
|
"light": "nord13"
|
||||||
|
},
|
||||||
|
"markdownHorizontalRule": {
|
||||||
|
"dark": "#8B95A7",
|
||||||
|
"light": "nord3"
|
||||||
|
},
|
||||||
|
"markdownListItem": {
|
||||||
|
"dark": "nord8",
|
||||||
|
"light": "nord10"
|
||||||
|
},
|
||||||
|
"markdownListEnumeration": {
|
||||||
|
"dark": "nord7",
|
||||||
|
"light": "nord7"
|
||||||
|
},
|
||||||
|
"markdownImage": {
|
||||||
|
"dark": "nord9",
|
||||||
|
"light": "nord9"
|
||||||
|
},
|
||||||
|
"markdownImageText": {
|
||||||
|
"dark": "nord7",
|
||||||
|
"light": "nord7"
|
||||||
|
},
|
||||||
|
"markdownCodeBlock": {
|
||||||
|
"dark": "nord4",
|
||||||
|
"light": "nord0"
|
||||||
|
},
|
||||||
|
"syntaxComment": {
|
||||||
|
"dark": "#8B95A7",
|
||||||
|
"light": "nord3"
|
||||||
|
},
|
||||||
|
"syntaxKeyword": {
|
||||||
|
"dark": "nord9",
|
||||||
|
"light": "nord9"
|
||||||
|
},
|
||||||
|
"syntaxFunction": {
|
||||||
|
"dark": "nord8",
|
||||||
|
"light": "nord8"
|
||||||
|
},
|
||||||
|
"syntaxVariable": {
|
||||||
|
"dark": "nord7",
|
||||||
|
"light": "nord7"
|
||||||
|
},
|
||||||
|
"syntaxString": {
|
||||||
|
"dark": "nord14",
|
||||||
|
"light": "nord14"
|
||||||
|
},
|
||||||
|
"syntaxNumber": {
|
||||||
|
"dark": "nord15",
|
||||||
|
"light": "nord15"
|
||||||
|
},
|
||||||
|
"syntaxType": {
|
||||||
|
"dark": "nord7",
|
||||||
|
"light": "nord7"
|
||||||
|
},
|
||||||
|
"syntaxOperator": {
|
||||||
|
"dark": "nord9",
|
||||||
|
"light": "nord9"
|
||||||
|
},
|
||||||
|
"syntaxPunctuation": {
|
||||||
|
"dark": "nord4",
|
||||||
|
"light": "nord0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,937 @@
|
||||||
|
/** @jsxImportSource @opentui/solid */
|
||||||
|
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||||
|
import { RGBA, VignetteEffect } from "@opentui/core"
|
||||||
|
import type {
|
||||||
|
TuiKeybindSet,
|
||||||
|
TuiPlugin,
|
||||||
|
TuiPluginApi,
|
||||||
|
TuiPluginMeta,
|
||||||
|
TuiPluginModule,
|
||||||
|
TuiSlotPlugin,
|
||||||
|
} from "@opencode-ai/plugin/tui"
|
||||||
|
|
||||||
|
const tabs = ["overview", "counter", "help"]
|
||||||
|
const bind = {
|
||||||
|
modal: "ctrl+shift+m",
|
||||||
|
screen: "ctrl+shift+o",
|
||||||
|
home: "escape,ctrl+h",
|
||||||
|
left: "left,h",
|
||||||
|
right: "right,l",
|
||||||
|
up: "up,k",
|
||||||
|
down: "down,j",
|
||||||
|
alert: "a",
|
||||||
|
confirm: "c",
|
||||||
|
prompt: "p",
|
||||||
|
select: "s",
|
||||||
|
modal_accept: "enter,return",
|
||||||
|
modal_close: "escape",
|
||||||
|
dialog_close: "escape",
|
||||||
|
local: "x",
|
||||||
|
local_push: "enter,return",
|
||||||
|
local_close: "q,backspace",
|
||||||
|
host: "z",
|
||||||
|
}
|
||||||
|
|
||||||
|
const pick = (value: unknown, fallback: string) => {
|
||||||
|
if (typeof value !== "string") return fallback
|
||||||
|
if (!value.trim()) return fallback
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = (value: unknown, fallback: number) => {
|
||||||
|
if (typeof value !== "number") return fallback
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const rec = (value: unknown) => {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return
|
||||||
|
return Object.fromEntries(Object.entries(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cfg = {
|
||||||
|
label: string
|
||||||
|
route: string
|
||||||
|
vignette: number
|
||||||
|
keybinds: Record<string, unknown> | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
type Route = {
|
||||||
|
modal: string
|
||||||
|
screen: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
tab: number
|
||||||
|
count: number
|
||||||
|
source: string
|
||||||
|
note: string
|
||||||
|
selected: string
|
||||||
|
local: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = (options: Record<string, unknown> | undefined) => {
|
||||||
|
return {
|
||||||
|
label: pick(options?.label, "smoke"),
|
||||||
|
route: pick(options?.route, "workspace-smoke"),
|
||||||
|
vignette: Math.max(0, num(options?.vignette, 0.35)),
|
||||||
|
keybinds: rec(options?.keybinds),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = (input: Cfg) => {
|
||||||
|
return {
|
||||||
|
modal: `${input.route}.modal`,
|
||||||
|
screen: `${input.route}.screen`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Keys = TuiKeybindSet
|
||||||
|
const ui = {
|
||||||
|
panel: "#1d1d1d",
|
||||||
|
border: "#4a4a4a",
|
||||||
|
text: "#f0f0f0",
|
||||||
|
muted: "#a5a5a5",
|
||||||
|
accent: "#5f87ff",
|
||||||
|
}
|
||||||
|
|
||||||
|
type Color = RGBA | string
|
||||||
|
|
||||||
|
const ink = (map: Record<string, unknown>, name: string, fallback: string): Color => {
|
||||||
|
const value = map[name]
|
||||||
|
if (typeof value === "string") return value
|
||||||
|
if (value instanceof RGBA) return value
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const look = (map: Record<string, unknown>) => {
|
||||||
|
return {
|
||||||
|
panel: ink(map, "backgroundPanel", ui.panel),
|
||||||
|
border: ink(map, "border", ui.border),
|
||||||
|
text: ink(map, "text", ui.text),
|
||||||
|
muted: ink(map, "textMuted", ui.muted),
|
||||||
|
accent: ink(map, "primary", ui.accent),
|
||||||
|
selected: ink(map, "selectedListItemText", ui.text),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tone = (api: TuiPluginApi) => {
|
||||||
|
return look(api.theme.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Skin = {
|
||||||
|
panel: Color
|
||||||
|
border: Color
|
||||||
|
text: Color
|
||||||
|
muted: Color
|
||||||
|
accent: Color
|
||||||
|
selected: Color
|
||||||
|
}
|
||||||
|
|
||||||
|
const Btn = (props: { txt: string; run: () => void; skin: Skin; on?: boolean }) => {
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
onMouseUp={() => {
|
||||||
|
props.run()
|
||||||
|
}}
|
||||||
|
backgroundColor={props.on ? props.skin.accent : props.skin.border}
|
||||||
|
paddingLeft={1}
|
||||||
|
paddingRight={1}
|
||||||
|
>
|
||||||
|
<text fg={props.on ? props.skin.selected : props.skin.text}>{props.txt}</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parse = (params: Record<string, unknown> | undefined) => {
|
||||||
|
const tab = typeof params?.tab === "number" ? params.tab : 0
|
||||||
|
const count = typeof params?.count === "number" ? params.count : 0
|
||||||
|
const source = typeof params?.source === "string" ? params.source : "unknown"
|
||||||
|
const note = typeof params?.note === "string" ? params.note : ""
|
||||||
|
const selected = typeof params?.selected === "string" ? params.selected : ""
|
||||||
|
const local = typeof params?.local === "number" ? params.local : 0
|
||||||
|
return {
|
||||||
|
tab: Math.max(0, Math.min(tab, tabs.length - 1)),
|
||||||
|
count,
|
||||||
|
source,
|
||||||
|
note,
|
||||||
|
selected,
|
||||||
|
local: Math.max(0, local),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = (api: TuiPluginApi, route: Route) => {
|
||||||
|
const value = api.route.current
|
||||||
|
const ok = Object.values(route).includes(value.name)
|
||||||
|
if (!ok) return parse(undefined)
|
||||||
|
if (!("params" in value)) return parse(undefined)
|
||||||
|
return parse(value.params)
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = [
|
||||||
|
{
|
||||||
|
title: "Overview",
|
||||||
|
value: 0,
|
||||||
|
description: "Switch to overview tab",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Counter",
|
||||||
|
value: 1,
|
||||||
|
description: "Switch to counter tab",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Help",
|
||||||
|
value: 2,
|
||||||
|
description: "Switch to help tab",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const host = (api: TuiPluginApi, input: Cfg, skin: Skin) => {
|
||||||
|
api.ui.dialog.setSize("medium")
|
||||||
|
api.ui.dialog.replace(() => (
|
||||||
|
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
|
||||||
|
<text fg={skin.text}>
|
||||||
|
<b>{input.label} host overlay</b>
|
||||||
|
</text>
|
||||||
|
<text fg={skin.muted}>Using api.ui.dialog stack with built-in backdrop</text>
|
||||||
|
<text fg={skin.muted}>esc closes · depth {api.ui.dialog.depth}</text>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<Btn txt="close" run={() => api.ui.dialog.clear()} skin={skin} on />
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const warn = (api: TuiPluginApi, route: Route, value: State) => {
|
||||||
|
const DialogAlert = api.ui.DialogAlert
|
||||||
|
api.ui.dialog.setSize("medium")
|
||||||
|
api.ui.dialog.replace(() => (
|
||||||
|
<DialogAlert
|
||||||
|
title="Smoke alert"
|
||||||
|
message="Testing built-in alert dialog"
|
||||||
|
onConfirm={() => api.route.navigate(route.screen, { ...value, source: "alert" })}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const check = (api: TuiPluginApi, route: Route, value: State) => {
|
||||||
|
const DialogConfirm = api.ui.DialogConfirm
|
||||||
|
api.ui.dialog.setSize("medium")
|
||||||
|
api.ui.dialog.replace(() => (
|
||||||
|
<DialogConfirm
|
||||||
|
title="Smoke confirm"
|
||||||
|
message="Apply +1 to counter?"
|
||||||
|
onConfirm={() => api.route.navigate(route.screen, { ...value, count: value.count + 1, source: "confirm" })}
|
||||||
|
onCancel={() => api.route.navigate(route.screen, { ...value, source: "confirm-cancel" })}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = (api: TuiPluginApi, route: Route, value: State) => {
|
||||||
|
const DialogPrompt = api.ui.DialogPrompt
|
||||||
|
api.ui.dialog.setSize("medium")
|
||||||
|
api.ui.dialog.replace(() => (
|
||||||
|
<DialogPrompt
|
||||||
|
title="Smoke prompt"
|
||||||
|
value={value.note}
|
||||||
|
onConfirm={(note) => {
|
||||||
|
api.ui.dialog.clear()
|
||||||
|
api.route.navigate(route.screen, { ...value, note, source: "prompt" })
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
api.ui.dialog.clear()
|
||||||
|
api.route.navigate(route.screen, value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const picker = (api: TuiPluginApi, route: Route, value: State) => {
|
||||||
|
const DialogSelect = api.ui.DialogSelect
|
||||||
|
api.ui.dialog.setSize("medium")
|
||||||
|
api.ui.dialog.replace(() => (
|
||||||
|
<DialogSelect
|
||||||
|
title="Smoke select"
|
||||||
|
options={opts}
|
||||||
|
current={value.tab}
|
||||||
|
onSelect={(item) => {
|
||||||
|
api.ui.dialog.clear()
|
||||||
|
api.route.navigate(route.screen, {
|
||||||
|
...value,
|
||||||
|
tab: typeof item.value === "number" ? item.value : value.tab,
|
||||||
|
selected: item.title,
|
||||||
|
source: "select",
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const Screen = (props: {
|
||||||
|
api: TuiPluginApi
|
||||||
|
input: Cfg
|
||||||
|
route: Route
|
||||||
|
keys: Keys
|
||||||
|
meta: TuiPluginMeta
|
||||||
|
params?: Record<string, unknown>
|
||||||
|
}) => {
|
||||||
|
const dim = useTerminalDimensions()
|
||||||
|
const value = parse(props.params)
|
||||||
|
const skin = tone(props.api)
|
||||||
|
const set = (local: number, base?: State) => {
|
||||||
|
const next = base ?? current(props.api, props.route)
|
||||||
|
props.api.route.navigate(props.route.screen, { ...next, local: Math.max(0, local), source: "local" })
|
||||||
|
}
|
||||||
|
const push = (base?: State) => {
|
||||||
|
const next = base ?? current(props.api, props.route)
|
||||||
|
set(next.local + 1, next)
|
||||||
|
}
|
||||||
|
const open = () => {
|
||||||
|
const next = current(props.api, props.route)
|
||||||
|
if (next.local > 0) return
|
||||||
|
set(1, next)
|
||||||
|
}
|
||||||
|
const pop = (base?: State) => {
|
||||||
|
const next = base ?? current(props.api, props.route)
|
||||||
|
const local = Math.max(0, next.local - 1)
|
||||||
|
set(local, next)
|
||||||
|
}
|
||||||
|
const show = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
open()
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
useKeyboard((evt) => {
|
||||||
|
if (props.api.route.current.name !== props.route.screen) return
|
||||||
|
const next = current(props.api, props.route)
|
||||||
|
if (props.api.ui.dialog.open) {
|
||||||
|
if (props.keys.match("dialog_close", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
props.api.ui.dialog.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next.local > 0) {
|
||||||
|
if (evt.name === "escape" || props.keys.match("local_close", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
pop(next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("local_push", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
push(next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("home", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
props.api.route.navigate("home")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("left", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("right", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("up", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("down", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("modal", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
props.api.route.navigate(props.route.modal, next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("local", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
open()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("host", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
host(props.api, props.input, skin)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("alert", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
warn(props.api, props.route, next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("confirm", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
check(props.api, props.route, next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("prompt", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
entry(props.api, props.route, next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("select", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
picker(props.api, props.route, next)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box width={dim().width} height={dim().height} backgroundColor={skin.panel} position="relative">
|
||||||
|
<box
|
||||||
|
flexDirection="column"
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
paddingTop={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={2}
|
||||||
|
>
|
||||||
|
<box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
|
||||||
|
<text fg={skin.text}>
|
||||||
|
<b>{props.input.label} screen</b>
|
||||||
|
<span style={{ fg: skin.muted }}> plugin route</span>
|
||||||
|
</text>
|
||||||
|
<text fg={skin.muted}>{props.keys.print("home")} home</text>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box flexDirection="row" gap={1} paddingBottom={1}>
|
||||||
|
{tabs.map((item, i) => {
|
||||||
|
const on = value.tab === i
|
||||||
|
return (
|
||||||
|
<Btn
|
||||||
|
txt={item}
|
||||||
|
run={() => props.api.route.navigate(props.route.screen, { ...value, tab: i })}
|
||||||
|
skin={skin}
|
||||||
|
on={on}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
borderColor={skin.border}
|
||||||
|
paddingTop={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={2}
|
||||||
|
flexGrow={1}
|
||||||
|
>
|
||||||
|
{value.tab === 0 ? (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg={skin.text}>Route: {props.route.screen}</text>
|
||||||
|
<text fg={skin.muted}>plugin state: {props.meta.state}</text>
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
first: {props.meta.state === "first" ? "yes" : "no"} · updated:{" "}
|
||||||
|
{props.meta.state === "updated" ? "yes" : "no"} · loads: {props.meta.load_count}
|
||||||
|
</text>
|
||||||
|
<text fg={skin.muted}>plugin source: {props.meta.source}</text>
|
||||||
|
<text fg={skin.muted}>source: {value.source}</text>
|
||||||
|
<text fg={skin.muted}>note: {value.note || "(none)"}</text>
|
||||||
|
<text fg={skin.muted}>selected: {value.selected || "(none)"}</text>
|
||||||
|
<text fg={skin.muted}>local stack depth: {value.local}</text>
|
||||||
|
<text fg={skin.muted}>host stack open: {props.api.ui.dialog.open ? "yes" : "no"}</text>
|
||||||
|
</box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{value.tab === 1 ? (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg={skin.text}>Counter: {value.count}</text>
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
{props.keys.print("up")} / {props.keys.print("down")} change value
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{value.tab === 2 ? (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
{props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "}
|
||||||
|
confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select
|
||||||
|
</text>
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
{props.keys.print("local")} local stack | {props.keys.print("host")} host stack
|
||||||
|
</text>
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "}
|
||||||
|
close
|
||||||
|
</text>
|
||||||
|
<text fg={skin.muted}>{props.keys.print("home")} returns home</text>
|
||||||
|
</box>
|
||||||
|
) : null}
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box flexDirection="row" gap={1} paddingTop={1}>
|
||||||
|
<Btn txt="go home" run={() => props.api.route.navigate("home")} skin={skin} />
|
||||||
|
<Btn txt="modal" run={() => props.api.route.navigate(props.route.modal, value)} skin={skin} on />
|
||||||
|
<Btn txt="local overlay" run={show} skin={skin} />
|
||||||
|
<Btn txt="host overlay" run={() => host(props.api, props.input, skin)} skin={skin} />
|
||||||
|
<Btn txt="alert" run={() => warn(props.api, props.route, value)} skin={skin} />
|
||||||
|
<Btn txt="confirm" run={() => check(props.api, props.route, value)} skin={skin} />
|
||||||
|
<Btn txt="prompt" run={() => entry(props.api, props.route, value)} skin={skin} />
|
||||||
|
<Btn txt="select" run={() => picker(props.api, props.route, value)} skin={skin} />
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
|
||||||
|
<box
|
||||||
|
visible={value.local > 0}
|
||||||
|
width={dim().width}
|
||||||
|
height={dim().height}
|
||||||
|
alignItems="center"
|
||||||
|
position="absolute"
|
||||||
|
zIndex={3000}
|
||||||
|
paddingTop={dim().height / 4}
|
||||||
|
left={0}
|
||||||
|
top={0}
|
||||||
|
backgroundColor={RGBA.fromInts(0, 0, 0, 160)}
|
||||||
|
onMouseUp={() => {
|
||||||
|
pop()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<box
|
||||||
|
onMouseUp={(evt) => {
|
||||||
|
evt.stopPropagation()
|
||||||
|
}}
|
||||||
|
width={60}
|
||||||
|
maxWidth={dim().width - 2}
|
||||||
|
backgroundColor={skin.panel}
|
||||||
|
border
|
||||||
|
borderColor={skin.border}
|
||||||
|
paddingTop={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={2}
|
||||||
|
gap={1}
|
||||||
|
flexDirection="column"
|
||||||
|
>
|
||||||
|
<text fg={skin.text}>
|
||||||
|
<b>{props.input.label} local overlay</b>
|
||||||
|
</text>
|
||||||
|
<text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
{props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close
|
||||||
|
</text>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<Btn txt="push" run={push} skin={skin} on />
|
||||||
|
<Btn txt="pop" run={pop} skin={skin} />
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Modal = (props: {
|
||||||
|
api: TuiPluginApi
|
||||||
|
input: Cfg
|
||||||
|
route: Route
|
||||||
|
keys: Keys
|
||||||
|
params?: Record<string, unknown>
|
||||||
|
}) => {
|
||||||
|
const Dialog = props.api.ui.Dialog
|
||||||
|
const value = parse(props.params)
|
||||||
|
const skin = tone(props.api)
|
||||||
|
|
||||||
|
useKeyboard((evt) => {
|
||||||
|
if (props.api.route.current.name !== props.route.modal) return
|
||||||
|
|
||||||
|
if (props.keys.match("modal_accept", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.keys.match("modal_close", evt)) {
|
||||||
|
evt.preventDefault()
|
||||||
|
evt.stopPropagation()
|
||||||
|
props.api.route.navigate("home")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box width="100%" height="100%" backgroundColor={skin.panel}>
|
||||||
|
<Dialog onClose={() => props.api.route.navigate("home")}>
|
||||||
|
<box paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
|
||||||
|
<text fg={skin.text}>
|
||||||
|
<b>{props.input.label} modal</b>
|
||||||
|
</text>
|
||||||
|
<text fg={skin.muted}>{props.keys.print("modal")} modal command</text>
|
||||||
|
<text fg={skin.muted}>{props.keys.print("screen")} screen command</text>
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
{props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
|
||||||
|
</text>
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<Btn
|
||||||
|
txt="open screen"
|
||||||
|
run={() => props.api.route.navigate(props.route.screen, { ...value, source: "modal" })}
|
||||||
|
skin={skin}
|
||||||
|
on
|
||||||
|
/>
|
||||||
|
<Btn txt="cancel" run={() => props.api.route.navigate("home")} skin={skin} />
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
</Dialog>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const home = (api: TuiPluginApi, input: Cfg) => ({
|
||||||
|
slots: {
|
||||||
|
home_logo(ctx) {
|
||||||
|
const map = ctx.theme.current
|
||||||
|
const skin = look(map)
|
||||||
|
const art = [
|
||||||
|
" $$\\",
|
||||||
|
" $$ |",
|
||||||
|
" $$$$$$$\\ $$$$$$\\$$$$\\ $$$$$$\\ $$ | $$\\ $$$$$$\\",
|
||||||
|
"$$ _____|$$ _$$ _$$\\ $$ __$$\\ $$ | $$ |$$ __$$\\",
|
||||||
|
"\\$$$$$$\\ $$ / $$ / $$ |$$ / $$ |$$$$$$ / $$$$$$$$ |",
|
||||||
|
" \\____$$\\ $$ | $$ | $$ |$$ | $$ |$$ _$$< $$ ____|",
|
||||||
|
"$$$$$$$ |$$ | $$ | $$ |\\$$$$$$ |$$ | \\$$\\ \\$$$$$$$\\",
|
||||||
|
"\\_______/ \\__| \\__| \\__| \\______/ \\__| \\__| \\_______|",
|
||||||
|
]
|
||||||
|
const fill = [
|
||||||
|
skin.accent,
|
||||||
|
skin.muted,
|
||||||
|
ink(map, "info", ui.accent),
|
||||||
|
skin.text,
|
||||||
|
ink(map, "success", ui.accent),
|
||||||
|
ink(map, "warning", ui.accent),
|
||||||
|
ink(map, "secondary", ui.accent),
|
||||||
|
ink(map, "error", ui.accent),
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box flexDirection="column">
|
||||||
|
{art.map((line, i) => (
|
||||||
|
<text fg={fill[i]}>{line}</text>
|
||||||
|
))}
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
home_prompt(ctx, value) {
|
||||||
|
const skin = look(ctx.theme.current)
|
||||||
|
type Prompt = (props: {
|
||||||
|
workspaceID?: string
|
||||||
|
visible?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
onSubmit?: () => void
|
||||||
|
hint?: JSX.Element
|
||||||
|
right?: JSX.Element
|
||||||
|
showPlaceholder?: boolean
|
||||||
|
placeholders?: {
|
||||||
|
normal?: string[]
|
||||||
|
shell?: string[]
|
||||||
|
}
|
||||||
|
}) => JSX.Element
|
||||||
|
type Slot = (
|
||||||
|
props: { name: string; mode?: unknown; children?: JSX.Element } & Record<string, unknown>,
|
||||||
|
) => JSX.Element | null
|
||||||
|
const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot }
|
||||||
|
const Prompt = ui.Prompt
|
||||||
|
const Slot = ui.Slot
|
||||||
|
const normal = [
|
||||||
|
`[SMOKE] route check for ${input.label}`,
|
||||||
|
"[SMOKE] confirm home_prompt slot override",
|
||||||
|
"[SMOKE] verify prompt-right slot passthrough",
|
||||||
|
]
|
||||||
|
const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"]
|
||||||
|
const hint = (
|
||||||
|
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
<span style={{ fg: skin.accent }}>•</span> smoke home prompt
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Prompt
|
||||||
|
workspaceID={value.workspace_id}
|
||||||
|
hint={hint}
|
||||||
|
right={
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<Slot name="home_prompt_right" workspace_id={value.workspace_id} />
|
||||||
|
<Slot name="smoke_prompt_right" workspace_id={value.workspace_id} label={input.label} />
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
placeholders={{ normal, shell }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
home_prompt_right(ctx, value) {
|
||||||
|
const skin = look(ctx.theme.current)
|
||||||
|
const id = value.workspace_id?.slice(0, 8) ?? "none"
|
||||||
|
return (
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
<span style={{ fg: skin.accent }}>{input.label}</span> home:{id}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
session_prompt_right(ctx, value) {
|
||||||
|
const skin = look(ctx.theme.current)
|
||||||
|
return (
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
<span style={{ fg: skin.accent }}>{input.label}</span> session:{value.session_id.slice(0, 8)}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
smoke_prompt_right(ctx, value) {
|
||||||
|
const skin = look(ctx.theme.current)
|
||||||
|
const id = typeof value.workspace_id === "string" ? value.workspace_id.slice(0, 8) : "none"
|
||||||
|
const label = typeof value.label === "string" ? value.label : input.label
|
||||||
|
return (
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
<span style={{ fg: skin.accent }}>{label}</span> custom:{id}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
home_bottom(ctx) {
|
||||||
|
const skin = look(ctx.theme.current)
|
||||||
|
const text = "extra content in the unified home bottom slot"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box width="100%" maxWidth={75} alignItems="center" paddingTop={1} flexShrink={0} gap={1}>
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
borderColor={skin.border}
|
||||||
|
backgroundColor={skin.panel}
|
||||||
|
paddingTop={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={2}
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
<span style={{ fg: skin.accent }}>{input.label}</span> {text}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const block = (input: Cfg, order: number, title: string, text: string): TuiSlotPlugin => ({
|
||||||
|
order,
|
||||||
|
slots: {
|
||||||
|
sidebar_content(ctx, value) {
|
||||||
|
const skin = look(ctx.theme.current)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box
|
||||||
|
border
|
||||||
|
borderColor={skin.border}
|
||||||
|
backgroundColor={skin.panel}
|
||||||
|
paddingTop={1}
|
||||||
|
paddingBottom={1}
|
||||||
|
paddingLeft={2}
|
||||||
|
paddingRight={2}
|
||||||
|
flexDirection="column"
|
||||||
|
gap={1}
|
||||||
|
>
|
||||||
|
<text fg={skin.accent}>
|
||||||
|
<b>{title}</b>
|
||||||
|
</text>
|
||||||
|
<text fg={skin.text}>{text}</text>
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
{input.label} order {order} · session {value.session_id.slice(0, 8)}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const slot = (api: TuiPluginApi, input: Cfg): TuiSlotPlugin[] => [
|
||||||
|
home(api, input),
|
||||||
|
block(input, 50, "Smoke above", "renders above internal sidebar blocks"),
|
||||||
|
block(input, 250, "Smoke between", "renders between internal sidebar blocks"),
|
||||||
|
block(input, 650, "Smoke below", "renders below internal sidebar blocks"),
|
||||||
|
]
|
||||||
|
|
||||||
|
const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
|
||||||
|
const route = names(input)
|
||||||
|
api.command.register(() => [
|
||||||
|
{
|
||||||
|
title: `${input.label} modal`,
|
||||||
|
value: "plugin.smoke.modal",
|
||||||
|
keybind: keys.get("modal"),
|
||||||
|
category: "Plugin",
|
||||||
|
slash: {
|
||||||
|
name: "smoke",
|
||||||
|
},
|
||||||
|
onSelect: () => {
|
||||||
|
api.route.navigate(route.modal, { source: "command" })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${input.label} screen`,
|
||||||
|
value: "plugin.smoke.screen",
|
||||||
|
keybind: keys.get("screen"),
|
||||||
|
category: "Plugin",
|
||||||
|
slash: {
|
||||||
|
name: "smoke-screen",
|
||||||
|
},
|
||||||
|
onSelect: () => {
|
||||||
|
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${input.label} alert dialog`,
|
||||||
|
value: "plugin.smoke.alert",
|
||||||
|
category: "Plugin",
|
||||||
|
slash: {
|
||||||
|
name: "smoke-alert",
|
||||||
|
},
|
||||||
|
onSelect: () => {
|
||||||
|
warn(api, route, current(api, route))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${input.label} confirm dialog`,
|
||||||
|
value: "plugin.smoke.confirm",
|
||||||
|
category: "Plugin",
|
||||||
|
slash: {
|
||||||
|
name: "smoke-confirm",
|
||||||
|
},
|
||||||
|
onSelect: () => {
|
||||||
|
check(api, route, current(api, route))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${input.label} prompt dialog`,
|
||||||
|
value: "plugin.smoke.prompt",
|
||||||
|
category: "Plugin",
|
||||||
|
slash: {
|
||||||
|
name: "smoke-prompt",
|
||||||
|
},
|
||||||
|
onSelect: () => {
|
||||||
|
entry(api, route, current(api, route))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${input.label} select dialog`,
|
||||||
|
value: "plugin.smoke.select",
|
||||||
|
category: "Plugin",
|
||||||
|
slash: {
|
||||||
|
name: "smoke-select",
|
||||||
|
},
|
||||||
|
onSelect: () => {
|
||||||
|
picker(api, route, current(api, route))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${input.label} host overlay`,
|
||||||
|
value: "plugin.smoke.host",
|
||||||
|
category: "Plugin",
|
||||||
|
slash: {
|
||||||
|
name: "smoke-host",
|
||||||
|
},
|
||||||
|
onSelect: () => {
|
||||||
|
host(api, input, tone(api))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${input.label} go home`,
|
||||||
|
value: "plugin.smoke.home",
|
||||||
|
category: "Plugin",
|
||||||
|
enabled: api.route.current.name !== "home",
|
||||||
|
onSelect: () => {
|
||||||
|
api.route.navigate("home")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: `${input.label} toast`,
|
||||||
|
value: "plugin.smoke.toast",
|
||||||
|
category: "Plugin",
|
||||||
|
onSelect: () => {
|
||||||
|
api.ui.toast({
|
||||||
|
variant: "info",
|
||||||
|
title: "Smoke",
|
||||||
|
message: "Plugin toast works",
|
||||||
|
duration: 2000,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const tui: TuiPlugin = async (api, options, meta) => {
|
||||||
|
if (options?.enabled === false) return
|
||||||
|
|
||||||
|
await api.theme.install("./smoke-theme.json")
|
||||||
|
api.theme.set("smoke-theme")
|
||||||
|
|
||||||
|
const value = cfg(options ?? undefined)
|
||||||
|
const route = names(value)
|
||||||
|
const keys = api.keybind.create(bind, value.keybinds)
|
||||||
|
const fx = new VignetteEffect(value.vignette)
|
||||||
|
const post = fx.apply.bind(fx)
|
||||||
|
api.renderer.addPostProcessFn(post)
|
||||||
|
api.lifecycle.onDispose(() => {
|
||||||
|
api.renderer.removePostProcessFn(post)
|
||||||
|
})
|
||||||
|
|
||||||
|
api.route.register([
|
||||||
|
{
|
||||||
|
name: route.screen,
|
||||||
|
render: ({ params }) => <Screen api={api} input={value} route={route} keys={keys} meta={meta} params={params} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: route.modal,
|
||||||
|
render: ({ params }) => <Modal api={api} input={value} route={route} keys={keys} params={params} />,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
reg(api, value, keys)
|
||||||
|
for (const item of slot(api, value)) {
|
||||||
|
api.slots.register(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin: TuiPluginModule & { id: string } = {
|
||||||
|
id: "tui-smoke",
|
||||||
|
tui,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default plugin
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
smoke-theme.json
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
/// <reference path="../env.d.ts" />
|
/// <reference path="../env.d.ts" />
|
||||||
import { tool } from "@opencode-ai/plugin"
|
import { tool } from "@opencode-ai/plugin"
|
||||||
import DESCRIPTION from "./github-pr-search.txt"
|
|
||||||
|
|
||||||
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||||
const response = await fetch(`https://api.github.com${endpoint}`, {
|
const response = await fetch(`https://api.github.com${endpoint}`, {
|
||||||
...options,
|
...options,
|
||||||
|
|
@ -24,7 +22,16 @@ interface PR {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description: DESCRIPTION,
|
description: `Use this tool to search GitHub pull requests by title and description.
|
||||||
|
|
||||||
|
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
|
||||||
|
- PR number and title
|
||||||
|
- Author
|
||||||
|
- State (open/closed/merged)
|
||||||
|
- Labels
|
||||||
|
- Description snippet
|
||||||
|
|
||||||
|
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
|
||||||
args: {
|
args: {
|
||||||
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
|
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
|
||||||
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
|
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
Use this tool to search GitHub pull requests by title and description.
|
|
||||||
|
|
||||||
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
|
|
||||||
- PR number and title
|
|
||||||
- Author
|
|
||||||
- State (open/closed/merged)
|
|
||||||
- Labels
|
|
||||||
- Description snippet
|
|
||||||
|
|
||||||
Use the query parameter to search for keywords that might appear in PR titles or descriptions.
|
|
||||||
|
|
@ -1,20 +1,10 @@
|
||||||
/// <reference path="../env.d.ts" />
|
/// <reference path="../env.d.ts" />
|
||||||
import { tool } from "@opencode-ai/plugin"
|
import { tool } from "@opencode-ai/plugin"
|
||||||
import DESCRIPTION from "./github-triage.txt"
|
|
||||||
|
|
||||||
const TEAM = {
|
const TEAM = {
|
||||||
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
||||||
zen: ["fwang", "MrMushrooooom"],
|
zen: ["fwang", "MrMushrooooom"],
|
||||||
tui: [
|
tui: ["thdxr", "kommander", "rekram1-node"],
|
||||||
"thdxr",
|
core: ["thdxr", "rekram1-node", "jlongster"],
|
||||||
"kommander",
|
|
||||||
// "rekram1-node" (on vacation)
|
|
||||||
],
|
|
||||||
core: [
|
|
||||||
"thdxr",
|
|
||||||
// "rekram1-node", (on vacation)
|
|
||||||
"jlongster",
|
|
||||||
],
|
|
||||||
docs: ["R44VC0RP"],
|
docs: ["R44VC0RP"],
|
||||||
windows: ["Hona"],
|
windows: ["Hona"],
|
||||||
} as const
|
} as const
|
||||||
|
|
@ -48,9 +38,17 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description: DESCRIPTION,
|
description: `Use this tool to assign and/or label a GitHub issue.
|
||||||
|
|
||||||
|
Choose labels and assignee using the current triage policy and ownership rules.
|
||||||
|
Pick the most fitting labels for the issue and assign one owner.
|
||||||
|
|
||||||
|
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
|
||||||
args: {
|
args: {
|
||||||
assignee: tool.schema.enum(ASSIGNEES as [string, ...string[]]).describe("The username of the assignee"),
|
assignee: tool.schema
|
||||||
|
.enum(ASSIGNEES as [string, ...string[]])
|
||||||
|
.describe("The username of the assignee")
|
||||||
|
.default("rekram1-node"),
|
||||||
labels: tool.schema
|
labels: tool.schema
|
||||||
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
|
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
|
||||||
.describe("The labels(s) to add to the issue")
|
.describe("The labels(s) to add to the issue")
|
||||||
|
|
@ -73,8 +71,7 @@ export default tool({
|
||||||
results.push("Dropped label: nix (issue does not mention nix)")
|
results.push("Dropped label: nix (issue does not mention nix)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
|
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
|
||||||
const assignee = web ? pick(TEAM.desktop) : args.assignee
|
|
||||||
|
|
||||||
if (labels.includes("zen") && !zen) {
|
if (labels.includes("zen") && !zen) {
|
||||||
throw new Error("Only add the zen label when issue title/body contains 'zen'")
|
throw new Error("Only add the zen label when issue title/body contains 'zen'")
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
Use this tool to assign and/or label a GitHub issue.
|
|
||||||
|
|
||||||
Choose labels and assignee using the current triage policy and ownership rules.
|
|
||||||
Pick the most fitting labels for the issue and assign one owner.
|
|
||||||
|
|
||||||
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
|
||||||
|
|
||||||
(Note: rekram1-node is on vacation, do not assign issues to him.)
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/tui.json",
|
||||||
|
"plugin": [
|
||||||
|
[
|
||||||
|
"./plugins/tui-smoke.tsx",
|
||||||
|
{
|
||||||
|
"enabled": false,
|
||||||
|
"label": "workspace",
|
||||||
|
"keybinds": {
|
||||||
|
"modal": "ctrl+alt+m",
|
||||||
|
"screen": "ctrl+alt+o",
|
||||||
|
"home": "escape,ctrl+shift+h",
|
||||||
|
"dialog_close": "escape,q"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
github-policies:
|
|
||||||
runners:
|
|
||||||
allowed_groups:
|
|
||||||
- "GitHub Actions"
|
|
||||||
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"
|
|
||||||
|
|
@ -122,3 +122,7 @@ const table = sqliteTable("session", {
|
||||||
- Avoid mocks as much as possible
|
- Avoid mocks as much as possible
|
||||||
- Test actual implementation, do not duplicate logic into tests
|
- Test actual implementation, do not duplicate logic into tests
|
||||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
||||||
|
|
||||||
|
## Type Checking
|
||||||
|
|
||||||
|
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
|
||||||
|
|
|
||||||
|
|
@ -137,4 +137,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**加入我们的社区** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
|
||||||
|
|
|
||||||
|
|
@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**加入我們的社群** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772091128,
|
"lastModified": 1773909469,
|
||||||
"narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=",
|
"narHash": "sha256-vglVrLfHjFIzIdV9A27Ugul6rh3I1qHbbitGW7dk420=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "3f0336406035444b4a24b942788334af5f906259",
|
"rev": "7149c06513f335be57f26fcbbbe34afda923882b",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
|
||||||
|
|
@ -496,7 +496,6 @@ async function subscribeSessionEvents() {
|
||||||
|
|
||||||
const TOOL: Record<string, [string, string]> = {
|
const TOOL: Record<string, [string, string]> = {
|
||||||
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
|
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
|
||||||
todoread: ["Todo", "\x1b[33m\x1b[1m"],
|
|
||||||
bash: ["Bash", "\x1b[31m\x1b[1m"],
|
bash: ["Bash", "\x1b[31m\x1b[1m"],
|
||||||
edit: ["Edit", "\x1b[32m\x1b[1m"],
|
edit: ["Edit", "\x1b[32m\x1b[1m"],
|
||||||
glob: ["Glob", "\x1b[34m\x1b[1m"],
|
glob: ["Glob", "\x1b[34m\x1b[1m"],
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,18 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
|
||||||
const zenLiteProduct = new stripe.Product("ZenLite", {
|
const zenLiteProduct = new stripe.Product("ZenLite", {
|
||||||
name: "OpenCode Go",
|
name: "OpenCode Go",
|
||||||
})
|
})
|
||||||
|
const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50", {
|
||||||
|
name: "First month 50% off",
|
||||||
|
percentOff: 50,
|
||||||
|
appliesToProducts: [zenLiteProduct.id],
|
||||||
|
duration: "once",
|
||||||
|
})
|
||||||
|
const zenLiteCouponFirstMonth100 = new stripe.Coupon("ZenLiteCouponFirstMonth100", {
|
||||||
|
name: "First month 100% off",
|
||||||
|
percentOff: 100,
|
||||||
|
appliesToProducts: [zenLiteProduct.id],
|
||||||
|
duration: "once",
|
||||||
|
})
|
||||||
const zenLitePrice = new stripe.Price("ZenLitePrice", {
|
const zenLitePrice = new stripe.Price("ZenLitePrice", {
|
||||||
product: zenLiteProduct.id,
|
product: zenLiteProduct.id,
|
||||||
currency: "usd",
|
currency: "usd",
|
||||||
|
|
@ -116,6 +128,9 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
|
||||||
properties: {
|
properties: {
|
||||||
product: zenLiteProduct.id,
|
product: zenLiteProduct.id,
|
||||||
price: zenLitePrice.id,
|
price: zenLitePrice.id,
|
||||||
|
priceInr: 92900,
|
||||||
|
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
|
||||||
|
firstMonth100Coupon: zenLiteCouponFirstMonth100.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -194,6 +209,10 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
|
||||||
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
||||||
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
||||||
|
|
||||||
|
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
|
||||||
|
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
|
||||||
|
const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
|
||||||
|
|
||||||
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
||||||
handler: "packages/console/function/src/log-processor.ts",
|
handler: "packages/console/function/src/log-processor.ts",
|
||||||
link: [new sst.Secret("HONEYCOMB_API_KEY")],
|
link: [new sst.Secret("HONEYCOMB_API_KEY")],
|
||||||
|
|
@ -212,8 +231,12 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||||
EMAILOCTOPUS_API_KEY,
|
EMAILOCTOPUS_API_KEY,
|
||||||
AWS_SES_ACCESS_KEY_ID,
|
AWS_SES_ACCESS_KEY_ID,
|
||||||
AWS_SES_SECRET_ACCESS_KEY,
|
AWS_SES_SECRET_ACCESS_KEY,
|
||||||
|
SALESFORCE_CLIENT_ID,
|
||||||
|
SALESFORCE_CLIENT_SECRET,
|
||||||
|
SALESFORCE_INSTANCE_URL,
|
||||||
ZEN_BLACK_PRICE,
|
ZEN_BLACK_PRICE,
|
||||||
ZEN_LITE_PRICE,
|
ZEN_LITE_PRICE,
|
||||||
|
new sst.Secret("ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES"),
|
||||||
new sst.Secret("ZEN_LIMITS"),
|
new sst.Secret("ZEN_LIMITS"),
|
||||||
new sst.Secret("ZEN_SESSION_SECRET"),
|
new sst.Secret("ZEN_SESSION_SECRET"),
|
||||||
...ZEN_MODELS,
|
...ZEN_MODELS,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-4kjoJ06VNvHltPHfzQRBG0bC6R39jao10ffGzrNZ230=",
|
"x86_64-linux": "sha256-85wpU1oCWbthPleNIOj5d5AOuuYZ6rM7gMLZR6YJ2WU=",
|
||||||
"aarch64-linux": "sha256-6Uio+S2rcyBWbBEeOZb9N1CCKgkbKi68lOIKi3Ws/pQ=",
|
"aarch64-linux": "sha256-C3A56SDQGJquCpIRj2JhIzr4A7N4cc9lxtEjl8bXDeM=",
|
||||||
"aarch64-darwin": "sha256-8ngN5KVN4vhdsk0QJ11BGgSVBrcaEbwSj23c77HBpgs=",
|
"aarch64-darwin": "sha256-/Ij3qhGRrcLlMfl9uEacDNnGK5URxhctuQFBW4Njrog=",
|
||||||
"x86_64-darwin": "sha256-v/ueYGb9a0Nymzy+mkO4uQr78DAuJnES1qOT0onFgnQ="
|
"x86_64-darwin": "sha256-10sOPuN4eZ75orw4FI8ztCq1+AKS2e8aAfg3Z6Yn56w="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ let
|
||||||
in
|
in
|
||||||
stdenvNoCC.mkDerivation {
|
stdenvNoCC.mkDerivation {
|
||||||
pname = "opencode-node_modules";
|
pname = "opencode-node_modules";
|
||||||
version = "${packageJson.version}-${rev}";
|
version = "${packageJson.version}+${lib.replaceString "-" "." rev}";
|
||||||
|
|
||||||
src = lib.fileset.toSource {
|
src = lib.fileset.toSource {
|
||||||
root = ../.;
|
root = ../.;
|
||||||
|
|
@ -54,6 +54,7 @@ stdenvNoCC.mkDerivation {
|
||||||
--filter '!./' \
|
--filter '!./' \
|
||||||
--filter './packages/opencode' \
|
--filter './packages/opencode' \
|
||||||
--filter './packages/desktop' \
|
--filter './packages/desktop' \
|
||||||
|
--filter './packages/app' \
|
||||||
--frozen-lockfile \
|
--frozen-lockfile \
|
||||||
--ignore-scripts \
|
--ignore-scripts \
|
||||||
--no-progress
|
--no-progress
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
stdenvNoCC,
|
stdenvNoCC,
|
||||||
callPackage,
|
callPackage,
|
||||||
bun,
|
bun,
|
||||||
|
nodejs,
|
||||||
sysctl,
|
sysctl,
|
||||||
makeBinaryWrapper,
|
makeBinaryWrapper,
|
||||||
models-dev,
|
models-dev,
|
||||||
|
|
@ -19,6 +20,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
bun
|
bun
|
||||||
|
nodejs # for patchShebangs node_modules
|
||||||
installShellFiles
|
installShellFiles
|
||||||
makeBinaryWrapper
|
makeBinaryWrapper
|
||||||
models-dev
|
models-dev
|
||||||
|
|
@ -29,6 +31,8 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||||
runHook preConfigure
|
runHook preConfigure
|
||||||
|
|
||||||
cp -R ${finalAttrs.node_modules}/. .
|
cp -R ${finalAttrs.node_modules}/. .
|
||||||
|
patchShebangs node_modules
|
||||||
|
patchShebangs packages/*/node_modules
|
||||||
|
|
||||||
runHook postConfigure
|
runHook postConfigure
|
||||||
'';
|
'';
|
||||||
|
|
|
||||||
22
package.json
22
package.json
|
|
@ -4,13 +4,15 @@
|
||||||
"description": "AI-powered development tool",
|
"description": "AI-powered development tool",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.10",
|
"packageManager": "bun@1.3.11",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||||
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
||||||
"dev:web": "bun --cwd packages/app dev",
|
"dev:web": "bun --cwd packages/app dev",
|
||||||
|
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||||
"typecheck": "bun turbo typecheck",
|
"typecheck": "bun turbo typecheck",
|
||||||
|
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"random": "echo 'Random script'",
|
"random": "echo 'Random script'",
|
||||||
"hello": "echo 'Hello World!'",
|
"hello": "echo 'Hello World!'",
|
||||||
|
|
@ -24,7 +26,9 @@
|
||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@types/bun": "1.3.9",
|
"@effect/platform-node": "4.0.0-beta.43",
|
||||||
|
"@types/bun": "1.3.11",
|
||||||
|
"@types/cross-spawn": "6.0.6",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
|
|
@ -41,15 +45,18 @@
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||||
"ai": "5.0.124",
|
"effect": "4.0.0-beta.43",
|
||||||
|
"ai": "6.0.149",
|
||||||
|
"cross-spawn": "7.0.6",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"luxon": "3.6.1",
|
"luxon": "3.6.1",
|
||||||
"marked": "17.0.1",
|
"marked": "17.0.1",
|
||||||
"marked-shiki": "1.2.1",
|
"marked-shiki": "1.2.1",
|
||||||
|
"remend": "1.3.0",
|
||||||
"@playwright/test": "1.51.0",
|
"@playwright/test": "1.51.0",
|
||||||
"typescript": "5.8.2",
|
"typescript": "5.8.2",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||||
|
|
@ -84,6 +91,7 @@
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
"heap-snapshot-toolkit": "1.1.3",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|
@ -97,9 +105,11 @@
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"esbuild",
|
"esbuild",
|
||||||
|
"node-pty",
|
||||||
"protobufjs",
|
"protobufjs",
|
||||||
"tree-sitter",
|
"tree-sitter",
|
||||||
"tree-sitter-bash",
|
"tree-sitter-bash",
|
||||||
|
"tree-sitter-powershell",
|
||||||
"web-tree-sitter",
|
"web-tree-sitter",
|
||||||
"electron"
|
"electron"
|
||||||
],
|
],
|
||||||
|
|
@ -109,6 +119,6 @@
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,10 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
||||||
### Using Fixtures
|
### Using Fixtures
|
||||||
|
|
||||||
- `page` - Playwright page
|
- `page` - Playwright page
|
||||||
- `sdk` - OpenCode SDK client for API calls
|
- `llm` - Mock LLM server for queuing responses (`text`, `tool`, `toolMatch`, `textMatch`, etc.)
|
||||||
- `gotoSession(sessionID?)` - Navigate to session
|
- `project` - Golden-path project fixture (call `project.open()` first, then use `project.sdk`, `project.prompt(...)`, `project.gotoSession(...)`, `project.trackSession(...)`)
|
||||||
|
- `sdk` - OpenCode SDK client for API calls (worker-scoped, shared directory)
|
||||||
|
- `gotoSession(sessionID?)` - Navigate to session (worker-scoped, shared directory)
|
||||||
|
|
||||||
### Helper Functions
|
### Helper Functions
|
||||||
|
|
||||||
|
|
@ -70,13 +72,12 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
||||||
- `openSettings(page)` - Open settings dialog
|
- `openSettings(page)` - Open settings dialog
|
||||||
- `closeDialog(page, dialog)` - Close any dialog
|
- `closeDialog(page, dialog)` - Close any dialog
|
||||||
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
||||||
|
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
|
||||||
|
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
|
||||||
- `withSession(sdk, title, callback)` - Create temp session
|
- `withSession(sdk, title, callback)` - Create temp session
|
||||||
- `withProject(...)` - Create temp project/workspace
|
|
||||||
- `sessionIDFromUrl(url)` - Read session ID from URL
|
- `sessionIDFromUrl(url)` - Read session ID from URL
|
||||||
- `slugFromUrl(url)` - Read workspace slug from URL
|
- `slugFromUrl(url)` - Read workspace slug from URL
|
||||||
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
|
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
|
||||||
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
|
|
||||||
- `trackDirectory(directory)` - Register directory for fixture cleanup
|
|
||||||
- `clickListItem(container, filter)` - Click list item by key/text
|
- `clickListItem(container, filter)` - Click list item by key/text
|
||||||
|
|
||||||
**Selectors** (`selectors.ts`):
|
**Selectors** (`selectors.ts`):
|
||||||
|
|
@ -126,9 +127,9 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
- Prefer `withSession(...)` for temp sessions
|
- Prefer the `project` fixture for tests that need a dedicated project with LLM mocking — call `project.open()` then use `project.prompt(...)`, `project.trackSession(...)`, etc.
|
||||||
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
|
- Use `withSession(sdk, title, callback)` for lightweight temp sessions on the shared worker directory
|
||||||
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
|
- Call `project.trackSession(sessionID, directory?)` and `project.trackDirectory(directory)` for any resources created outside the fixture so teardown can clean them up
|
||||||
- Avoid calling `sdk.session.delete(...)` directly
|
- Avoid calling `sdk.session.delete(...)` directly
|
||||||
|
|
||||||
### Timeouts
|
### Timeouts
|
||||||
|
|
@ -167,6 +168,42 @@ await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
|
||||||
await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Terminal Tests
|
||||||
|
|
||||||
|
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
|
||||||
|
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
|
||||||
|
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
|
||||||
|
- After opening the terminal, use `waitTerminalFocusIdle(...)` before the next keyboard action when prompt focus or keyboard routing matters.
|
||||||
|
- This avoids racing terminal mount, focus handoff, and prompt readiness when the next step types or sends shortcuts.
|
||||||
|
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
|
||||||
|
|
||||||
|
### Wait on state
|
||||||
|
|
||||||
|
- Never use wall-clock waits like `page.waitForTimeout(...)` to make a test pass
|
||||||
|
- Avoid race-prone flows that assume work is finished after an action
|
||||||
|
- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
|
||||||
|
- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
|
||||||
|
- Prefer semantic app state over transient DOM visibility when behavior depends on active selection, focus ownership, or async retry loops
|
||||||
|
- Do not treat a visible element as proof that the app will route the next action to it
|
||||||
|
- When fixing a flake, validate with `--repeat-each` and multiple workers when practical
|
||||||
|
|
||||||
|
### Add hooks
|
||||||
|
|
||||||
|
- If required state is not observable from the UI, add a small test-only driver or probe in app code instead of sleeps or fragile DOM checks
|
||||||
|
- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
|
||||||
|
- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
|
||||||
|
- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
|
||||||
|
- Add minimal test-only probes for semantic state like the active list item or selected command when DOM intermediates are unstable
|
||||||
|
- Prefer probing committed app state over asserting on transient highlight, visibility, or animation states
|
||||||
|
|
||||||
|
### Prefer helpers
|
||||||
|
|
||||||
|
- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
|
||||||
|
- Use direct locators when the interaction is simple and a helper would not add clarity
|
||||||
|
- Prefer helpers that both perform an action and verify the app consumed it
|
||||||
|
- Avoid composing helpers redundantly when one already includes the other or already waits for the resulting state
|
||||||
|
- If a helper already covers the required wait or verification, use it directly instead of layering extra clicks, keypresses, or assertions
|
||||||
|
|
||||||
## Writing New Tests
|
## Writing New Tests
|
||||||
|
|
||||||
1. Choose appropriate folder or create new one
|
1. Choose appropriate folder or create new one
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
|
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { expect, type Locator, type Page } from "@playwright/test"
|
import { expect, type Locator, type Page } from "@playwright/test"
|
||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { execSync } from "node:child_process"
|
import { execSync } from "node:child_process"
|
||||||
|
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
|
||||||
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||||
import {
|
import {
|
||||||
dropdownMenuTriggerSelector,
|
|
||||||
dropdownMenuContentSelector,
|
dropdownMenuContentSelector,
|
||||||
sessionTimelineHeaderSelector,
|
projectSwitchSelector,
|
||||||
projectMenuTriggerSelector,
|
projectMenuTriggerSelector,
|
||||||
projectCloseMenuSelector,
|
projectCloseMenuSelector,
|
||||||
projectWorkspacesToggleSelector,
|
projectWorkspacesToggleSelector,
|
||||||
|
|
@ -16,10 +17,22 @@ import {
|
||||||
listItemSelector,
|
listItemSelector,
|
||||||
listItemKeySelector,
|
listItemKeySelector,
|
||||||
listItemKeyStartsWithSelector,
|
listItemKeyStartsWithSelector,
|
||||||
|
promptSelector,
|
||||||
|
terminalSelector,
|
||||||
workspaceItemSelector,
|
workspaceItemSelector,
|
||||||
workspaceMenuTriggerSelector,
|
workspaceMenuTriggerSelector,
|
||||||
} from "./selectors"
|
} from "./selectors"
|
||||||
|
|
||||||
|
const phase = new WeakMap<Page, "test" | "cleanup">()
|
||||||
|
|
||||||
|
export function setHealthPhase(page: Page, value: "test" | "cleanup") {
|
||||||
|
phase.set(page, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function healthPhase(page: Page) {
|
||||||
|
return phase.get(page) ?? "test"
|
||||||
|
}
|
||||||
|
|
||||||
export async function defocus(page: Page) {
|
export async function defocus(page: Page) {
|
||||||
await page
|
await page
|
||||||
.evaluate(() => {
|
.evaluate(() => {
|
||||||
|
|
@ -29,9 +42,141 @@ export async function defocus(page: Page) {
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openPalette(page: Page) {
|
async function terminalID(term: Locator) {
|
||||||
|
const id = await term.getAttribute(terminalAttr)
|
||||||
|
if (id) return id
|
||||||
|
throw new Error(`Active terminal missing ${terminalAttr}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function terminalConnects(page: Page, input?: { term?: Locator }) {
|
||||||
|
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||||
|
const id = await terminalID(term)
|
||||||
|
return page.evaluate((id) => {
|
||||||
|
return (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]?.connects ?? 0
|
||||||
|
}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectTerminal(page: Page, input?: { term?: Locator }) {
|
||||||
|
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||||
|
const id = await terminalID(term)
|
||||||
|
await page.evaluate((id) => {
|
||||||
|
;(window as E2EWindow).__opencode_e2e?.terminal?.controls?.[id]?.disconnect?.()
|
||||||
|
}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminalReady(page: Page, term?: Locator) {
|
||||||
|
const next = term ?? page.locator(terminalSelector).first()
|
||||||
|
const id = await terminalID(next)
|
||||||
|
return page.evaluate((id) => {
|
||||||
|
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
|
||||||
|
return !!state?.connected && (state.settled ?? 0) > 0
|
||||||
|
}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminalFocusIdle(page: Page, term?: Locator) {
|
||||||
|
const next = term ?? page.locator(terminalSelector).first()
|
||||||
|
const id = await terminalID(next)
|
||||||
|
return page.evaluate((id) => {
|
||||||
|
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
|
||||||
|
return (state?.focusing ?? 0) === 0
|
||||||
|
}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
|
||||||
|
const next = input.term ?? page.locator(terminalSelector).first()
|
||||||
|
const id = await terminalID(next)
|
||||||
|
return page.evaluate(
|
||||||
|
(input) => {
|
||||||
|
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[input.id]
|
||||||
|
return state?.rendered.includes(input.token) ?? false
|
||||||
|
},
|
||||||
|
{ id, token: input.token },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptSlashActive(page: Page, id: string) {
|
||||||
|
return page.evaluate((id) => {
|
||||||
|
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
|
||||||
|
if (state?.popover !== "slash") return false
|
||||||
|
if (!state.slash.ids.includes(id)) return false
|
||||||
|
return state.slash.active === id
|
||||||
|
}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptSlashSelects(page: Page) {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
return (window as E2EWindow).__opencode_e2e?.prompt?.current?.selects ?? 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptSlashSelected(page: Page, input: { id: string; count: number }) {
|
||||||
|
return page.evaluate((input) => {
|
||||||
|
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
|
||||||
|
if (!state) return false
|
||||||
|
return state.selected === input.id && state.selects >= input.count
|
||||||
|
}, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
|
||||||
|
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||||
|
const timeout = input?.timeout ?? 10_000
|
||||||
|
await expect(term).toBeVisible()
|
||||||
|
await expect(term.locator("textarea")).toHaveCount(1)
|
||||||
|
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitTerminalFocusIdle(page: Page, input?: { term?: Locator; timeout?: number }) {
|
||||||
|
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||||
|
const timeout = input?.timeout ?? 10_000
|
||||||
|
await waitTerminalReady(page, { term, timeout })
|
||||||
|
await expect.poll(() => terminalFocusIdle(page, term), { timeout }).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function showPromptSlash(
|
||||||
|
page: Page,
|
||||||
|
input: { id: string; text: string; prompt?: Locator; timeout?: number },
|
||||||
|
) {
|
||||||
|
const prompt = input.prompt ?? page.locator(promptSelector)
|
||||||
|
const timeout = input.timeout ?? 10_000
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
await prompt.click().catch(() => false)
|
||||||
|
await prompt.fill(input.text).catch(() => false)
|
||||||
|
return promptSlashActive(page, input.id).catch(() => false)
|
||||||
|
},
|
||||||
|
{ timeout },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runPromptSlash(
|
||||||
|
page: Page,
|
||||||
|
input: { id: string; text: string; prompt?: Locator; timeout?: number },
|
||||||
|
) {
|
||||||
|
const prompt = input.prompt ?? page.locator(promptSelector)
|
||||||
|
const timeout = input.timeout ?? 10_000
|
||||||
|
const count = await promptSlashSelects(page)
|
||||||
|
await showPromptSlash(page, input)
|
||||||
|
await prompt.press("Enter")
|
||||||
|
await expect.poll(() => promptSlashSelected(page, { id: input.id, count: count + 1 }), { timeout }).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
|
||||||
|
const term = input.term ?? page.locator(terminalSelector).first()
|
||||||
|
const timeout = input.timeout ?? 10_000
|
||||||
|
await waitTerminalReady(page, { term, timeout })
|
||||||
|
const textarea = term.locator("textarea")
|
||||||
|
await term.click()
|
||||||
|
await expect(textarea).toBeFocused()
|
||||||
|
await page.keyboard.type(input.cmd)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPalette(page: Page, key = "K") {
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
await page.keyboard.press(`${modKey}+P`)
|
await page.keyboard.press(`${modKey}+${key}`)
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
const dialog = page.getByRole("dialog")
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
|
|
@ -60,12 +205,52 @@ export async function closeDialog(page: Page, dialog: Locator) {
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isSidebarClosed(page: Page) {
|
async function isSidebarClosed(page: Page) {
|
||||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
const button = await waitSidebarButton(page, "isSidebarClosed")
|
||||||
await expect(button).toBeVisible()
|
|
||||||
return (await button.getAttribute("aria-expanded")) !== "true"
|
return (await button.getAttribute("aria-expanded")) !== "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function errorBoundaryText(page: Page) {
|
||||||
|
const title = page.getByRole("heading", { name: /something went wrong/i }).first()
|
||||||
|
if (!(await title.isVisible().catch(() => false))) return
|
||||||
|
|
||||||
|
const description = await page
|
||||||
|
.getByText(/an error occurred while loading the application\./i)
|
||||||
|
.first()
|
||||||
|
.textContent()
|
||||||
|
.catch(() => "")
|
||||||
|
const detail = await page
|
||||||
|
.getByRole("textbox", { name: /error details/i })
|
||||||
|
.first()
|
||||||
|
.inputValue()
|
||||||
|
.catch(async () =>
|
||||||
|
(
|
||||||
|
(await page
|
||||||
|
.getByRole("textbox", { name: /error details/i })
|
||||||
|
.first()
|
||||||
|
.textContent()
|
||||||
|
.catch(() => "")) ?? ""
|
||||||
|
).trim(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertHealthy(page: Page, context: string) {
|
||||||
|
const text = await errorBoundaryText(page)
|
||||||
|
if (!text) return
|
||||||
|
console.log(`[e2e:error-boundary][${context}]\n${text}`)
|
||||||
|
throw new Error(`Error boundary during ${context}\n${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitSidebarButton(page: Page, context: string) {
|
||||||
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
|
const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
|
||||||
|
await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
|
||||||
|
await assertHealthy(page, context)
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
export async function toggleSidebar(page: Page) {
|
export async function toggleSidebar(page: Page) {
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
await page.keyboard.press(`${modKey}+B`)
|
await page.keyboard.press(`${modKey}+B`)
|
||||||
|
|
@ -74,7 +259,7 @@ export async function toggleSidebar(page: Page) {
|
||||||
export async function openSidebar(page: Page) {
|
export async function openSidebar(page: Page) {
|
||||||
if (!(await isSidebarClosed(page))) return
|
if (!(await isSidebarClosed(page))) return
|
||||||
|
|
||||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
const button = await waitSidebarButton(page, "openSidebar")
|
||||||
await button.click()
|
await button.click()
|
||||||
|
|
||||||
const opened = await expect(button)
|
const opened = await expect(button)
|
||||||
|
|
@ -91,7 +276,7 @@ export async function openSidebar(page: Page) {
|
||||||
export async function closeSidebar(page: Page) {
|
export async function closeSidebar(page: Page) {
|
||||||
if (await isSidebarClosed(page)) return
|
if (await isSidebarClosed(page)) return
|
||||||
|
|
||||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
const button = await waitSidebarButton(page, "closeSidebar")
|
||||||
await button.click()
|
await button.click()
|
||||||
|
|
||||||
const closed = await expect(button)
|
const closed = await expect(button)
|
||||||
|
|
@ -106,6 +291,7 @@ export async function closeSidebar(page: Page) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openSettings(page: Page) {
|
export async function openSettings(page: Page) {
|
||||||
|
await assertHealthy(page, "openSettings")
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
const dialog = page.getByRole("dialog")
|
||||||
|
|
@ -118,79 +304,30 @@ export async function openSettings(page: Page) {
|
||||||
|
|
||||||
if (opened) return dialog
|
if (opened) return dialog
|
||||||
|
|
||||||
|
await assertHealthy(page, "openSettings")
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
await page.getByRole("button", { name: "Settings" }).first().click()
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
return dialog
|
return dialog
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
|
export async function createTestProject(input?: { serverUrl?: string }) {
|
||||||
await page.addInitScript(
|
|
||||||
(args: { directory: string; serverUrl: string; extra: string[] }) => {
|
|
||||||
const key = "opencode.global.dat:server"
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
const parsed = (() => {
|
|
||||||
if (!raw) return undefined
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw) as unknown
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
|
|
||||||
const list = Array.isArray(store.list) ? store.list : []
|
|
||||||
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
|
||||||
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
|
||||||
const nextProjects = { ...(projects as Record<string, unknown>) }
|
|
||||||
|
|
||||||
const add = (origin: string, directory: string) => {
|
|
||||||
const current = nextProjects[origin]
|
|
||||||
const items = Array.isArray(current) ? current : []
|
|
||||||
const existing = items.filter(
|
|
||||||
(p): p is { worktree: string; expanded?: boolean } =>
|
|
||||||
!!p &&
|
|
||||||
typeof p === "object" &&
|
|
||||||
"worktree" in p &&
|
|
||||||
typeof (p as { worktree?: unknown }).worktree === "string",
|
|
||||||
)
|
|
||||||
|
|
||||||
if (existing.some((p) => p.worktree === directory)) return
|
|
||||||
nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
|
|
||||||
}
|
|
||||||
|
|
||||||
const directories = [args.directory, ...args.extra]
|
|
||||||
for (const directory of directories) {
|
|
||||||
add("local", directory)
|
|
||||||
add(args.serverUrl, directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem(
|
|
||||||
key,
|
|
||||||
JSON.stringify({
|
|
||||||
list,
|
|
||||||
projects: nextProjects,
|
|
||||||
lastProject,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ directory: input.directory, serverUrl, extra: input.extra ?? [] },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createTestProject() {
|
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
||||||
|
const id = `e2e-${path.basename(root)}`
|
||||||
|
|
||||||
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
|
await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
|
||||||
|
|
||||||
execSync("git init", { cwd: root, stdio: "ignore" })
|
execSync("git init", { cwd: root, stdio: "ignore" })
|
||||||
|
await fs.writeFile(path.join(root, ".git", "opencode"), id)
|
||||||
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
||||||
|
execSync("git config commit.gpgsign false", { cwd: root, stdio: "ignore" })
|
||||||
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
||||||
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
})
|
})
|
||||||
|
|
||||||
return resolveDirectory(root)
|
return resolveDirectory(root, input?.serverUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupTestProject(directory: string) {
|
export async function cleanupTestProject(directory: string) {
|
||||||
|
|
@ -204,12 +341,24 @@ export function slugFromUrl(url: string) {
|
||||||
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
|
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function probeSession(page: Page) {
|
||||||
|
return page
|
||||||
|
.evaluate(() => {
|
||||||
|
const win = window as E2EWindow
|
||||||
|
const current = win.__opencode_e2e?.model?.current
|
||||||
|
if (!current) return null
|
||||||
|
return { dir: current.dir, sessionID: current.sessionID }
|
||||||
|
})
|
||||||
|
.catch(() => null as { dir?: string; sessionID?: string } | null)
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitSlug(page: Page, skip: string[] = []) {
|
export async function waitSlug(page: Page, skip: string[] = []) {
|
||||||
let prev = ""
|
let prev = ""
|
||||||
let next = ""
|
let next = ""
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
() => {
|
async () => {
|
||||||
|
await assertHealthy(page, "waitSlug")
|
||||||
const slug = slugFromUrl(page.url())
|
const slug = slugFromUrl(page.url())
|
||||||
if (!slug) return ""
|
if (!slug) return ""
|
||||||
if (skip.includes(slug)) return ""
|
if (skip.includes(slug)) return ""
|
||||||
|
|
@ -227,6 +376,105 @@ export async function waitSlug(page: Page, skip: string[] = []) {
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveSlug(slug: string, input?: { serverUrl?: string }) {
|
||||||
|
const directory = base64Decode(slug)
|
||||||
|
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||||
|
const resolved = await resolveDirectory(directory, input?.serverUrl)
|
||||||
|
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitDir(page: Page, directory: string, input?: { serverUrl?: string }) {
|
||||||
|
const target = await resolveDirectory(directory, input?.serverUrl)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
await assertHealthy(page, "waitDir")
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
if (!slug) return ""
|
||||||
|
return resolveSlug(slug, input)
|
||||||
|
.then((item) => item.directory)
|
||||||
|
.catch(() => "")
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.toBe(target)
|
||||||
|
return { directory: target, slug: base64Encode(target) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitSession(
|
||||||
|
page: Page,
|
||||||
|
input: {
|
||||||
|
directory: string
|
||||||
|
sessionID?: string
|
||||||
|
serverUrl?: string
|
||||||
|
allowAnySession?: boolean
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const target = await resolveDirectory(input.directory, input.serverUrl)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
await assertHealthy(page, "waitSession")
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
if (!slug) return false
|
||||||
|
const resolved = await resolveSlug(slug, { serverUrl: input.serverUrl }).catch(() => undefined)
|
||||||
|
if (!resolved || resolved.directory !== target) return false
|
||||||
|
const current = sessionIDFromUrl(page.url())
|
||||||
|
if (input.sessionID && current !== input.sessionID) return false
|
||||||
|
if (!input.sessionID && !input.allowAnySession && current) return false
|
||||||
|
|
||||||
|
const state = await probeSession(page)
|
||||||
|
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
||||||
|
if (!input.sessionID && !input.allowAnySession && state?.sessionID) return false
|
||||||
|
if (state?.dir) {
|
||||||
|
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
|
||||||
|
if (dir !== target) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return page
|
||||||
|
.locator(promptSelector)
|
||||||
|
.first()
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false)
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
return { directory: target, slug: base64Encode(target) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000, serverUrl?: string) {
|
||||||
|
const sdk = createSdk(directory, serverUrl)
|
||||||
|
const target = await resolveDirectory(directory, serverUrl)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const data = await sdk.session
|
||||||
|
.get({ sessionID })
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (!data?.directory) return ""
|
||||||
|
return resolveDirectory(data.directory, serverUrl).catch(() => data.directory)
|
||||||
|
},
|
||||||
|
{ timeout },
|
||||||
|
)
|
||||||
|
.toBe(target)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const items = await sdk.session
|
||||||
|
.messages({ sessionID, limit: 20 })
|
||||||
|
.then((x) => x.data ?? [])
|
||||||
|
.catch(() => [])
|
||||||
|
return items.some((item) => item.info.role === "user")
|
||||||
|
},
|
||||||
|
{ timeout },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
export function sessionIDFromUrl(url: string) {
|
export function sessionIDFromUrl(url: string) {
|
||||||
const match = /\/session\/([^/?#]+)/.exec(url)
|
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||||
return match?.[1]
|
return match?.[1]
|
||||||
|
|
@ -244,9 +492,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
||||||
|
|
||||||
const scroller = page.locator(".scroll-view__viewport").first()
|
const scroller = page.locator(".scroll-view__viewport").first()
|
||||||
await expect(scroller).toBeVisible()
|
await expect(scroller).toBeVisible()
|
||||||
const header = page.locator(sessionTimelineHeaderSelector).first()
|
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||||
await expect(header).toBeVisible({ timeout: 30_000 })
|
|
||||||
await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
|
||||||
|
|
||||||
const menu = page
|
const menu = page
|
||||||
.locator(dropdownMenuContentSelector)
|
.locator(dropdownMenuContentSelector)
|
||||||
|
|
@ -262,7 +508,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
||||||
|
|
||||||
if (opened) return menu
|
if (opened) return menu
|
||||||
|
|
||||||
const menuTrigger = header.getByRole("button", { name: /more options/i }).first()
|
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
|
||||||
await expect(menuTrigger).toBeVisible()
|
await expect(menuTrigger).toBeVisible()
|
||||||
await menuTrigger.click()
|
await menuTrigger.click()
|
||||||
|
|
||||||
|
|
@ -286,12 +532,15 @@ export async function confirmDialog(page: Page, buttonName: string | RegExp) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openSharePopover(page: Page) {
|
export async function openSharePopover(page: Page) {
|
||||||
const rightSection = page.locator(titlebarRightSelector)
|
const scroller = page.locator(".scroll-view__viewport").first()
|
||||||
const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
|
await expect(scroller).toBeVisible()
|
||||||
await expect(shareButton).toBeVisible()
|
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
|
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
|
||||||
|
await expect(menuTrigger).toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
const popoverBody = page
|
const popoverBody = page
|
||||||
.locator(popoverBodySelector)
|
.locator('[data-component="popover-content"]')
|
||||||
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
|
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
|
||||||
.first()
|
.first()
|
||||||
|
|
||||||
|
|
@ -301,16 +550,13 @@ export async function openSharePopover(page: Page) {
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
if (!opened) {
|
if (!opened) {
|
||||||
await shareButton.click()
|
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||||
await expect(popoverBody).toBeVisible()
|
await menuTrigger.click()
|
||||||
|
await clickMenuItem(menu, /share/i)
|
||||||
|
await expect(menu).toHaveCount(0)
|
||||||
|
await expect(popoverBody).toBeVisible({ timeout: 30_000 })
|
||||||
}
|
}
|
||||||
return { rightSection, popoverBody }
|
return { rightSection: scroller, popoverBody }
|
||||||
}
|
|
||||||
|
|
||||||
export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
|
|
||||||
const button = page.getByRole("button").filter({ hasText: buttonName }).first()
|
|
||||||
await expect(button).toBeVisible()
|
|
||||||
await button.click()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clickListItem(
|
export async function clickListItem(
|
||||||
|
|
@ -374,8 +620,9 @@ export async function cleanupSession(input: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
directory?: string
|
directory?: string
|
||||||
sdk?: ReturnType<typeof createSdk>
|
sdk?: ReturnType<typeof createSdk>
|
||||||
|
serverUrl?: string
|
||||||
}) {
|
}) {
|
||||||
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
|
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory, input.serverUrl) : undefined)
|
||||||
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
|
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
|
||||||
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
|
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
|
||||||
const current = await status(sdk, input.sessionID).catch(() => undefined)
|
const current = await status(sdk, input.sessionID).catch(() => undefined)
|
||||||
|
|
@ -477,40 +724,6 @@ export async function seedSessionQuestion(
|
||||||
return { id: result.id }
|
return { id: result.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedSessionPermission(
|
|
||||||
sdk: ReturnType<typeof createSdk>,
|
|
||||||
input: {
|
|
||||||
sessionID: string
|
|
||||||
permission: string
|
|
||||||
patterns: string[]
|
|
||||||
description?: string
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const text = [
|
|
||||||
"Your only valid response is one bash tool call.",
|
|
||||||
`Use this JSON input: ${JSON.stringify({
|
|
||||||
command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
|
|
||||||
workdir: "/",
|
|
||||||
description: input.description ?? `seed ${input.permission} permission request`,
|
|
||||||
})}`,
|
|
||||||
"Do not output plain text.",
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
const result = await seed({
|
|
||||||
sdk,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
prompt: text,
|
|
||||||
timeout: 30_000,
|
|
||||||
probe: async () => {
|
|
||||||
const list = await sdk.permission.list().then((x) => x.data ?? [])
|
|
||||||
return list.find((item) => item.sessionID === input.sessionID)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result) throw new Error("Timed out seeding permission request")
|
|
||||||
return { id: result.id }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function seedSessionTask(
|
export async function seedSessionTask(
|
||||||
sdk: ReturnType<typeof createSdk>,
|
sdk: ReturnType<typeof createSdk>,
|
||||||
input: {
|
input: {
|
||||||
|
|
@ -542,12 +755,19 @@ export async function seedSessionTask(
|
||||||
.flatMap((message) => message.parts)
|
.flatMap((message) => message.parts)
|
||||||
.find((part) => {
|
.find((part) => {
|
||||||
if (part.type !== "tool" || part.tool !== "task") return false
|
if (part.type !== "tool" || part.tool !== "task") return false
|
||||||
if (part.state.input?.description !== input.description) return false
|
if (!("state" in part) || !part.state || typeof part.state !== "object") return false
|
||||||
return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0
|
if (!("input" in part.state) || !part.state.input || typeof part.state.input !== "object") return false
|
||||||
|
if (!("description" in part.state.input) || part.state.input.description !== input.description) return false
|
||||||
|
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object")
|
||||||
|
return false
|
||||||
|
if (!("sessionId" in part.state.metadata)) return false
|
||||||
|
return typeof part.state.metadata.sessionId === "string" && part.state.metadata.sessionId.length > 0
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!part) return
|
if (!part || !("state" in part) || !part.state || typeof part.state !== "object") return
|
||||||
const id = part.state.metadata?.sessionId
|
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
|
||||||
|
if (!("sessionId" in part.state.metadata)) return
|
||||||
|
const id = part.state.metadata.sessionId
|
||||||
if (typeof id !== "string" || !id) return
|
if (typeof id !== "string" || !id) return
|
||||||
const child = await sdk.session
|
const child = await sdk.session
|
||||||
.get({ sessionID: id })
|
.get({ sessionID: id })
|
||||||
|
|
@ -562,36 +782,6 @@ export async function seedSessionTask(
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedSessionTodos(
|
|
||||||
sdk: ReturnType<typeof createSdk>,
|
|
||||||
input: {
|
|
||||||
sessionID: string
|
|
||||||
todos: Array<{ content: string; status: string; priority: string }>
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const text = [
|
|
||||||
"Your only valid response is one todowrite tool call.",
|
|
||||||
`Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
|
|
||||||
"Do not output plain text.",
|
|
||||||
].join("\n")
|
|
||||||
const target = JSON.stringify(input.todos)
|
|
||||||
|
|
||||||
const result = await seed({
|
|
||||||
sdk,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
prompt: text,
|
|
||||||
timeout: 30_000,
|
|
||||||
probe: async () => {
|
|
||||||
const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
|
|
||||||
if (JSON.stringify(todos) !== target) return
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result) throw new Error("Timed out seeding todos")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||||
const [questions, permissions] = await Promise.all([
|
const [questions, permissions] = await Promise.all([
|
||||||
sdk.question.list().then((x) => x.data ?? []),
|
sdk.question.list().then((x) => x.data ?? []),
|
||||||
|
|
@ -633,8 +823,14 @@ export async function openStatusPopover(page: Page) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openProjectMenu(page: Page, projectSlug: string) {
|
export async function openProjectMenu(page: Page, projectSlug: string) {
|
||||||
|
await openSidebar(page)
|
||||||
|
const item = page.locator(projectSwitchSelector(projectSlug)).first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.hover()
|
||||||
|
|
||||||
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
||||||
await expect(trigger).toHaveCount(1)
|
await expect(trigger).toHaveCount(1)
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
|
||||||
const menu = page
|
const menu = page
|
||||||
.locator(dropdownMenuContentSelector)
|
.locator(dropdownMenuContentSelector)
|
||||||
|
|
@ -643,7 +839,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
|
||||||
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
|
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
|
||||||
|
|
||||||
const clicked = await trigger
|
const clicked = await trigger
|
||||||
.click({ timeout: 1500 })
|
.click({ force: true, timeout: 1500 })
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
|
|
@ -675,30 +871,57 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
||||||
const current = await page
|
const current = () =>
|
||||||
.getByRole("button", { name: "New workspace" })
|
page
|
||||||
.first()
|
.getByRole("button", { name: "New workspace" })
|
||||||
.isVisible()
|
.first()
|
||||||
.then((x) => x)
|
.isVisible()
|
||||||
.catch(() => false)
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
if (current === enabled) return
|
if ((await current()) === enabled) return
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
await page.reload()
|
||||||
|
await openSidebar(page)
|
||||||
|
if ((await current()) === enabled) return
|
||||||
|
}
|
||||||
|
|
||||||
const flip = async (timeout?: number) => {
|
const flip = async (timeout?: number) => {
|
||||||
const menu = await openProjectMenu(page, projectSlug)
|
const menu = await openProjectMenu(page, projectSlug)
|
||||||
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
||||||
await expect(toggle).toBeVisible()
|
await expect(toggle).toBeVisible()
|
||||||
return toggle.click({ force: true, timeout })
|
await expect(toggle).toBeEnabled({ timeout: 30_000 })
|
||||||
|
const clicked = await toggle
|
||||||
|
.click({ force: true, timeout })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (clicked) return
|
||||||
|
await toggle.focus()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
}
|
}
|
||||||
|
|
||||||
const flipped = await flip(1500)
|
for (const timeout of [1500, undefined, undefined]) {
|
||||||
.then(() => true)
|
if ((await current()) === enabled) break
|
||||||
.catch(() => false)
|
await flip(timeout)
|
||||||
|
.then(() => undefined)
|
||||||
|
.catch(() => undefined)
|
||||||
|
const matched = await expect
|
||||||
|
.poll(current, { timeout: 5_000 })
|
||||||
|
.toBe(enabled)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (matched) break
|
||||||
|
}
|
||||||
|
|
||||||
if (!flipped) await flip()
|
if ((await current()) !== enabled) {
|
||||||
|
await page.reload()
|
||||||
|
await openSidebar(page)
|
||||||
|
}
|
||||||
|
|
||||||
const expected = enabled ? "New workspace" : "New session"
|
const expected = enabled ? "New workspace" : "New session"
|
||||||
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
|
await expect.poll(current, { timeout: 60_000 }).toBe(enabled)
|
||||||
|
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible({ timeout: 30_000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
||||||
|
|
@ -714,3 +937,13 @@ export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
||||||
await expect(menu).toBeVisible()
|
await expect(menu).toBeVisible()
|
||||||
return menu
|
return menu
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function assistantText(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||||
|
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
return messages
|
||||||
|
.filter((m) => m.info.role === "assistant")
|
||||||
|
.flatMap((m) => m.parts)
|
||||||
|
.filter((p) => p.type === "text")
|
||||||
|
.map((p) => p.text)
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,11 @@ import { serverNamePattern } from "../utils"
|
||||||
|
|
||||||
test("home renders and shows core entrypoints", async ({ page }) => {
|
test("home renders and shows core entrypoints", async ({ page }) => {
|
||||||
await page.goto("/")
|
await page.goto("/")
|
||||||
|
const nav = page.locator('[data-component="sidebar-nav-desktop"]')
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
|
||||||
|
await expect(nav.getByText("No projects open")).toBeVisible()
|
||||||
|
await expect(nav.getByText("Open a project to get started")).toBeVisible()
|
||||||
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
|
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openPalette } from "../actions"
|
import { closeDialog, openPalette } from "../actions"
|
||||||
|
|
||||||
test("search palette opens and closes", async ({ page, gotoSession }) => {
|
test("search palette opens and closes", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
@ -9,3 +9,12 @@ test("search palette opens and closes", async ({ page, gotoSession }) => {
|
||||||
await page.keyboard.press("Escape")
|
await page.keyboard.press("Escape")
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("search palette also opens with cmd+p", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openPalette(page, "P")
|
||||||
|
|
||||||
|
await closeDialog(page, dialog)
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { spawn } from "node:child_process"
|
||||||
|
import fs from "node:fs/promises"
|
||||||
|
import net from "node:net"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
type Handle = {
|
||||||
|
url: string
|
||||||
|
stop: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function freePort() {
|
||||||
|
return new Promise<number>((resolve, reject) => {
|
||||||
|
const server = net.createServer()
|
||||||
|
server.once("error", reject)
|
||||||
|
server.listen(0, () => {
|
||||||
|
const address = server.address()
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close(() => reject(new Error("Failed to acquire a free port")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) reject(err)
|
||||||
|
else resolve(address.port)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForHealth(url: string, probe = "/global/health") {
|
||||||
|
const end = Date.now() + 120_000
|
||||||
|
let last = ""
|
||||||
|
while (Date.now() < end) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}${probe}`)
|
||||||
|
if (res.ok) return
|
||||||
|
last = `status ${res.status}`
|
||||||
|
} catch (err) {
|
||||||
|
last = err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
|
||||||
|
if (proc.exitCode !== null) return
|
||||||
|
await Promise.race([
|
||||||
|
new Promise<void>((resolve) => proc.once("exit", () => resolve())),
|
||||||
|
new Promise<void>((resolve) => setTimeout(resolve, timeout)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_CAP = 100
|
||||||
|
|
||||||
|
function cap(input: string[]) {
|
||||||
|
if (input.length > LOG_CAP) input.splice(0, input.length - LOG_CAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
function tail(input: string[]) {
|
||||||
|
return input.slice(-40).join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startBackend(label: string, input?: { llmUrl?: string }): Promise<Handle> {
|
||||||
|
const port = await freePort()
|
||||||
|
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
|
||||||
|
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||||
|
const repoDir = path.resolve(appDir, "../..")
|
||||||
|
const opencodeDir = path.join(repoDir, "packages", "opencode")
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
||||||
|
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
||||||
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
|
||||||
|
OPENCODE_TEST_HOME: path.join(sandbox, "home"),
|
||||||
|
XDG_DATA_HOME: path.join(sandbox, "share"),
|
||||||
|
XDG_CACHE_HOME: path.join(sandbox, "cache"),
|
||||||
|
XDG_CONFIG_HOME: path.join(sandbox, "config"),
|
||||||
|
XDG_STATE_HOME: path.join(sandbox, "state"),
|
||||||
|
OPENCODE_CLIENT: "app",
|
||||||
|
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
||||||
|
OPENCODE_E2E_LLM_URL: input?.llmUrl,
|
||||||
|
} satisfies Record<string, string | undefined>
|
||||||
|
const out: string[] = []
|
||||||
|
const err: string[] = []
|
||||||
|
const proc = spawn(
|
||||||
|
"bun",
|
||||||
|
["run", "--conditions=browser", "./src/index.ts", "serve", "--port", String(port), "--hostname", "127.0.0.1"],
|
||||||
|
{
|
||||||
|
cwd: opencodeDir,
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
proc.stdout?.on("data", (chunk) => {
|
||||||
|
out.push(String(chunk))
|
||||||
|
cap(out)
|
||||||
|
})
|
||||||
|
proc.stderr?.on("data", (chunk) => {
|
||||||
|
err.push(String(chunk))
|
||||||
|
cap(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = `http://127.0.0.1:${port}`
|
||||||
|
try {
|
||||||
|
await waitForHealth(url)
|
||||||
|
} catch (error) {
|
||||||
|
proc.kill("SIGTERM")
|
||||||
|
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`Failed to start isolated e2e backend for ${label}`,
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
tail(out),
|
||||||
|
tail(err),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
async stop() {
|
||||||
|
if (proc.exitCode === null) {
|
||||||
|
proc.kill("SIGTERM")
|
||||||
|
await waitExit(proc)
|
||||||
|
}
|
||||||
|
if (proc.exitCode === null) {
|
||||||
|
proc.kill("SIGKILL")
|
||||||
|
await waitExit(proc)
|
||||||
|
}
|
||||||
|
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,35 +1,277 @@
|
||||||
import { test as base, expect, type Page } from "@playwright/test"
|
import { test as base, expect, type Page } from "@playwright/test"
|
||||||
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
|
import { ManagedRuntime } from "effect"
|
||||||
|
import type { E2EWindow } from "../src/testing/terminal"
|
||||||
|
import type { Item, Reply, Usage } from "../../opencode/test/lib/llm-server"
|
||||||
|
import { TestLLMServer } from "../../opencode/test/lib/llm-server"
|
||||||
|
import { startBackend } from "./backend"
|
||||||
|
import {
|
||||||
|
healthPhase,
|
||||||
|
cleanupSession,
|
||||||
|
cleanupTestProject,
|
||||||
|
createTestProject,
|
||||||
|
setHealthPhase,
|
||||||
|
sessionIDFromUrl,
|
||||||
|
waitSession,
|
||||||
|
waitSessionIdle,
|
||||||
|
waitSessionSaved,
|
||||||
|
waitSlug,
|
||||||
|
} from "./actions"
|
||||||
import { promptSelector } from "./selectors"
|
import { promptSelector } from "./selectors"
|
||||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
import { createSdk, dirSlug, getWorktree, serverUrl, sessionPath } from "./utils"
|
||||||
|
|
||||||
|
type LLMFixture = {
|
||||||
|
url: string
|
||||||
|
push: (...input: (Item | Reply)[]) => Promise<void>
|
||||||
|
pushMatch: (
|
||||||
|
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
||||||
|
...input: (Item | Reply)[]
|
||||||
|
) => Promise<void>
|
||||||
|
textMatch: (
|
||||||
|
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
||||||
|
value: string,
|
||||||
|
opts?: { usage?: Usage },
|
||||||
|
) => Promise<void>
|
||||||
|
toolMatch: (
|
||||||
|
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
||||||
|
name: string,
|
||||||
|
input: unknown,
|
||||||
|
) => Promise<void>
|
||||||
|
text: (value: string, opts?: { usage?: Usage }) => Promise<void>
|
||||||
|
tool: (name: string, input: unknown) => Promise<void>
|
||||||
|
toolHang: (name: string, input: unknown) => Promise<void>
|
||||||
|
reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise<void>
|
||||||
|
fail: (message?: unknown) => Promise<void>
|
||||||
|
error: (status: number, body: unknown) => Promise<void>
|
||||||
|
hang: () => Promise<void>
|
||||||
|
hold: (value: string, wait: PromiseLike<unknown>) => Promise<void>
|
||||||
|
hits: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
|
||||||
|
calls: () => Promise<number>
|
||||||
|
wait: (count: number) => Promise<void>
|
||||||
|
inputs: () => Promise<Record<string, unknown>[]>
|
||||||
|
pending: () => Promise<number>
|
||||||
|
misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
|
||||||
|
}
|
||||||
|
|
||||||
|
type LLMWorker = LLMFixture & {
|
||||||
|
reset: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssistantFixture = {
|
||||||
|
reply: LLMFixture["text"]
|
||||||
|
tool: LLMFixture["tool"]
|
||||||
|
toolHang: LLMFixture["toolHang"]
|
||||||
|
reason: LLMFixture["reason"]
|
||||||
|
fail: LLMFixture["fail"]
|
||||||
|
error: LLMFixture["error"]
|
||||||
|
hang: LLMFixture["hang"]
|
||||||
|
hold: LLMFixture["hold"]
|
||||||
|
calls: LLMFixture["calls"]
|
||||||
|
pending: LLMFixture["pending"]
|
||||||
|
}
|
||||||
|
|
||||||
export const settingsKey = "settings.v3"
|
export const settingsKey = "settings.v3"
|
||||||
|
|
||||||
|
const seedModel = (() => {
|
||||||
|
const [providerID = "opencode", modelID = "big-pickle"] = (
|
||||||
|
process.env.OPENCODE_E2E_MODEL ?? "opencode/big-pickle"
|
||||||
|
).split("/")
|
||||||
|
return {
|
||||||
|
providerID: providerID || "opencode",
|
||||||
|
modelID: modelID || "big-pickle",
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
function clean(value: string | null) {
|
||||||
|
return (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function visit(page: Page, url: string) {
|
||||||
|
let err: unknown
|
||||||
|
for (const _ of [0, 1, 2]) {
|
||||||
|
try {
|
||||||
|
await page.goto(url)
|
||||||
|
return
|
||||||
|
} catch (cause) {
|
||||||
|
err = cause
|
||||||
|
if (!String(cause).includes("ERR_CONNECTION_REFUSED")) throw cause
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptSend(page: Page) {
|
||||||
|
return page
|
||||||
|
.evaluate(() => {
|
||||||
|
const win = window as E2EWindow
|
||||||
|
const sent = win.__opencode_e2e?.prompt?.sent
|
||||||
|
return {
|
||||||
|
started: sent?.started ?? 0,
|
||||||
|
count: sent?.count ?? 0,
|
||||||
|
sessionID: sent?.sessionID,
|
||||||
|
directory: sent?.directory,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => ({ started: 0, count: 0, sessionID: undefined, directory: undefined }))
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectHandle = {
|
||||||
|
directory: string
|
||||||
|
slug: string
|
||||||
|
gotoSession: (sessionID?: string) => Promise<void>
|
||||||
|
trackSession: (sessionID: string, directory?: string) => void
|
||||||
|
trackDirectory: (directory: string) => void
|
||||||
|
sdk: ReturnType<typeof createSdk>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectOptions = {
|
||||||
|
extra?: string[]
|
||||||
|
model?: { providerID: string; modelID: string }
|
||||||
|
setup?: (directory: string) => Promise<void>
|
||||||
|
beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectFixture = ProjectHandle & {
|
||||||
|
open: (options?: ProjectOptions) => Promise<void>
|
||||||
|
prompt: (text: string) => Promise<string>
|
||||||
|
user: (text: string) => Promise<string>
|
||||||
|
shell: (cmd: string) => Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
type TestFixtures = {
|
type TestFixtures = {
|
||||||
|
llm: LLMFixture
|
||||||
|
assistant: AssistantFixture
|
||||||
|
project: ProjectFixture
|
||||||
sdk: ReturnType<typeof createSdk>
|
sdk: ReturnType<typeof createSdk>
|
||||||
gotoSession: (sessionID?: string) => Promise<void>
|
gotoSession: (sessionID?: string) => Promise<void>
|
||||||
withProject: <T>(
|
|
||||||
callback: (project: {
|
|
||||||
directory: string
|
|
||||||
slug: string
|
|
||||||
gotoSession: (sessionID?: string) => Promise<void>
|
|
||||||
trackSession: (sessionID: string, directory?: string) => void
|
|
||||||
trackDirectory: (directory: string) => void
|
|
||||||
}) => Promise<T>,
|
|
||||||
options?: { extra?: string[] },
|
|
||||||
) => Promise<T>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkerFixtures = {
|
type WorkerFixtures = {
|
||||||
|
_llm: LLMWorker
|
||||||
|
backend: {
|
||||||
|
url: string
|
||||||
|
sdk: (directory?: string) => ReturnType<typeof createSdk>
|
||||||
|
}
|
||||||
directory: string
|
directory: string
|
||||||
slug: string
|
slug: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||||
directory: [
|
_llm: [
|
||||||
async ({}, use) => {
|
async ({}, use) => {
|
||||||
const directory = await getWorktree()
|
const rt = ManagedRuntime.make(TestLLMServer.layer)
|
||||||
await use(directory)
|
try {
|
||||||
|
const svc = await rt.runPromise(TestLLMServer.asEffect())
|
||||||
|
await use({
|
||||||
|
url: svc.url,
|
||||||
|
push: (...input) => rt.runPromise(svc.push(...input)),
|
||||||
|
pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
|
||||||
|
textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
|
||||||
|
toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)),
|
||||||
|
text: (value, opts) => rt.runPromise(svc.text(value, opts)),
|
||||||
|
tool: (name, input) => rt.runPromise(svc.tool(name, input)),
|
||||||
|
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
|
||||||
|
reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
|
||||||
|
fail: (message) => rt.runPromise(svc.fail(message)),
|
||||||
|
error: (status, body) => rt.runPromise(svc.error(status, body)),
|
||||||
|
hang: () => rt.runPromise(svc.hang),
|
||||||
|
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
|
||||||
|
reset: () => rt.runPromise(svc.reset),
|
||||||
|
hits: () => rt.runPromise(svc.hits),
|
||||||
|
calls: () => rt.runPromise(svc.calls),
|
||||||
|
wait: (count) => rt.runPromise(svc.wait(count)),
|
||||||
|
inputs: () => rt.runPromise(svc.inputs),
|
||||||
|
pending: () => rt.runPromise(svc.pending),
|
||||||
|
misses: () => rt.runPromise(svc.misses),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await rt.dispose()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ scope: "worker" },
|
||||||
|
],
|
||||||
|
backend: [
|
||||||
|
async ({ _llm }, use, workerInfo) => {
|
||||||
|
const handle = await startBackend(`w${workerInfo.workerIndex}`, { llmUrl: _llm.url })
|
||||||
|
try {
|
||||||
|
await use({
|
||||||
|
url: handle.url,
|
||||||
|
sdk: (directory?: string) => createSdk(directory, handle.url),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await handle.stop()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ scope: "worker" },
|
||||||
|
],
|
||||||
|
llm: async ({ _llm }, use) => {
|
||||||
|
await _llm.reset()
|
||||||
|
await use({
|
||||||
|
url: _llm.url,
|
||||||
|
push: _llm.push,
|
||||||
|
pushMatch: _llm.pushMatch,
|
||||||
|
textMatch: _llm.textMatch,
|
||||||
|
toolMatch: _llm.toolMatch,
|
||||||
|
text: _llm.text,
|
||||||
|
tool: _llm.tool,
|
||||||
|
toolHang: _llm.toolHang,
|
||||||
|
reason: _llm.reason,
|
||||||
|
fail: _llm.fail,
|
||||||
|
error: _llm.error,
|
||||||
|
hang: _llm.hang,
|
||||||
|
hold: _llm.hold,
|
||||||
|
hits: _llm.hits,
|
||||||
|
calls: _llm.calls,
|
||||||
|
wait: _llm.wait,
|
||||||
|
inputs: _llm.inputs,
|
||||||
|
pending: _llm.pending,
|
||||||
|
misses: _llm.misses,
|
||||||
|
})
|
||||||
|
const pending = await _llm.pending()
|
||||||
|
if (pending > 0) {
|
||||||
|
throw new Error(`TestLLMServer still has ${pending} queued response(s) after the test finished`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
assistant: async ({ llm }, use) => {
|
||||||
|
await use({
|
||||||
|
reply: llm.text,
|
||||||
|
tool: llm.tool,
|
||||||
|
toolHang: llm.toolHang,
|
||||||
|
reason: llm.reason,
|
||||||
|
fail: llm.fail,
|
||||||
|
error: llm.error,
|
||||||
|
hang: llm.hang,
|
||||||
|
hold: llm.hold,
|
||||||
|
calls: llm.calls,
|
||||||
|
pending: llm.pending,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
page: async ({ page }, use) => {
|
||||||
|
let boundary: string | undefined
|
||||||
|
setHealthPhase(page, "test")
|
||||||
|
const consoleHandler = (msg: { text(): string }) => {
|
||||||
|
const text = msg.text()
|
||||||
|
if (!text.includes("[e2e:error-boundary]")) return
|
||||||
|
if (healthPhase(page) === "cleanup") {
|
||||||
|
console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
boundary ||= text
|
||||||
|
console.log(text)
|
||||||
|
}
|
||||||
|
const pageErrorHandler = (err: Error) => {
|
||||||
|
console.log(`[e2e:pageerror] ${err.stack || err.message}`)
|
||||||
|
}
|
||||||
|
page.on("console", consoleHandler)
|
||||||
|
page.on("pageerror", pageErrorHandler)
|
||||||
|
await use(page)
|
||||||
|
page.off("console", consoleHandler)
|
||||||
|
page.off("pageerror", pageErrorHandler)
|
||||||
|
if (boundary) throw new Error(boundary)
|
||||||
|
},
|
||||||
|
directory: [
|
||||||
|
async ({ backend }, use) => {
|
||||||
|
await use(await getWorktree(backend.url))
|
||||||
},
|
},
|
||||||
{ scope: "worker" },
|
{ scope: "worker" },
|
||||||
],
|
],
|
||||||
|
|
@ -39,67 +281,324 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||||
},
|
},
|
||||||
{ scope: "worker" },
|
{ scope: "worker" },
|
||||||
],
|
],
|
||||||
sdk: async ({ directory }, use) => {
|
sdk: async ({ directory, backend }, use) => {
|
||||||
await use(createSdk(directory))
|
await use(backend.sdk(directory))
|
||||||
},
|
},
|
||||||
gotoSession: async ({ page, directory }, use) => {
|
gotoSession: async ({ page, directory, backend }, use) => {
|
||||||
await seedStorage(page, { directory })
|
await seedStorage(page, { directory, serverUrl: backend.url })
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
const gotoSession = async (sessionID?: string) => {
|
||||||
await page.goto(sessionPath(directory, sessionID))
|
await visit(page, sessionPath(directory, sessionID))
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await waitSession(page, {
|
||||||
|
directory,
|
||||||
|
sessionID,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
allowAnySession: !sessionID,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
await use(gotoSession)
|
await use(gotoSession)
|
||||||
},
|
},
|
||||||
withProject: async ({ page }, use) => {
|
project: async ({ page, llm, backend }, use) => {
|
||||||
await use(async (callback, options) => {
|
const item = makeProject(page, llm, backend)
|
||||||
const root = await createTestProject()
|
try {
|
||||||
const slug = dirSlug(root)
|
await use(item.project)
|
||||||
const sessions = new Map<string, string>()
|
} finally {
|
||||||
const dirs = new Set<string>()
|
await item.cleanup()
|
||||||
await seedStorage(page, { directory: root, extra: options?.extra })
|
}
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
|
||||||
await page.goto(sessionPath(root, sessionID))
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
const current = sessionIDFromUrl(page.url())
|
|
||||||
if (current) trackSession(current)
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackSession = (sessionID: string, directory?: string) => {
|
|
||||||
sessions.set(sessionID, directory ?? root)
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackDirectory = (directory: string) => {
|
|
||||||
if (directory !== root) dirs.add(directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoSession()
|
|
||||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
|
||||||
} finally {
|
|
||||||
await Promise.allSettled(
|
|
||||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
|
||||||
)
|
|
||||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
|
||||||
await cleanupTestProject(root)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
|
function makeProject(
|
||||||
await seedProjects(page, input)
|
page: Page,
|
||||||
await page.addInitScript(() => {
|
llm: LLMFixture,
|
||||||
localStorage.setItem(
|
backend: { url: string; sdk: (directory?: string) => ReturnType<typeof createSdk> },
|
||||||
"opencode.global.dat:model",
|
) {
|
||||||
JSON.stringify({
|
let state:
|
||||||
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
|
| {
|
||||||
user: [],
|
directory: string
|
||||||
variant: {},
|
slug: string
|
||||||
}),
|
sdk: ReturnType<typeof createSdk>
|
||||||
|
sessions: Map<string, string>
|
||||||
|
dirs: Set<string>
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
|
||||||
|
const need = () => {
|
||||||
|
if (state) return state
|
||||||
|
throw new Error("project.open() must be called first")
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackSession = (sessionID: string, directory?: string) => {
|
||||||
|
const cur = need()
|
||||||
|
cur.sessions.set(sessionID, directory ?? cur.directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackDirectory = (directory: string) => {
|
||||||
|
const cur = need()
|
||||||
|
if (directory !== cur.directory) cur.dirs.add(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
const gotoSession = async (sessionID?: string) => {
|
||||||
|
const cur = need()
|
||||||
|
await visit(page, sessionPath(cur.directory, sessionID))
|
||||||
|
await waitSession(page, {
|
||||||
|
directory: cur.directory,
|
||||||
|
sessionID,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
allowAnySession: !sessionID,
|
||||||
|
})
|
||||||
|
const current = sessionIDFromUrl(page.url())
|
||||||
|
if (current) trackSession(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = async (options?: ProjectOptions) => {
|
||||||
|
if (state) return
|
||||||
|
const directory = await createTestProject({ serverUrl: backend.url })
|
||||||
|
const sdk = backend.sdk(directory)
|
||||||
|
await options?.setup?.(directory)
|
||||||
|
await seedStorage(page, {
|
||||||
|
directory,
|
||||||
|
extra: options?.extra,
|
||||||
|
model: options?.model,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
})
|
||||||
|
state = {
|
||||||
|
directory,
|
||||||
|
slug: "",
|
||||||
|
sdk,
|
||||||
|
sessions: new Map(),
|
||||||
|
dirs: new Set(),
|
||||||
|
}
|
||||||
|
await options?.beforeGoto?.({ directory, sdk })
|
||||||
|
await gotoSession()
|
||||||
|
need().slug = await waitSlug(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
const send = async (text: string, input: { noReply: boolean; shell: boolean }) => {
|
||||||
|
if (input.noReply) {
|
||||||
|
const cur = need()
|
||||||
|
const state = await page.evaluate(() => {
|
||||||
|
const model = (window as E2EWindow).__opencode_e2e?.model?.current
|
||||||
|
if (!model) return null
|
||||||
|
return {
|
||||||
|
dir: model.dir,
|
||||||
|
sessionID: model.sessionID,
|
||||||
|
agent: model.agent,
|
||||||
|
model: model.model ? { providerID: model.model.providerID, modelID: model.model.modelID } : undefined,
|
||||||
|
variant: model.variant ?? undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const dir = state?.dir ?? cur.directory
|
||||||
|
const sdk = backend.sdk(dir)
|
||||||
|
const sessionID = state?.sessionID
|
||||||
|
? state.sessionID
|
||||||
|
: await sdk.session.create({ directory: dir, title: "E2E Session" }).then((res) => {
|
||||||
|
if (!res.data?.id) throw new Error("Failed to create no-reply session")
|
||||||
|
return res.data.id
|
||||||
|
})
|
||||||
|
await sdk.session.prompt({
|
||||||
|
sessionID,
|
||||||
|
agent: state?.agent,
|
||||||
|
model: state?.model,
|
||||||
|
variant: state?.variant,
|
||||||
|
noReply: true,
|
||||||
|
parts: [{ type: "text", text }],
|
||||||
|
})
|
||||||
|
await visit(page, sessionPath(dir, sessionID))
|
||||||
|
const active = await waitSession(page, {
|
||||||
|
directory: dir,
|
||||||
|
sessionID,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
})
|
||||||
|
trackSession(sessionID, active.directory)
|
||||||
|
await waitSessionSaved(active.directory, sessionID, 90_000, backend.url)
|
||||||
|
return sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = await promptSend(page)
|
||||||
|
if (!input.noReply && !input.shell && (await llm.pending()) === 0) {
|
||||||
|
await llm.text("ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector).first()
|
||||||
|
const submit = async () => {
|
||||||
|
await expect(prompt).toBeVisible()
|
||||||
|
await prompt.click()
|
||||||
|
if (input.shell) {
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
||||||
|
}
|
||||||
|
await page.keyboard.type(text)
|
||||||
|
await expect.poll(async () => clean(await prompt.textContent())).toBe(text)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
const started = await expect
|
||||||
|
.poll(async () => (await promptSend(page)).started, { timeout: 5_000 })
|
||||||
|
.toBeGreaterThan(prev.started)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (started) return
|
||||||
|
const send = page.getByRole("button", { name: "Send" }).first()
|
||||||
|
const enabled = await send
|
||||||
|
.isEnabled()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
if (enabled) {
|
||||||
|
await send.click()
|
||||||
|
} else {
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
}
|
||||||
|
await expect.poll(async () => (await promptSend(page)).started, { timeout: 5_000 }).toBeGreaterThan(prev.started)
|
||||||
|
}
|
||||||
|
|
||||||
|
await submit()
|
||||||
|
|
||||||
|
let next: { sessionID: string; directory: string } | undefined
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const sent = await promptSend(page)
|
||||||
|
if (sent.count <= prev.count) return ""
|
||||||
|
if (!sent.sessionID || !sent.directory) return ""
|
||||||
|
next = { sessionID: sent.sessionID, directory: sent.directory }
|
||||||
|
return sent.sessionID
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.not.toBe("")
|
||||||
|
|
||||||
|
if (!next) throw new Error("Failed to observe prompt submission in e2e prompt probe")
|
||||||
|
const active = await waitSession(page, {
|
||||||
|
directory: next.directory,
|
||||||
|
sessionID: next.sessionID,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
})
|
||||||
|
trackSession(next.sessionID, active.directory)
|
||||||
|
if (!input.shell) {
|
||||||
|
await waitSessionSaved(active.directory, next.sessionID, 90_000, backend.url)
|
||||||
|
}
|
||||||
|
await waitSessionIdle(backend.sdk(active.directory), next.sessionID, 90_000).catch(() => undefined)
|
||||||
|
return next.sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = async (text: string) => {
|
||||||
|
return send(text, { noReply: false, shell: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = async (text: string) => {
|
||||||
|
return send(text, { noReply: true, shell: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const shell = async (cmd: string) => {
|
||||||
|
return send(cmd, { noReply: false, shell: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
const cur = state
|
||||||
|
if (!cur) return
|
||||||
|
setHealthPhase(page, "cleanup")
|
||||||
|
await Promise.allSettled(
|
||||||
|
Array.from(cur.sessions, ([sessionID, directory]) =>
|
||||||
|
cleanupSession({ sessionID, directory, serverUrl: backend.url }),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
})
|
await Promise.allSettled(Array.from(cur.dirs, (directory) => cleanupTestProject(directory)))
|
||||||
|
await cleanupTestProject(cur.directory)
|
||||||
|
state = undefined
|
||||||
|
setHealthPhase(page, "test")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
open,
|
||||||
|
prompt,
|
||||||
|
user,
|
||||||
|
shell,
|
||||||
|
gotoSession,
|
||||||
|
trackSession,
|
||||||
|
trackDirectory,
|
||||||
|
get directory() {
|
||||||
|
return need().directory
|
||||||
|
},
|
||||||
|
get slug() {
|
||||||
|
return need().slug
|
||||||
|
},
|
||||||
|
get sdk() {
|
||||||
|
return need().sdk
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cleanup,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedStorage(
|
||||||
|
page: Page,
|
||||||
|
input: {
|
||||||
|
directory: string
|
||||||
|
extra?: string[]
|
||||||
|
model?: { providerID: string; modelID: string }
|
||||||
|
serverUrl?: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const origin = input.serverUrl ?? serverUrl
|
||||||
|
await page.addInitScript(
|
||||||
|
(args: {
|
||||||
|
directory: string
|
||||||
|
serverUrl: string
|
||||||
|
extra: string[]
|
||||||
|
model: { providerID: string; modelID: string }
|
||||||
|
}) => {
|
||||||
|
const key = "opencode.global.dat:server"
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
const parsed = (() => {
|
||||||
|
if (!raw) return undefined
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as unknown
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
|
||||||
|
const list = Array.isArray(store.list) ? store.list : []
|
||||||
|
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
||||||
|
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
||||||
|
const next = { ...(projects as Record<string, unknown>) }
|
||||||
|
const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
|
||||||
|
|
||||||
|
const add = (origin: string, directory: string) => {
|
||||||
|
const current = next[origin]
|
||||||
|
const items = Array.isArray(current) ? current : []
|
||||||
|
const existing = items.filter(
|
||||||
|
(p): p is { worktree: string; expanded?: boolean } =>
|
||||||
|
!!p &&
|
||||||
|
typeof p === "object" &&
|
||||||
|
"worktree" in p &&
|
||||||
|
typeof (p as { worktree?: unknown }).worktree === "string",
|
||||||
|
)
|
||||||
|
if (existing.some((p) => p.worktree === directory)) return
|
||||||
|
next[origin] = [{ worktree: directory, expanded: true }, ...existing]
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const directory of [args.directory, ...args.extra]) {
|
||||||
|
add("local", directory)
|
||||||
|
add(args.serverUrl, directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(key, JSON.stringify({ list: nextList, projects: next, lastProject }))
|
||||||
|
localStorage.setItem("opencode.settings.dat:defaultServerUrl", args.serverUrl)
|
||||||
|
|
||||||
|
const win = window as E2EWindow
|
||||||
|
win.__opencode_e2e = {
|
||||||
|
...win.__opencode_e2e,
|
||||||
|
model: { enabled: true },
|
||||||
|
prompt: { enabled: true },
|
||||||
|
terminal: { enabled: true, terminals: {} },
|
||||||
|
}
|
||||||
|
localStorage.setItem("opencode.global.dat:model", JSON.stringify({ recent: [args.model], user: [], variant: {} }))
|
||||||
|
},
|
||||||
|
{ directory: input.directory, serverUrl: origin, extra: input.extra ?? [], model: input.model ?? seedModel },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { expect }
|
export { expect }
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { clickListItem } from "../actions"
|
import { clickListItem } from "../actions"
|
||||||
|
|
||||||
test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
|
test.fixme("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
await page.locator(promptSelector).click()
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,49 @@
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
|
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
|
||||||
|
|
||||||
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
test("dialog edit project updates name and startup script", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async ({ slug }) => {
|
await project.open()
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const open = async () => {
|
const open = async () => {
|
||||||
const menu = await openProjectMenu(page, slug)
|
const menu = await openProjectMenu(page, project.slug)
|
||||||
await clickMenuItem(menu, /^Edit$/i, { force: true })
|
await clickMenuItem(menu, /^Edit$/i, { force: true })
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
const dialog = page.getByRole("dialog")
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
|
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
|
||||||
return dialog
|
return dialog
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = `e2e project ${Date.now()}`
|
const name = `e2e project ${Date.now()}`
|
||||||
const startup = `echo e2e_${Date.now()}`
|
const startup = `echo e2e_${Date.now()}`
|
||||||
|
|
||||||
const dialog = await open()
|
const dialog = await open()
|
||||||
|
|
||||||
const nameInput = dialog.getByLabel("Name")
|
const nameInput = dialog.getByLabel("Name")
|
||||||
await nameInput.fill(name)
|
await nameInput.fill(name)
|
||||||
|
|
||||||
const startupInput = dialog.getByLabel("Workspace startup script")
|
const startupInput = dialog.getByLabel("Workspace startup script")
|
||||||
await startupInput.fill(startup)
|
await startupInput.fill(startup)
|
||||||
|
|
||||||
await dialog.getByRole("button", { name: "Save" }).click()
|
await dialog.getByRole("button", { name: "Save" }).click()
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
const header = page.locator(".group\\/project").first()
|
await expect
|
||||||
await expect(header).toContainText(name)
|
.poll(
|
||||||
|
async () => {
|
||||||
const reopened = await open()
|
await page.reload()
|
||||||
await expect(reopened.getByLabel("Name")).toHaveValue(name)
|
await openSidebar(page)
|
||||||
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
|
const reopened = await open()
|
||||||
await reopened.getByRole("button", { name: "Cancel" }).click()
|
const value = await reopened.getByLabel("Name").inputValue()
|
||||||
await expect(reopened).toHaveCount(0)
|
const next = await reopened.getByLabel("Workspace startup script").inputValue()
|
||||||
})
|
await reopened.getByRole("button", { name: "Cancel" }).click()
|
||||||
|
await expect(reopened).toHaveCount(0)
|
||||||
|
return `${value}\n${next}`
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(`${name}\n${startup}`)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,41 +3,46 @@ import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, open
|
||||||
import { projectSwitchSelector } from "../selectors"
|
import { projectSwitchSelector } from "../selectors"
|
||||||
import { dirSlug } from "../utils"
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
test("closing active project navigates to another open project", async ({ page, withProject }) => {
|
test("closing active project navigates to another open project", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await project.open({ extra: [other] })
|
||||||
async ({ slug }) => {
|
await openSidebar(page)
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
await expect(otherButton).toBeVisible()
|
await expect(otherButton).toBeVisible()
|
||||||
await otherButton.click()
|
await otherButton.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||||
|
|
||||||
const menu = await openProjectMenu(page, otherSlug)
|
const menu = await openProjectMenu(page, otherSlug)
|
||||||
|
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||||
|
|
||||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => {
|
||||||
|
const pathname = new URL(page.url()).pathname
|
||||||
|
if (new RegExp(`^/${project.slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
||||||
|
if (pathname === "/") return "home"
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toMatch(/^(project|home)$/)
|
||||||
|
|
||||||
await expect
|
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
|
||||||
.poll(() => {
|
await expect
|
||||||
const pathname = new URL(page.url()).pathname
|
.poll(
|
||||||
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
async () => {
|
||||||
if (pathname === "/") return "home"
|
return await page.locator(projectSwitchSelector(otherSlug)).count()
|
||||||
return ""
|
},
|
||||||
})
|
{ timeout: 15_000 },
|
||||||
.toMatch(/^(project|home)$/)
|
)
|
||||||
|
.toBe(0)
|
||||||
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
|
|
||||||
await expect(otherButton).toHaveCount(0)
|
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,142 +1,93 @@
|
||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
import type { Page } from "@playwright/test"
|
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
|
import {
|
||||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
defocus,
|
||||||
|
createTestProject,
|
||||||
|
cleanupTestProject,
|
||||||
|
openSidebar,
|
||||||
|
setWorkspacesEnabled,
|
||||||
|
waitSession,
|
||||||
|
waitSlug,
|
||||||
|
} from "../actions"
|
||||||
|
import { projectSwitchSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { dirSlug, resolveDirectory } from "../utils"
|
import { dirSlug, resolveDirectory } from "../utils"
|
||||||
|
|
||||||
async function workspaces(page: Page, directory: string, enabled: boolean) {
|
test("can switch between projects from sidebar", async ({ page, project }) => {
|
||||||
await page.evaluate(
|
|
||||||
({ directory, enabled }: { directory: string; enabled: boolean }) => {
|
|
||||||
const key = "opencode.global.dat:layout"
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
const data = raw ? JSON.parse(raw) : {}
|
|
||||||
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
|
|
||||||
const current =
|
|
||||||
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
|
|
||||||
? sidebar.workspaces
|
|
||||||
: {}
|
|
||||||
const next = { ...current }
|
|
||||||
|
|
||||||
if (enabled) next[directory] = true
|
|
||||||
if (!enabled) delete next[directory]
|
|
||||||
|
|
||||||
localStorage.setItem(
|
|
||||||
key,
|
|
||||||
JSON.stringify({
|
|
||||||
...data,
|
|
||||||
sidebar: {
|
|
||||||
...sidebar,
|
|
||||||
workspaces: next,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ directory, enabled },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await project.open({ extra: [other] })
|
||||||
async ({ directory }) => {
|
await defocus(page)
|
||||||
await defocus(page)
|
|
||||||
|
|
||||||
const currentSlug = dirSlug(directory)
|
const currentSlug = dirSlug(project.directory)
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
await expect(otherButton).toBeVisible()
|
await expect(otherButton).toBeVisible()
|
||||||
await otherButton.click()
|
await otherButton.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||||
|
|
||||||
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
|
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
|
||||||
await expect(currentButton).toBeVisible()
|
await expect(currentButton).toBeVisible()
|
||||||
await currentButton.click()
|
await currentButton.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
|
test("switching back to a project opens the latest workspace session", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await project.open({ extra: [other] })
|
||||||
async ({ directory, slug, trackSession, trackDirectory }) => {
|
await defocus(page)
|
||||||
await defocus(page)
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
await workspaces(page, directory, true)
|
await openSidebar(page)
|
||||||
await page.reload()
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
await openSidebar(page)
|
|
||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
const raw = await waitSlug(page, [slug])
|
const raw = await waitSlug(page, [project.slug])
|
||||||
const dir = base64Decode(raw)
|
const dir = base64Decode(raw)
|
||||||
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
|
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
|
||||||
const space = await resolveDirectory(dir)
|
const space = await resolveDirectory(dir)
|
||||||
const next = dirSlug(space)
|
const next = dirSlug(space)
|
||||||
trackDirectory(space)
|
project.trackDirectory(space)
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
|
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
|
||||||
await expect(item).toBeVisible()
|
await expect(item).toBeVisible()
|
||||||
await item.hover()
|
await item.hover()
|
||||||
|
|
||||||
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
|
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
|
||||||
await expect(btn).toBeVisible()
|
await expect(btn).toBeVisible()
|
||||||
await btn.click({ force: true })
|
await btn.click({ force: true })
|
||||||
|
|
||||||
// A new workspace can be discovered via a transient slug before the route and sidebar
|
await waitSession(page, { directory: space })
|
||||||
// settle to the canonical workspace path on Windows, so interact with either and assert
|
|
||||||
// against the resolved workspace slug.
|
|
||||||
await waitSlug(page)
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
|
||||||
|
|
||||||
// Create a session by sending a prompt
|
const created = await project.user("test")
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await expect(prompt).toBeVisible()
|
|
||||||
await prompt.fill("test")
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
// Wait for the URL to update with the new session ID
|
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
|
||||||
|
|
||||||
const created = sessionIDFromUrl(page.url())
|
await openSidebar(page)
|
||||||
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
|
||||||
trackSession(created, space)
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
|
await expect(otherButton).toBeVisible()
|
||||||
|
await otherButton.click({ force: true })
|
||||||
|
await waitSession(page, { directory: other })
|
||||||
|
|
||||||
await openSidebar(page)
|
const rootButton = page.locator(projectSwitchSelector(project.slug)).first()
|
||||||
|
await expect(rootButton).toBeVisible()
|
||||||
|
await rootButton.click({ force: true })
|
||||||
|
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
await waitSession(page, { directory: space, sessionID: created })
|
||||||
await expect(otherButton).toBeVisible()
|
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
||||||
await otherButton.click()
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
|
||||||
|
|
||||||
const rootButton = page.locator(projectSwitchSelector(slug)).first()
|
|
||||||
await expect(rootButton).toBeVisible()
|
|
||||||
await rootButton.click()
|
|
||||||
|
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,109 +1,78 @@
|
||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
|
import {
|
||||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
openSidebar,
|
||||||
import { createSdk } from "../utils"
|
resolveSlug,
|
||||||
|
sessionIDFromUrl,
|
||||||
|
setWorkspacesEnabled,
|
||||||
|
waitDir,
|
||||||
|
waitSession,
|
||||||
|
waitSlug,
|
||||||
|
} from "../actions"
|
||||||
|
import { workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
|
|
||||||
async function waitWorkspaceReady(page: Page, slug: string) {
|
function item(space: { slug: string; raw: string }) {
|
||||||
|
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function button(space: { slug: string; raw: string }) {
|
||||||
|
return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect
|
await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 })
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
|
||||||
try {
|
|
||||||
await item.hover({ timeout: 500 })
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
const slug = await waitSlug(page, [root, ...seen])
|
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
|
||||||
const directory = base64Decode(slug)
|
await waitDir(page, next.directory)
|
||||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
|
||||||
return { slug, directory }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openWorkspaceNewSession(page: Page, slug: string) {
|
|
||||||
await waitWorkspaceReady(page, slug)
|
|
||||||
|
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
|
||||||
await item.hover()
|
|
||||||
|
|
||||||
const button = page.locator(workspaceNewSessionSelector(slug)).first()
|
|
||||||
await expect(button).toBeVisible()
|
|
||||||
await button.click({ force: true })
|
|
||||||
|
|
||||||
const next = await waitSlug(page)
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
|
async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) {
|
||||||
const next = await openWorkspaceNewSession(page, slug)
|
await waitWorkspaceReady(page, space)
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
const row = page.locator(item(space)).first()
|
||||||
await expect(prompt).toBeVisible()
|
await row.hover()
|
||||||
await expect(prompt).toBeEditable()
|
|
||||||
await prompt.click()
|
|
||||||
await expect(prompt).toBeFocused()
|
|
||||||
await prompt.fill(text)
|
|
||||||
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
|
||||||
await prompt.press("Enter")
|
|
||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url())).toBe(next)
|
const next = page.locator(button(space)).first()
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
await expect(next).toBeVisible()
|
||||||
|
await next.click({ force: true })
|
||||||
|
|
||||||
const sessionID = sessionIDFromUrl(page.url())
|
await waitSession(page, { directory: space.directory })
|
||||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
|
|
||||||
return { sessionID, slug: next }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sessionDirectory(directory: string, sessionID: string) {
|
async function createSessionFromWorkspace(
|
||||||
const info = await createSdk(directory)
|
project: Parameters<typeof test>[0]["project"],
|
||||||
.session.get({ sessionID })
|
page: Page,
|
||||||
.then((x) => x.data)
|
space: { slug: string; raw: string; directory: string },
|
||||||
.catch(() => undefined)
|
text: string,
|
||||||
if (!info) return ""
|
) {
|
||||||
return info.directory
|
await openWorkspaceNewSession(page, space)
|
||||||
|
return project.user(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
|
await project.open()
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await setWorkspacesEnabled(page, root, true)
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
|
|
||||||
const first = await createWorkspace(page, root, [])
|
const first = await createWorkspace(page, project.slug, [])
|
||||||
trackDirectory(first.directory)
|
project.trackDirectory(first.directory)
|
||||||
await waitWorkspaceReady(page, first.slug)
|
await waitWorkspaceReady(page, first)
|
||||||
|
|
||||||
const second = await createWorkspace(page, root, [first.slug])
|
const second = await createWorkspace(page, project.slug, [first.slug])
|
||||||
trackDirectory(second.directory)
|
project.trackDirectory(second.directory)
|
||||||
await waitWorkspaceReady(page, second.slug)
|
await waitWorkspaceReady(page, second)
|
||||||
|
|
||||||
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
await createSessionFromWorkspace(project, page, first, `workspace one ${Date.now()}`)
|
||||||
trackSession(firstSession.sessionID, first.directory)
|
await createSessionFromWorkspace(project, page, second, `workspace two ${Date.now()}`)
|
||||||
|
await createSessionFromWorkspace(project, page, first, `workspace one again ${Date.now()}`)
|
||||||
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
|
||||||
trackSession(secondSession.sessionID, second.directory)
|
|
||||||
|
|
||||||
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
|
||||||
trackSession(thirdSession.sessionID, first.directory)
|
|
||||||
|
|
||||||
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
|
|
||||||
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
|
|
||||||
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
|
||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
|
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
|
@ -13,29 +13,32 @@ import {
|
||||||
confirmDialog,
|
confirmDialog,
|
||||||
openSidebar,
|
openSidebar,
|
||||||
openWorkspaceMenu,
|
openWorkspaceMenu,
|
||||||
|
resolveSlug,
|
||||||
setWorkspacesEnabled,
|
setWorkspacesEnabled,
|
||||||
slugFromUrl,
|
slugFromUrl,
|
||||||
|
waitDir,
|
||||||
waitSlug,
|
waitSlug,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
import { inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||||
import { createSdk, dirSlug } from "../utils"
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
async function setupWorkspaceTest(page: Page, project: { slug: string; trackDirectory: (directory: string) => void }) {
|
||||||
const rootSlug = project.slug
|
const rootSlug = project.slug
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
await setWorkspacesEnabled(page, rootSlug, true)
|
await setWorkspacesEnabled(page, rootSlug, true)
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const slug = await waitSlug(page, [rootSlug])
|
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
|
||||||
const dir = base64Decode(slug)
|
await waitDir(page, next.directory)
|
||||||
|
project.trackDirectory(next.directory)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
const item = page.locator(workspaceItemSelector(next.slug)).first()
|
||||||
try {
|
try {
|
||||||
await item.hover({ timeout: 500 })
|
await item.hover({ timeout: 500 })
|
||||||
return true
|
return true
|
||||||
|
|
@ -47,47 +50,195 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||||
)
|
)
|
||||||
.toBe(true)
|
.toBe(true)
|
||||||
|
|
||||||
return { rootSlug, slug, directory: dir }
|
return { rootSlug, slug: next.slug, directory: next.directory }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
|
test("can enable and disable workspaces from project menu", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
await withProject(async ({ slug }) => {
|
await openSidebar(page)
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||||
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
||||||
|
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
|
await expect(page.locator(workspaceItemSelector(project.slug)).first()).toBeVisible()
|
||||||
|
|
||||||
await setWorkspacesEnabled(page, slug, false)
|
await setWorkspacesEnabled(page, project.slug, false)
|
||||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
await expect(page.locator(workspaceItemSelector(project.slug))).toHaveCount(0)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("can create a workspace", async ({ page, withProject }) => {
|
test("can create a workspace", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
const next = await resolveSlug(await waitSlug(page, [project.slug]))
|
||||||
|
await waitDir(page, next.directory)
|
||||||
|
project.trackDirectory(next.directory)
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const item = page.locator(workspaceItemSelector(next.slug)).first()
|
||||||
|
try {
|
||||||
|
await item.hover({ timeout: 500 })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("non-git projects keep workspace mode disabled", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async ({ slug }) => {
|
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
|
||||||
await openSidebar(page)
|
const nonGitSlug = dirSlug(nonGit)
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
try {
|
||||||
const workspaceSlug = await waitSlug(page, [slug])
|
await project.open({ extra: [nonGit] })
|
||||||
const workspaceDir = base64Decode(workspaceSlug)
|
await page.goto(`/${nonGitSlug}/session`)
|
||||||
|
|
||||||
|
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
||||||
|
|
||||||
|
const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
|
||||||
|
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
||||||
|
await expect(page.getByRole("button", { name: "Create Git repository" })).toBeVisible()
|
||||||
|
} finally {
|
||||||
|
await cleanupTestProject(nonGit)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can rename a workspace", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
|
const { slug } = await setupWorkspaceTest(page, project)
|
||||||
|
|
||||||
|
const rename = `e2e workspace ${Date.now()}`
|
||||||
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
|
await clickMenuItem(menu, /^Rename$/i, { force: true })
|
||||||
|
|
||||||
|
await expect(menu).toHaveCount(0)
|
||||||
|
|
||||||
|
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
const input = item.locator(inlineInputSelector).first()
|
||||||
|
const shown = await input
|
||||||
|
.isVisible()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
if (!shown) {
|
||||||
|
const retry = await openWorkspaceMenu(page, slug)
|
||||||
|
await clickMenuItem(retry, /^Rename$/i, { force: true })
|
||||||
|
await expect(retry).toHaveCount(0)
|
||||||
|
}
|
||||||
|
await expect(input).toBeVisible()
|
||||||
|
await input.fill(rename)
|
||||||
|
await input.press("Enter")
|
||||||
|
await expect(item).toContainText(rename)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can reset a workspace", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
|
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
|
||||||
|
|
||||||
|
const readme = path.join(createdDir, "README.md")
|
||||||
|
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
|
||||||
|
const original = await fs.readFile(readme, "utf8")
|
||||||
|
const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
|
||||||
|
await fs.writeFile(readme, dirty, "utf8")
|
||||||
|
await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await fs
|
||||||
|
.stat(extra)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
})
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const files = await project.sdk.file
|
||||||
|
.status({ directory: createdDir })
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [])
|
||||||
|
return files.length
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
|
await clickMenuItem(menu, /^Reset$/i, { force: true })
|
||||||
|
await confirmDialog(page, /^Reset workspace$/i)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const files = await project.sdk.file
|
||||||
|
.status({ directory: createdDir })
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [])
|
||||||
|
return files.length
|
||||||
|
},
|
||||||
|
{ timeout: 120_000 },
|
||||||
|
)
|
||||||
|
.toBe(0)
|
||||||
|
|
||||||
|
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 120_000 }).toBe(original)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await fs
|
||||||
|
.stat(extra)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
})
|
||||||
|
.toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can reorder workspaces by drag and drop", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
const rootSlug = project.slug
|
||||||
|
|
||||||
|
const listSlugs = async () => {
|
||||||
|
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
|
||||||
|
const slugs = await nodes.evaluateAll((els) => {
|
||||||
|
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
|
||||||
|
})
|
||||||
|
return slugs
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitReady = async (slug: string) => {
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||||
try {
|
try {
|
||||||
await item.hover({ timeout: 500 })
|
await item.hover({ timeout: 500 })
|
||||||
return true
|
return true
|
||||||
|
|
@ -98,276 +249,120 @@ test("can create a workspace", async ({ page, withProject }) => {
|
||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
)
|
)
|
||||||
.toBe(true)
|
.toBe(true)
|
||||||
|
|
||||||
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
|
|
||||||
|
|
||||||
await cleanupTestProject(workspaceDir)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
|
|
||||||
const nonGitSlug = dirSlug(nonGit)
|
|
||||||
|
|
||||||
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withProject(async () => {
|
|
||||||
await page.goto(`/${nonGitSlug}/session`)
|
|
||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
|
||||||
|
|
||||||
const activeDir = base64Decode(slugFromUrl(page.url()))
|
|
||||||
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
|
||||||
|
|
||||||
const trigger = page.locator('[data-action="project-menu"]').first()
|
|
||||||
const hasMenu = await trigger
|
|
||||||
.isVisible()
|
|
||||||
.then((x) => x)
|
|
||||||
.catch(() => false)
|
|
||||||
if (!hasMenu) return
|
|
||||||
|
|
||||||
await trigger.click({ force: true })
|
|
||||||
|
|
||||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
|
||||||
await expect(menu).toBeVisible()
|
|
||||||
|
|
||||||
const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
|
|
||||||
|
|
||||||
await expect(toggle).toBeVisible()
|
|
||||||
await expect(toggle).toBeDisabled()
|
|
||||||
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
await cleanupTestProject(nonGit)
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
test("can rename a workspace", async ({ page, withProject }) => {
|
const drag = async (from: string, to: string) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
const src = page.locator(workspaceItemSelector(from)).first()
|
||||||
|
const dst = page.locator(workspaceItemSelector(to)).first()
|
||||||
|
|
||||||
await withProject(async (project) => {
|
const a = await src.boundingBox()
|
||||||
const { slug } = await setupWorkspaceTest(page, project)
|
const b = await dst.boundingBox()
|
||||||
|
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
||||||
|
|
||||||
const rename = `e2e workspace ${Date.now()}`
|
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
|
||||||
const menu = await openWorkspaceMenu(page, slug)
|
await page.mouse.down()
|
||||||
await clickMenuItem(menu, /^Rename$/i, { force: true })
|
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
|
||||||
|
await page.mouse.up()
|
||||||
|
}
|
||||||
|
|
||||||
await expect(menu).toHaveCount(0)
|
await openSidebar(page)
|
||||||
|
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
await setWorkspacesEnabled(page, rootSlug, true)
|
||||||
await expect(item).toBeVisible()
|
|
||||||
const input = item.locator(inlineInputSelector).first()
|
|
||||||
await expect(input).toBeVisible()
|
|
||||||
await input.fill(rename)
|
|
||||||
await input.press("Enter")
|
|
||||||
await expect(item).toContainText(rename)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("can reset a workspace", async ({ page, sdk, withProject }) => {
|
const workspaces = [] as { directory: string; slug: string }[]
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
for (const _ of [0, 1]) {
|
||||||
|
const prev = slugFromUrl(page.url())
|
||||||
await withProject(async (project) => {
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
|
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
|
||||||
|
await waitDir(page, next.directory)
|
||||||
const readme = path.join(createdDir, "README.md")
|
project.trackDirectory(next.directory)
|
||||||
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
|
workspaces.push(next)
|
||||||
const original = await fs.readFile(readme, "utf8")
|
|
||||||
const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
|
|
||||||
await fs.writeFile(readme, dirty, "utf8")
|
|
||||||
await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await fs
|
|
||||||
.stat(extra)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
})
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const files = await sdk.file
|
|
||||||
.status({ directory: createdDir })
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
.catch(() => [])
|
|
||||||
return files.length
|
|
||||||
})
|
|
||||||
.toBeGreaterThan(0)
|
|
||||||
|
|
||||||
const menu = await openWorkspaceMenu(page, slug)
|
|
||||||
await clickMenuItem(menu, /^Reset$/i, { force: true })
|
|
||||||
await confirmDialog(page, /^Reset workspace$/i)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const files = await sdk.file
|
|
||||||
.status({ directory: createdDir })
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
.catch(() => [])
|
|
||||||
return files.length
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(0)
|
|
||||||
|
|
||||||
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await fs
|
|
||||||
.stat(extra)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
})
|
|
||||||
.toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("can delete a workspace", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
await withProject(async (project) => {
|
|
||||||
const sdk = createSdk(project.directory)
|
|
||||||
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const worktrees = await sdk.worktree
|
|
||||||
.list()
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
.catch(() => [] as string[])
|
|
||||||
return worktrees.includes(directory)
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
const menu = await openWorkspaceMenu(page, slug)
|
|
||||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
|
||||||
await confirmDialog(page, /^Delete workspace$/i)
|
|
||||||
|
|
||||||
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const worktrees = await sdk.worktree
|
|
||||||
.list()
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
.catch(() => [] as string[])
|
|
||||||
return worktrees.includes(directory)
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(false)
|
|
||||||
|
|
||||||
await project.gotoSession()
|
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
|
}
|
||||||
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
|
||||||
})
|
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
|
||||||
|
|
||||||
|
const a = workspaces[0].slug
|
||||||
|
const b = workspaces[1].slug
|
||||||
|
|
||||||
|
await waitReady(a)
|
||||||
|
await waitReady(b)
|
||||||
|
|
||||||
|
const list = async () => {
|
||||||
|
const slugs = await listSlugs()
|
||||||
|
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const slugs = await list()
|
||||||
|
return slugs.length === 2
|
||||||
|
})
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const before = await list()
|
||||||
|
const from = before[1]
|
||||||
|
const to = before[0]
|
||||||
|
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
|
||||||
|
|
||||||
|
await drag(from, to)
|
||||||
|
|
||||||
|
await expect.poll(async () => await list()).toEqual([from, to])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
|
test("can delete a workspace", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
await withProject(async ({ slug: rootSlug }) => {
|
await project.open()
|
||||||
const workspaces = [] as { directory: string; slug: string }[]
|
|
||||||
|
|
||||||
const listSlugs = async () => {
|
const rootSlug = project.slug
|
||||||
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
|
await openSidebar(page)
|
||||||
const slugs = await nodes.evaluateAll((els) => {
|
await setWorkspacesEnabled(page, rootSlug, true)
|
||||||
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
|
|
||||||
})
|
|
||||||
return slugs
|
|
||||||
}
|
|
||||||
|
|
||||||
const waitReady = async (slug: string) => {
|
const created = await project.sdk.worktree.create({ directory: project.directory }).then((res) => res.data)
|
||||||
await expect
|
if (!created?.directory) throw new Error("Failed to create workspace for delete test")
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
|
||||||
try {
|
|
||||||
await item.hover({ timeout: 500 })
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const drag = async (from: string, to: string) => {
|
const directory = created.directory
|
||||||
const src = page.locator(workspaceItemSelector(from)).first()
|
const slug = dirSlug(directory)
|
||||||
const dst = page.locator(workspaceItemSelector(to)).first()
|
project.trackDirectory(directory)
|
||||||
|
|
||||||
const a = await src.boundingBox()
|
await page.reload()
|
||||||
const b = await dst.boundingBox()
|
await openSidebar(page)
|
||||||
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible({ timeout: 60_000 })
|
||||||
|
|
||||||
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
|
await expect
|
||||||
await page.mouse.down()
|
.poll(
|
||||||
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
|
async () => {
|
||||||
await page.mouse.up()
|
const worktrees = await project.sdk.worktree
|
||||||
}
|
.list()
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
return worktrees.includes(directory)
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
try {
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
await openSidebar(page)
|
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||||
|
await confirmDialog(page, /^Delete workspace$/i)
|
||||||
|
|
||||||
await setWorkspacesEnabled(page, rootSlug, true)
|
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
|
||||||
|
|
||||||
for (const _ of [0, 1]) {
|
await expect
|
||||||
const prev = slugFromUrl(page.url())
|
.poll(
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
async () => {
|
||||||
const slug = await waitSlug(page, [rootSlug, prev])
|
const worktrees = await project.sdk.worktree
|
||||||
const dir = base64Decode(slug)
|
.list()
|
||||||
workspaces.push({ slug, directory: dir })
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
return worktrees.includes(directory)
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(false)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
}
|
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
|
||||||
|
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
||||||
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
|
|
||||||
|
|
||||||
const a = workspaces[0].slug
|
|
||||||
const b = workspaces[1].slug
|
|
||||||
|
|
||||||
await waitReady(a)
|
|
||||||
await waitReady(b)
|
|
||||||
|
|
||||||
const list = async () => {
|
|
||||||
const slugs = await listSlugs()
|
|
||||||
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const slugs = await list()
|
|
||||||
return slugs.length === 2
|
|
||||||
})
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
const before = await list()
|
|
||||||
const from = before[1]
|
|
||||||
const to = before[0]
|
|
||||||
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
|
|
||||||
|
|
||||||
await drag(from, to)
|
|
||||||
|
|
||||||
await expect.poll(async () => await list()).toEqual([from, to])
|
|
||||||
} finally {
|
|
||||||
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
type Hit = { body: Record<string, unknown> }
|
||||||
|
|
||||||
|
export function bodyText(hit: Hit) {
|
||||||
|
return JSON.stringify(hit.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match requests whose body contains the exact serialized tool input.
|
||||||
|
* The seed prompts embed JSON.stringify(input) in the prompt text, which
|
||||||
|
* gets escaped again inside the JSON body — so we double-escape to match.
|
||||||
|
*/
|
||||||
|
export function inputMatch(input: unknown) {
|
||||||
|
const escaped = JSON.stringify(JSON.stringify(input)).slice(1, -1)
|
||||||
|
return (hit: Hit) => bodyText(hit).includes(escaped)
|
||||||
|
}
|
||||||
|
|
@ -1,47 +1,25 @@
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
import { assistantText, withSession } from "../actions"
|
||||||
|
|
||||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
|
||||||
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
||||||
// the connection open while the agent works, causing "Failed to fetch" over
|
// the connection open while the agent works, causing "Failed to fetch" over
|
||||||
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
||||||
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
|
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, project, assistant }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
// Simulate Tailscale/VPN killing the long-lived sync connection
|
// Simulate Tailscale/VPN killing the long-lived sync connection
|
||||||
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const token = `E2E_ASYNC_${Date.now()}`
|
const token = `E2E_ASYNC_${Date.now()}`
|
||||||
await page.locator(promptSelector).click()
|
await project.open()
|
||||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
await assistant.reply(token)
|
||||||
await page.keyboard.press("Enter")
|
const sessionID = await project.prompt(`Reply with exactly: ${token}`)
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
|
||||||
const sessionID = sessionIDFromUrl(page.url())!
|
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
||||||
|
|
||||||
try {
|
|
||||||
// Agent response arrives via SSE despite sync endpoint being dead
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
|
||||||
return messages
|
|
||||||
.filter((m) => m.info.role === "assistant")
|
|
||||||
.flatMap((m) => m.parts)
|
|
||||||
.filter((p) => p.type === "text")
|
|
||||||
.map((p) => p.text)
|
|
||||||
.join("\n")
|
|
||||||
},
|
|
||||||
{ timeout: 90_000 },
|
|
||||||
)
|
|
||||||
.toContain(token)
|
|
||||||
} finally {
|
|
||||||
await cleanupSession({ sdk, sessionID })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
import type { Locator, Page } from "@playwright/test"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptAgentSelector, promptModelSelector, promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
type Probe = {
|
||||||
|
agent?: string
|
||||||
|
model?: { providerID: string; modelID: string; name?: string }
|
||||||
|
models?: Array<{ providerID: string; modelID: string; name: string }>
|
||||||
|
agents?: Array<{ name: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(page: Page): Promise<Probe | null> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const win = window as Window & {
|
||||||
|
__opencode_e2e?: {
|
||||||
|
model?: {
|
||||||
|
current?: Probe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return win.__opencode_e2e?.model?.current ?? null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function state(page: Page) {
|
||||||
|
const value = await probe(page)
|
||||||
|
if (!value) throw new Error("Failed to resolve model selection probe")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ready(page: Page) {
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await prompt.click()
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
|
await prompt.pressSequentially("focus")
|
||||||
|
return prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
async function body(prompt: Locator) {
|
||||||
|
return prompt.evaluate((el) => (el as HTMLElement).innerText)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("agent select returns focus to the prompt", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = await ready(page)
|
||||||
|
|
||||||
|
const info = await state(page)
|
||||||
|
const next = info.agents?.map((item) => item.name).find((name) => name !== info.agent)
|
||||||
|
test.skip(!next, "only one agent available")
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
await page.locator(`${promptAgentSelector} [data-slot="select-select-trigger"]`).first().click()
|
||||||
|
|
||||||
|
const item = page.locator('[data-slot="select-select-item"]').filter({ hasText: next }).first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click({ force: true })
|
||||||
|
|
||||||
|
await expect(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()).toHaveText(
|
||||||
|
next,
|
||||||
|
)
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
|
await prompt.pressSequentially(" agent")
|
||||||
|
await expect.poll(() => body(prompt)).toContain("focus agent")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("model select returns focus to the prompt", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = await ready(page)
|
||||||
|
|
||||||
|
const info = await state(page)
|
||||||
|
const key = info.model ? `${info.model.providerID}:${info.model.modelID}` : null
|
||||||
|
const next = info.models?.find((item) => `${item.providerID}:${item.modelID}` !== key)
|
||||||
|
test.skip(!next, "only one model available")
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
await page.locator(`${promptModelSelector} [data-action="prompt-model"]`).first().click()
|
||||||
|
|
||||||
|
const item = page.locator(`[data-slot="list-item"][data-key="${next.providerID}:${next.modelID}"]`).first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click({ force: true })
|
||||||
|
|
||||||
|
await expect(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()).toHaveText(next.name)
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
|
await prompt.pressSequentially(" model")
|
||||||
|
await expect.poll(() => body(prompt)).toContain("focus model")
|
||||||
|
})
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { withSession } from "../actions"
|
import { assistantText } from "../actions"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
type Sdk = ReturnType<typeof createSdk>
|
||||||
|
|
||||||
const isBash = (part: unknown): part is ToolPart => {
|
const isBash = (part: unknown): part is ToolPart => {
|
||||||
if (!part || typeof part !== "object") return false
|
if (!part || typeof part !== "object") return false
|
||||||
|
|
@ -13,54 +15,15 @@ const isBash = (part: unknown): part is ToolPart => {
|
||||||
return "state" in part
|
return "state" in part
|
||||||
}
|
}
|
||||||
|
|
||||||
async function edge(page: Page, pos: "start" | "end") {
|
|
||||||
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
|
|
||||||
const selection = window.getSelection()
|
|
||||||
if (!selection) return
|
|
||||||
|
|
||||||
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
|
||||||
const nodes: Text[] = []
|
|
||||||
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
|
|
||||||
nodes.push(node as Text)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nodes.length === 0) {
|
|
||||||
const node = document.createTextNode("")
|
|
||||||
el.appendChild(node)
|
|
||||||
nodes.push(node)
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
|
|
||||||
const range = document.createRange()
|
|
||||||
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
|
|
||||||
range.collapse(true)
|
|
||||||
selection.removeAllRanges()
|
|
||||||
selection.addRange(range)
|
|
||||||
}, pos)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function wait(page: Page, value: string) {
|
async function wait(page: Page, value: string) {
|
||||||
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
|
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
|
async function reply(sdk: Sdk, sessionID: string, token: string) {
|
||||||
await expect
|
await expect.poll(() => assistantText(sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
|
||||||
return messages
|
|
||||||
.filter((item) => item.info.role === "assistant")
|
|
||||||
.flatMap((item) => item.parts)
|
|
||||||
.filter((item) => item.type === "text")
|
|
||||||
.map((item) => item.text)
|
|
||||||
.join("\n")
|
|
||||||
},
|
|
||||||
{ timeout: 90_000 },
|
|
||||||
)
|
|
||||||
.toContain(token)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
|
async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) {
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
|
|
@ -79,103 +42,105 @@ async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string,
|
||||||
.toContain(token)
|
.toContain(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
|
test("prompt history restores unsent draft with arrow navigation", async ({ page, project, assistant }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
|
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
||||||
await gotoSession(session.id)
|
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
||||||
|
const first = `Reply with exactly: ${firstToken}`
|
||||||
|
const second = `Reply with exactly: ${secondToken}`
|
||||||
|
const draft = `draft ${Date.now()}`
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
await project.open()
|
||||||
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
await assistant.reply(firstToken)
|
||||||
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
const sessionID = await project.prompt(first)
|
||||||
const first = `Reply with exactly: ${firstToken}`
|
await wait(page, "")
|
||||||
const second = `Reply with exactly: ${secondToken}`
|
await reply(project.sdk, sessionID, firstToken)
|
||||||
const draft = `draft ${Date.now()}`
|
|
||||||
|
|
||||||
await prompt.click()
|
await assistant.reply(secondToken)
|
||||||
await page.keyboard.type(first)
|
await project.prompt(second)
|
||||||
await page.keyboard.press("Enter")
|
await wait(page, "")
|
||||||
await wait(page, "")
|
await reply(project.sdk, sessionID, secondToken)
|
||||||
await reply(sdk, session.id, firstToken)
|
|
||||||
|
|
||||||
await prompt.click()
|
const prompt = page.locator(promptSelector)
|
||||||
await page.keyboard.type(second)
|
await prompt.click()
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.type(draft)
|
||||||
await wait(page, "")
|
await wait(page, draft)
|
||||||
await reply(sdk, session.id, secondToken)
|
|
||||||
|
|
||||||
await prompt.click()
|
await prompt.fill("")
|
||||||
await page.keyboard.type(draft)
|
await wait(page, "")
|
||||||
await wait(page, draft)
|
|
||||||
|
|
||||||
await edge(page, "start")
|
await page.keyboard.press("ArrowUp")
|
||||||
await page.keyboard.press("ArrowUp")
|
await wait(page, second)
|
||||||
await wait(page, second)
|
|
||||||
|
|
||||||
await page.keyboard.press("ArrowUp")
|
await page.keyboard.press("ArrowUp")
|
||||||
await wait(page, first)
|
await wait(page, first)
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
await page.keyboard.press("ArrowDown")
|
||||||
await wait(page, second)
|
await wait(page, second)
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
await page.keyboard.press("ArrowDown")
|
||||||
await wait(page, draft)
|
await wait(page, "")
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
|
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
||||||
await gotoSession(session.id)
|
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
||||||
|
const normalToken = `E2E_NORMAL_${Date.now()}`
|
||||||
|
const first = `echo ${firstToken}`
|
||||||
|
const second = `echo ${secondToken}`
|
||||||
|
const normal = `Reply with exactly: ${normalToken}`
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
await gotoSession()
|
||||||
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
|
||||||
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
|
||||||
const normalToken = `E2E_NORMAL_${Date.now()}`
|
|
||||||
const first = `echo ${firstToken}`
|
|
||||||
const second = `echo ${secondToken}`
|
|
||||||
const normal = `Reply with exactly: ${normalToken}`
|
|
||||||
|
|
||||||
await prompt.click()
|
const prompt = page.locator(promptSelector)
|
||||||
await page.keyboard.type("!")
|
|
||||||
await page.keyboard.type(first)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
await wait(page, "")
|
|
||||||
await shell(sdk, session.id, first, firstToken)
|
|
||||||
|
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.type("!")
|
await page.keyboard.type("!")
|
||||||
await page.keyboard.type(second)
|
await page.keyboard.type(first)
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
await wait(page, "")
|
await wait(page, "")
|
||||||
await shell(sdk, session.id, second, secondToken)
|
|
||||||
|
|
||||||
await prompt.click()
|
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||||
await page.keyboard.type("!")
|
const sessionID = sessionIDFromUrl(page.url())!
|
||||||
await page.keyboard.press("ArrowUp")
|
await shell(sdk, sessionID, first, firstToken)
|
||||||
await wait(page, second)
|
|
||||||
|
|
||||||
await page.keyboard.press("ArrowUp")
|
await prompt.click()
|
||||||
await wait(page, first)
|
await page.keyboard.type("!")
|
||||||
|
await page.keyboard.type(second)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await shell(sdk, sessionID, second, secondToken)
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
await page.keyboard.press("Escape")
|
||||||
await wait(page, second)
|
await wait(page, "")
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
await prompt.click()
|
||||||
await wait(page, "")
|
await page.keyboard.type("!")
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, second)
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
await page.keyboard.press("ArrowUp")
|
||||||
await wait(page, "")
|
await wait(page, first)
|
||||||
|
|
||||||
await prompt.click()
|
await page.keyboard.press("ArrowDown")
|
||||||
await page.keyboard.type(normal)
|
await wait(page, second)
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
await wait(page, "")
|
|
||||||
await reply(sdk, session.id, normalToken)
|
|
||||||
|
|
||||||
await prompt.click()
|
await page.keyboard.press("ArrowDown")
|
||||||
await page.keyboard.press("ArrowUp")
|
await wait(page, "")
|
||||||
await wait(page, normal)
|
|
||||||
})
|
await page.keyboard.press("Escape")
|
||||||
|
await wait(page, "")
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(normal)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await reply(sdk, sessionID, normalToken)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, normal)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,18 @@ test("shift+enter inserts a newline without submitting", async ({ page, gotoSess
|
||||||
await expect(page).toHaveURL(/\/session\/?$/)
|
await expect(page).toHaveURL(/\/session\/?$/)
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
await prompt.click()
|
await prompt.focus()
|
||||||
await page.keyboard.type("line one")
|
await expect(prompt).toBeFocused()
|
||||||
await page.keyboard.press("Shift+Enter")
|
|
||||||
await page.keyboard.type("line two")
|
await prompt.pressSequentially("line one")
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
|
|
||||||
|
await prompt.press("Shift+Enter")
|
||||||
|
await expect(page).toHaveURL(/\/session\/?$/)
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
|
|
||||||
|
await prompt.pressSequentially("line two")
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/?$/)
|
await expect(page).toHaveURL(/\/session\/?$/)
|
||||||
await expect(prompt).toContainText("line one")
|
await expect.poll(() => prompt.evaluate((el) => el.innerText)).toBe("line one\nline two")
|
||||||
await expect(prompt).toContainText("line two")
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { sessionIDFromUrl } from "../actions"
|
import { closeDialog, openSettings, withSession } from "../actions"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptModelSelector, promptSelector, promptVariantSelector } from "../selectors"
|
||||||
import { createSdk } from "../utils"
|
|
||||||
|
|
||||||
const isBash = (part: unknown): part is ToolPart => {
|
const isBash = (part: unknown): part is ToolPart => {
|
||||||
if (!part || typeof part !== "object") return false
|
if (!part || typeof part !== "object") return false
|
||||||
|
|
@ -11,34 +10,34 @@ const isBash = (part: unknown): part is ToolPart => {
|
||||||
return "state" in part
|
return "state" in part
|
||||||
}
|
}
|
||||||
|
|
||||||
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
|
test("shell mode runs a command in the project directory", async ({ page, project }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
await project.open()
|
||||||
const sdk = createSdk(directory)
|
const cmd = process.platform === "win32" ? "dir" : "command ls"
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
const cmd = process.platform === "win32" ? "dir" : "ls"
|
|
||||||
|
|
||||||
await gotoSession()
|
await withSession(project.sdk, `e2e shell ${Date.now()}`, async (session) => {
|
||||||
await prompt.click()
|
project.trackSession(session.id)
|
||||||
await page.keyboard.type("!")
|
await project.gotoSession(session.id)
|
||||||
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
const dialog = await openSettings(page)
|
||||||
|
const toggle = dialog.locator('[data-action="settings-auto-accept-permissions"]').first()
|
||||||
await page.keyboard.type(cmd)
|
const input = toggle.locator('[data-slot="switch-input"]').first()
|
||||||
await page.keyboard.press("Enter")
|
await expect(toggle).toBeVisible()
|
||||||
|
if ((await input.getAttribute("aria-checked")) !== "true") {
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
await toggle.locator('[data-slot="switch-control"]').click()
|
||||||
|
await expect(input).toHaveAttribute("aria-checked", "true")
|
||||||
const id = sessionIDFromUrl(page.url())
|
}
|
||||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
await closeDialog(page, dialog)
|
||||||
trackSession(id, directory)
|
await project.shell(cmd)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
|
const list = await project.sdk.session
|
||||||
|
.messages({ sessionID: session.id, limit: 50 })
|
||||||
|
.then((x) => x.data ?? [])
|
||||||
const msg = list.findLast(
|
const msg = list.findLast(
|
||||||
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
|
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === project.directory,
|
||||||
)
|
)
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
|
|
||||||
|
|
@ -51,12 +50,25 @@ test("shell mode runs a command in the project directory", async ({ page, withPr
|
||||||
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
||||||
if (!output.includes("README.md")) return
|
if (!output.includes("README.md")) return
|
||||||
|
|
||||||
return { cwd: directory, output }
|
return { cwd: project.directory, output }
|
||||||
},
|
},
|
||||||
{ timeout: 90_000 },
|
{ timeout: 90_000 },
|
||||||
)
|
)
|
||||||
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
|
.toEqual(expect.objectContaining({ cwd: project.directory, output: expect.stringContaining("README.md") }))
|
||||||
|
|
||||||
await expect(prompt).toHaveText("")
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("shell mode unmounts model and variant controls", async ({ page, project }) => {
|
||||||
|
await project.open()
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector).first()
|
||||||
|
await expect(page.locator(promptModelSelector)).toHaveCount(1)
|
||||||
|
await expect(page.locator(promptVariantSelector)).toHaveCount(1)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
|
||||||
|
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
||||||
|
await expect(page.locator(promptModelSelector)).toHaveCount(0)
|
||||||
|
await expect(page.locator(promptVariantSelector)).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,16 @@ async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
|
||||||
.toBeGreaterThan(0)
|
.toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
|
test("/share and /unshare update session share state", async ({ page, project }) => {
|
||||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||||
|
|
||||||
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
await project.open()
|
||||||
|
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
||||||
|
project.trackSession(session.id)
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
|
|
||||||
await seed(sdk, session.id)
|
await seed(project.sdk, session.id)
|
||||||
await gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.type("/share")
|
await page.keyboard.type("/share")
|
||||||
|
|
@ -39,7 +41,7 @@ test("/share and /unshare update session share state", async ({ page, sdk, gotoS
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.share?.url || undefined
|
return data?.share?.url || undefined
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
|
|
@ -54,7 +56,7 @@ test("/share and /unshare update session share state", async ({ page, sdk, gotoS
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.share?.url || undefined
|
return data?.share?.url || undefined
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
import { runPromptSlash, waitTerminalFocusIdle } from "../actions"
|
||||||
import { promptSelector, terminalSelector } from "../selectors"
|
import { promptSelector, terminalSelector } from "../selectors"
|
||||||
|
|
||||||
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||||
|
|
@ -9,15 +10,9 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||||
|
|
||||||
await expect(terminal).not.toBeVisible()
|
await expect(terminal).not.toBeVisible()
|
||||||
|
|
||||||
await prompt.click()
|
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
|
||||||
await page.keyboard.type("/terminal")
|
await waitTerminalFocusIdle(page, { term: terminal })
|
||||||
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
await expect(terminal).toBeVisible()
|
|
||||||
|
|
||||||
await prompt.click()
|
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
|
||||||
await page.keyboard.type("/terminal")
|
|
||||||
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
await expect(terminal).not.toBeVisible()
|
await expect(terminal).not.toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { assistantText } from "../actions"
|
||||||
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
|
||||||
|
|
||||||
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
test("can send a prompt and receive a reply", async ({ page, project, assistant }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const pageErrors: string[] = []
|
const pageErrors: string[] = []
|
||||||
|
|
@ -11,42 +10,16 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
|
||||||
}
|
}
|
||||||
page.on("pageerror", onPageError)
|
page.on("pageerror", onPageError)
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const token = `E2E_OK_${Date.now()}`
|
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
|
||||||
|
|
||||||
const sessionID = (() => {
|
|
||||||
const id = sessionIDFromUrl(page.url())
|
|
||||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
|
||||||
return id
|
|
||||||
})()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await expect
|
const token = `E2E_OK_${Date.now()}`
|
||||||
.poll(
|
await project.open()
|
||||||
async () => {
|
await assistant.reply(token)
|
||||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
const sessionID = await project.prompt(`Reply with exactly: ${token}`)
|
||||||
return messages
|
|
||||||
.filter((m) => m.info.role === "assistant")
|
|
||||||
.flatMap((m) => m.parts)
|
|
||||||
.filter((p) => p.type === "text")
|
|
||||||
.map((p) => p.text)
|
|
||||||
.join("\n")
|
|
||||||
},
|
|
||||||
{ timeout: 90_000 },
|
|
||||||
)
|
|
||||||
|
|
||||||
.toContain(token)
|
await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
|
||||||
|
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
|
||||||
} finally {
|
} finally {
|
||||||
page.off("pageerror", onPageError)
|
page.off("pageerror", onPageError)
|
||||||
await cleanupSession({ sdk, sessionID })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageErrors.length > 0) {
|
if (pageErrors.length > 0) {
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,20 @@
|
||||||
export const promptSelector = '[data-component="prompt-input"]'
|
export const promptSelector = '[data-component="prompt-input"]'
|
||||||
export const terminalSelector = '[data-component="terminal"]'
|
const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
|
||||||
|
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
|
||||||
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
||||||
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
||||||
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
||||||
export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
|
|
||||||
export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
|
|
||||||
export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
|
|
||||||
export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
|
|
||||||
export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
|
|
||||||
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
|
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
|
||||||
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
|
|
||||||
|
|
||||||
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
||||||
|
export const promptAgentSelector = '[data-component="prompt-agent-control"]'
|
||||||
|
export const promptModelSelector = '[data-component="prompt-model-control"]'
|
||||||
|
export const promptVariantSelector = '[data-component="prompt-variant-control"]'
|
||||||
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
||||||
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
|
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
|
||||||
export const settingsThemeSelector = '[data-action="settings-theme"]'
|
export const settingsThemeSelector = '[data-action="settings-theme"]'
|
||||||
export const settingsFontSelector = '[data-action="settings-font"]'
|
export const settingsCodeFontSelector = '[data-action="settings-code-font"]'
|
||||||
|
export const settingsUIFontSelector = '[data-action="settings-ui-font"]'
|
||||||
export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]'
|
export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]'
|
||||||
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
|
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
|
||||||
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
|
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
|
||||||
|
|
@ -25,7 +24,7 @@ export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-error
|
||||||
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
|
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
|
||||||
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
|
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
|
||||||
|
|
||||||
export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
||||||
|
|
||||||
export const projectSwitchSelector = (slug: string) =>
|
export const projectSwitchSelector = (slug: string) =>
|
||||||
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
||||||
|
|
@ -35,9 +34,6 @@ export const projectMenuTriggerSelector = (slug: string) =>
|
||||||
|
|
||||||
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
||||||
|
|
||||||
export const projectClearNotificationsSelector = (slug: string) =>
|
|
||||||
`[data-action="project-clear-notifications"][data-project="${slug}"]`
|
|
||||||
|
|
||||||
export const projectWorkspacesToggleSelector = (slug: string) =>
|
export const projectWorkspacesToggleSelector = (slug: string) =>
|
||||||
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
|
@ -45,14 +41,10 @@ export const titlebarRightSelector = "#opencode-titlebar-right"
|
||||||
|
|
||||||
export const popoverBodySelector = '[data-slot="popover-body"]'
|
export const popoverBodySelector = '[data-slot="popover-body"]'
|
||||||
|
|
||||||
export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
|
|
||||||
|
|
||||||
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
|
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
|
||||||
|
|
||||||
export const inlineInputSelector = '[data-component="inline-input"]'
|
export const inlineInputSelector = '[data-component="inline-input"]'
|
||||||
|
|
||||||
export const sessionTimelineHeaderSelector = "[data-session-title]"
|
|
||||||
|
|
||||||
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
|
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
|
||||||
|
|
||||||
export const workspaceItemSelector = (slug: string) =>
|
export const workspaceItemSelector = (slug: string) =>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { seedSessionTask, withSession } from "../actions"
|
import { seedSessionTask, withSession } from "../actions"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
import { inputMatch } from "../prompt/mock"
|
||||||
|
|
||||||
test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
|
test("task tool child-session link does not trigger stale show errors", async ({ page, llm, project }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const errs: string[] = []
|
const errs: string[] = []
|
||||||
|
|
@ -10,28 +11,54 @@ test("task tool child-session link does not trigger stale show errors", async ({
|
||||||
}
|
}
|
||||||
page.on("pageerror", onError)
|
page.on("pageerror", onError)
|
||||||
|
|
||||||
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
try {
|
||||||
const child = await seedSessionTask(sdk, {
|
await project.open()
|
||||||
sessionID: session.id,
|
await withSession(project.sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
||||||
description: "Open child session",
|
const taskInput = {
|
||||||
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
description: "Open child session",
|
||||||
})
|
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
||||||
|
subagent_type: "general",
|
||||||
|
}
|
||||||
|
await llm.toolMatch(inputMatch(taskInput), "task", taskInput)
|
||||||
|
const child = await seedSessionTask(project.sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
description: taskInput.description,
|
||||||
|
prompt: taskInput.prompt,
|
||||||
|
})
|
||||||
|
project.trackSession(child.sessionID)
|
||||||
|
|
||||||
try {
|
await project.gotoSession(session.id)
|
||||||
await gotoSession(session.id)
|
|
||||||
|
|
||||||
const link = page
|
const header = page.locator("[data-session-title]")
|
||||||
.locator("a.subagent-link")
|
await expect(header.getByRole("button", { name: "More options" })).toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
|
const card = page
|
||||||
|
.locator('[data-component="task-tool-card"]')
|
||||||
.filter({ hasText: /open child session/i })
|
.filter({ hasText: /open child session/i })
|
||||||
.first()
|
.first()
|
||||||
await expect(link).toBeVisible({ timeout: 30_000 })
|
await expect(card).toBeVisible({ timeout: 30_000 })
|
||||||
await link.click()
|
await card.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
||||||
await page.waitForTimeout(1000)
|
await expect(header.locator('[data-slot="session-title-parent"]')).toHaveText(session.title)
|
||||||
expect(errs).toEqual([])
|
await expect(header.locator('[data-slot="session-title-child"]')).toHaveText(taskInput.description)
|
||||||
} finally {
|
await expect(header.locator('[data-slot="session-title-separator"]')).toHaveText("/")
|
||||||
page.off("pageerror", onError)
|
await expect
|
||||||
}
|
.poll(
|
||||||
})
|
() =>
|
||||||
|
header.locator('[data-slot="session-title-separator"]').evaluate((el) => ({
|
||||||
|
left: getComputedStyle(el).paddingLeft,
|
||||||
|
right: getComputedStyle(el).paddingRight,
|
||||||
|
})),
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toEqual({ left: "8px", right: "8px" })
|
||||||
|
await expect(header.getByRole("button", { name: "More options" })).toHaveCount(0)
|
||||||
|
await expect(page.getByText("Subagent sessions cannot be prompted.")).toBeVisible({ timeout: 30_000 })
|
||||||
|
await expect(page.getByRole("button", { name: "Back to main session." })).toBeVisible({ timeout: 30_000 })
|
||||||
|
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
page.off("pageerror", onError)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
|
import {
|
||||||
|
composerEvent,
|
||||||
|
type ComposerDriverState,
|
||||||
|
type ComposerProbeState,
|
||||||
|
type ComposerWindow,
|
||||||
|
} from "../../src/testing/session-composer"
|
||||||
|
import { cleanupSession, clearSessionDockSeed, closeDialog, openSettings, seedSessionQuestion } from "../actions"
|
||||||
import {
|
import {
|
||||||
permissionDockSelector,
|
permissionDockSelector,
|
||||||
promptSelector,
|
promptSelector,
|
||||||
questionDockSelector,
|
questionDockSelector,
|
||||||
sessionComposerDockSelector,
|
sessionComposerDockSelector,
|
||||||
sessionTodoDockSelector,
|
|
||||||
sessionTodoListSelector,
|
|
||||||
sessionTodoToggleButtonSelector,
|
sessionTodoToggleButtonSelector,
|
||||||
} from "../selectors"
|
} from "../selectors"
|
||||||
|
import { modKey } from "../utils"
|
||||||
|
import { inputMatch } from "../prompt/mock"
|
||||||
|
|
||||||
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
||||||
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
|
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
|
||||||
|
|
@ -17,12 +23,13 @@ async function withDockSession<T>(
|
||||||
sdk: Sdk,
|
sdk: Sdk,
|
||||||
title: string,
|
title: string,
|
||||||
fn: (session: { id: string; title: string }) => Promise<T>,
|
fn: (session: { id: string; title: string }) => Promise<T>,
|
||||||
opts?: { permission?: PermissionRule[] },
|
opts?: { permission?: PermissionRule[]; trackSession?: (sessionID: string) => void },
|
||||||
) {
|
) {
|
||||||
const session = await sdk.session
|
const session = await sdk.session
|
||||||
.create(opts?.permission ? { title, permission: opts.permission } : { title })
|
.create(opts?.permission ? { title, permission: opts.permission } : { title })
|
||||||
.then((r) => r.data)
|
.then((r) => r.data)
|
||||||
if (!session?.id) throw new Error("Session create did not return an id")
|
if (!session?.id) throw new Error("Session create did not return an id")
|
||||||
|
opts?.trackSession?.(session.id)
|
||||||
try {
|
try {
|
||||||
return await fn(session)
|
return await fn(session)
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -30,6 +37,17 @@ async function withDockSession<T>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultQuestions = [
|
||||||
|
{
|
||||||
|
header: "Need input",
|
||||||
|
question: "Pick one option",
|
||||||
|
options: [
|
||||||
|
{ label: "Continue", description: "Continue now" },
|
||||||
|
{ label: "Stop", description: "Stop here" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
|
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
|
||||||
|
|
@ -42,21 +60,133 @@ async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>
|
||||||
|
|
||||||
async function clearPermissionDock(page: any, label: RegExp) {
|
async function clearPermissionDock(page: any, label: RegExp) {
|
||||||
const dock = page.locator(permissionDockSelector)
|
const dock = page.locator(permissionDockSelector)
|
||||||
for (let i = 0; i < 3; i++) {
|
await expect(dock).toBeVisible()
|
||||||
const count = await dock.count()
|
await dock.getByRole("button", { name: label }).click()
|
||||||
if (count === 0) return
|
|
||||||
await dock.getByRole("button", { name: label }).click()
|
|
||||||
await page.waitForTimeout(150)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setAutoAccept(page: any, enabled: boolean) {
|
async function setAutoAccept(page: any, enabled: boolean) {
|
||||||
const button = page.locator('[data-action="prompt-permissions"]').first()
|
const dialog = await openSettings(page)
|
||||||
await expect(button).toBeVisible()
|
const toggle = dialog.locator('[data-action="settings-auto-accept-permissions"]').first()
|
||||||
const pressed = (await button.getAttribute("aria-pressed")) === "true"
|
const input = toggle.locator('[data-slot="switch-input"]').first()
|
||||||
if (pressed === enabled) return
|
await expect(toggle).toBeVisible()
|
||||||
await button.click()
|
const checked = (await input.getAttribute("aria-checked")) === "true"
|
||||||
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
|
if (checked !== enabled) await toggle.locator('[data-slot="switch-control"]').click()
|
||||||
|
await expect(input).toHaveAttribute("aria-checked", enabled ? "true" : "false")
|
||||||
|
await closeDialog(page, dialog)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectQuestionBlocked(page: any) {
|
||||||
|
await expect(page.locator(questionDockSelector)).toBeVisible()
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectQuestionOpen(page: any) {
|
||||||
|
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectPermissionBlocked(page: any) {
|
||||||
|
await expect(page.locator(permissionDockSelector)).toBeVisible()
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectPermissionOpen(page: any) {
|
||||||
|
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function todoDock(page: any, sessionID: string) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const win = window as ComposerWindow
|
||||||
|
win.__opencode_e2e = {
|
||||||
|
...win.__opencode_e2e,
|
||||||
|
composer: {
|
||||||
|
enabled: true,
|
||||||
|
sessions: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const write = async (driver: ComposerDriverState | undefined) => {
|
||||||
|
await page.evaluate(
|
||||||
|
(input: { event: string; sessionID: string; driver: ComposerDriverState | undefined }) => {
|
||||||
|
const win = window as ComposerWindow
|
||||||
|
const composer = win.__opencode_e2e?.composer
|
||||||
|
if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
|
||||||
|
composer.sessions ??= {}
|
||||||
|
const prev = composer.sessions[input.sessionID] ?? {}
|
||||||
|
if (!input.driver) {
|
||||||
|
if (!prev.probe) {
|
||||||
|
delete composer.sessions[input.sessionID]
|
||||||
|
} else {
|
||||||
|
composer.sessions[input.sessionID] = { probe: prev.probe }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
composer.sessions[input.sessionID] = {
|
||||||
|
...prev,
|
||||||
|
driver: input.driver,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent(input.event, { detail: { sessionID: input.sessionID } }))
|
||||||
|
},
|
||||||
|
{ event: composerEvent, sessionID, driver },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const read = () =>
|
||||||
|
page.evaluate((sessionID: string) => {
|
||||||
|
const win = window as ComposerWindow
|
||||||
|
return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null
|
||||||
|
}, sessionID) as Promise<ComposerProbeState | null>
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
async clear() {
|
||||||
|
await write(undefined)
|
||||||
|
return api
|
||||||
|
},
|
||||||
|
async open(todos: NonNullable<ComposerDriverState["todos"]>) {
|
||||||
|
await write({ live: true, todos })
|
||||||
|
return api
|
||||||
|
},
|
||||||
|
async finish(todos: NonNullable<ComposerDriverState["todos"]>) {
|
||||||
|
await write({ live: false, todos })
|
||||||
|
return api
|
||||||
|
},
|
||||||
|
async expectOpen(states: ComposerProbeState["states"]) {
|
||||||
|
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
|
||||||
|
mounted: true,
|
||||||
|
collapsed: false,
|
||||||
|
hidden: false,
|
||||||
|
count: states.length,
|
||||||
|
states,
|
||||||
|
})
|
||||||
|
return api
|
||||||
|
},
|
||||||
|
async expectCollapsed(states: ComposerProbeState["states"]) {
|
||||||
|
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
|
||||||
|
mounted: true,
|
||||||
|
collapsed: true,
|
||||||
|
hidden: true,
|
||||||
|
count: states.length,
|
||||||
|
states,
|
||||||
|
})
|
||||||
|
return api
|
||||||
|
},
|
||||||
|
async expectClosed() {
|
||||||
|
await expect.poll(read, { timeout: 10_000 }).toMatchObject({ mounted: false })
|
||||||
|
return api
|
||||||
|
},
|
||||||
|
async collapse() {
|
||||||
|
await page.locator(sessionTodoToggleButtonSelector).click()
|
||||||
|
return api
|
||||||
|
},
|
||||||
|
async expand() {
|
||||||
|
await page.locator(sessionTodoToggleButtonSelector).click()
|
||||||
|
return api
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return api
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withMockPermission<T>(
|
async function withMockPermission<T>(
|
||||||
|
|
@ -70,8 +200,10 @@ async function withMockPermission<T>(
|
||||||
always?: string[]
|
always?: string[]
|
||||||
},
|
},
|
||||||
opts: { child?: any } | undefined,
|
opts: { child?: any } | undefined,
|
||||||
fn: () => Promise<T>,
|
fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
|
||||||
) {
|
) {
|
||||||
|
const listUrl = /\/permission(?:\?.*)?$/
|
||||||
|
const replyUrls = [/\/session\/[^/]+\/permissions\/[^/?]+(?:\?.*)?$/, /\/permission\/[^/]+\/reply(?:\?.*)?$/]
|
||||||
let pending = [
|
let pending = [
|
||||||
{
|
{
|
||||||
...request,
|
...request,
|
||||||
|
|
@ -90,7 +222,8 @@ async function withMockPermission<T>(
|
||||||
|
|
||||||
const reply = async (route: any) => {
|
const reply = async (route: any) => {
|
||||||
const url = new URL(route.request().url())
|
const url = new URL(route.request().url())
|
||||||
const id = url.pathname.split("/").pop()
|
const parts = url.pathname.split("/").filter(Boolean)
|
||||||
|
const id = parts.at(-1) === "reply" ? parts.at(-2) : parts.at(-1)
|
||||||
pending = pending.filter((item) => item.id !== id)
|
pending = pending.filter((item) => item.id !== id)
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|
@ -99,8 +232,10 @@ async function withMockPermission<T>(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.route("**/permission", list)
|
await page.route(listUrl, list)
|
||||||
await page.route("**/session/*/permissions/*", reply)
|
for (const item of replyUrls) {
|
||||||
|
await page.route(item, reply)
|
||||||
|
}
|
||||||
|
|
||||||
const sessionList = opts?.child
|
const sessionList = opts?.child
|
||||||
? async (route: any) => {
|
? async (route: any) => {
|
||||||
|
|
@ -109,9 +244,7 @@ async function withMockPermission<T>(
|
||||||
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
|
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
|
||||||
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
|
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: res.status(),
|
response: res,
|
||||||
headers: res.headers(),
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify(json),
|
body: JSON.stringify(json),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -119,307 +252,404 @@ async function withMockPermission<T>(
|
||||||
|
|
||||||
if (sessionList) await page.route("**/session?*", sessionList)
|
if (sessionList) await page.route("**/session?*", sessionList)
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
async resolved() {
|
||||||
|
await expect.poll(() => pending.length, { timeout: 10_000 }).toBe(0)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await fn()
|
return await fn(state)
|
||||||
} finally {
|
} finally {
|
||||||
await page.unroute("**/permission", list)
|
await page.unroute(listUrl, list)
|
||||||
await page.unroute("**/session/*/permissions/*", reply)
|
for (const item of replyUrls) {
|
||||||
|
await page.unroute(item, reply)
|
||||||
|
}
|
||||||
if (sessionList) await page.unroute("**/session?*", sessionList)
|
if (sessionList) await page.unroute("**/session?*", sessionList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
|
test("default dock shows prompt input", async ({ page, project }) => {
|
||||||
await withDockSession(sdk, "e2e composer dock default", async (session) => {
|
await project.open()
|
||||||
await gotoSession(session.id)
|
await withDockSession(
|
||||||
|
project.sdk,
|
||||||
|
"e2e composer dock default",
|
||||||
|
async (session) => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
await expect(page.locator('[data-action="prompt-permissions"]')).toHaveCount(0)
|
||||||
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
||||||
|
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
await page.locator(promptSelector).click()
|
||||||
await expect(page.locator(promptSelector)).toBeFocused()
|
await expect(page.locator(promptSelector)).toBeFocused()
|
||||||
})
|
},
|
||||||
|
{ trackSession: project.trackSession },
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
|
test("auto-accept toggle works before first submit", async ({ page, project }) => {
|
||||||
await gotoSession()
|
await project.open()
|
||||||
|
|
||||||
const button = page.locator('[data-action="prompt-permissions"]').first()
|
|
||||||
await expect(button).toBeVisible()
|
|
||||||
await expect(button).toHaveAttribute("aria-pressed", "false")
|
|
||||||
|
|
||||||
await setAutoAccept(page, true)
|
await setAutoAccept(page, true)
|
||||||
await setAutoAccept(page, false)
|
await setAutoAccept(page, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
|
test("blocked question flow unblocks after submit", async ({ page, llm, project }) => {
|
||||||
await withDockSession(sdk, "e2e composer dock question", async (session) => {
|
await project.open()
|
||||||
await withDockSeed(sdk, session.id, async () => {
|
await withDockSession(
|
||||||
await gotoSession(session.id)
|
project.sdk,
|
||||||
|
"e2e composer dock question",
|
||||||
|
async (session) => {
|
||||||
|
await withDockSeed(project.sdk, session.id, async () => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
await seedSessionQuestion(sdk, {
|
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
|
||||||
sessionID: session.id,
|
await seedSessionQuestion(project.sdk, {
|
||||||
questions: [
|
sessionID: session.id,
|
||||||
{
|
questions: defaultQuestions,
|
||||||
header: "Need input",
|
|
||||||
question: "Pick one option",
|
|
||||||
options: [
|
|
||||||
{ label: "Continue", description: "Continue now" },
|
|
||||||
{ label: "Stop", description: "Stop here" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
|
||||||
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await dock.locator('[data-slot="question-option"]').first().click()
|
|
||||||
await dock.getByRole("button", { name: /submit/i }).click()
|
|
||||||
|
|
||||||
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
|
||||||
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
|
||||||
await gotoSession(session.id)
|
|
||||||
await setAutoAccept(page, false)
|
|
||||||
await withMockPermission(
|
|
||||||
page,
|
|
||||||
{
|
|
||||||
id: "per_e2e_once",
|
|
||||||
sessionID: session.id,
|
|
||||||
permission: "bash",
|
|
||||||
patterns: ["/tmp/opencode-e2e-perm-once"],
|
|
||||||
metadata: { description: "Need permission for command" },
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
async () => {
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /allow once/i)
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
|
|
||||||
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
|
|
||||||
await gotoSession(session.id)
|
|
||||||
await setAutoAccept(page, false)
|
|
||||||
await withMockPermission(
|
|
||||||
page,
|
|
||||||
{
|
|
||||||
id: "per_e2e_reject",
|
|
||||||
sessionID: session.id,
|
|
||||||
permission: "bash",
|
|
||||||
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
async () => {
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /deny/i)
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
|
|
||||||
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
|
|
||||||
await gotoSession(session.id)
|
|
||||||
await setAutoAccept(page, false)
|
|
||||||
await withMockPermission(
|
|
||||||
page,
|
|
||||||
{
|
|
||||||
id: "per_e2e_always",
|
|
||||||
sessionID: session.id,
|
|
||||||
permission: "bash",
|
|
||||||
patterns: ["/tmp/opencode-e2e-perm-always"],
|
|
||||||
metadata: { description: "Need permission for command" },
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
async () => {
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /allow always/i)
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("child session question request blocks parent dock and unblocks after submit", async ({
|
|
||||||
page,
|
|
||||||
sdk,
|
|
||||||
gotoSession,
|
|
||||||
}) => {
|
|
||||||
await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
|
|
||||||
await gotoSession(session.id)
|
|
||||||
|
|
||||||
const child = await sdk.session
|
|
||||||
.create({
|
|
||||||
title: "e2e composer dock child question",
|
|
||||||
parentID: session.id,
|
|
||||||
})
|
|
||||||
.then((r) => r.data)
|
|
||||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withDockSeed(sdk, child.id, async () => {
|
|
||||||
await seedSessionQuestion(sdk, {
|
|
||||||
sessionID: child.id,
|
|
||||||
questions: [
|
|
||||||
{
|
|
||||||
header: "Child input",
|
|
||||||
question: "Pick one child option",
|
|
||||||
options: [
|
|
||||||
{ label: "Continue", description: "Continue child" },
|
|
||||||
{ label: "Stop", description: "Stop child" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
const dock = page.locator(questionDockSelector)
|
||||||
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
await expectQuestionBlocked(page)
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await dock.locator('[data-slot="question-option"]').first().click()
|
await dock.locator('[data-slot="question-option"]').first().click()
|
||||||
await dock.getByRole("button", { name: /submit/i }).click()
|
await dock.getByRole("button", { name: /submit/i }).click()
|
||||||
|
|
||||||
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
await expectQuestionOpen(page)
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
})
|
})
|
||||||
} finally {
|
},
|
||||||
await cleanupSession({ sdk, sessionID: child.id })
|
{ trackSession: project.trackSession },
|
||||||
}
|
)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("child session permission request blocks parent dock and supports allow once", async ({
|
test("blocked question flow supports keyboard shortcuts", async ({ page, llm, project }) => {
|
||||||
page,
|
await project.open()
|
||||||
sdk,
|
await withDockSession(
|
||||||
gotoSession,
|
project.sdk,
|
||||||
}) => {
|
"e2e composer dock question keyboard",
|
||||||
await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
|
async (session) => {
|
||||||
await gotoSession(session.id)
|
await withDockSeed(project.sdk, session.id, async () => {
|
||||||
await setAutoAccept(page, false)
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
const child = await sdk.session
|
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
|
||||||
.create({
|
await seedSessionQuestion(project.sdk, {
|
||||||
title: "e2e composer dock child permission",
|
sessionID: session.id,
|
||||||
parentID: session.id,
|
questions: defaultQuestions,
|
||||||
|
})
|
||||||
|
|
||||||
|
const dock = page.locator(questionDockSelector)
|
||||||
|
const first = dock.locator('[data-slot="question-option"]').first()
|
||||||
|
const second = dock.locator('[data-slot="question-option"]').nth(1)
|
||||||
|
|
||||||
|
await expectQuestionBlocked(page)
|
||||||
|
await expect(first).toBeFocused()
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown")
|
||||||
|
await expect(second).toBeFocused()
|
||||||
|
|
||||||
|
await page.keyboard.press("Space")
|
||||||
|
await page.keyboard.press(`${modKey}+Enter`)
|
||||||
|
await expectQuestionOpen(page)
|
||||||
})
|
})
|
||||||
.then((r) => r.data)
|
},
|
||||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
{ trackSession: project.trackSession },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
test("blocked question flow supports escape dismiss", async ({ page, llm, project }) => {
|
||||||
|
await project.open()
|
||||||
|
await withDockSession(
|
||||||
|
project.sdk,
|
||||||
|
"e2e composer dock question escape",
|
||||||
|
async (session) => {
|
||||||
|
await withDockSeed(project.sdk, session.id, async () => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
|
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
|
||||||
|
await seedSessionQuestion(project.sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
questions: defaultQuestions,
|
||||||
|
})
|
||||||
|
|
||||||
|
const dock = page.locator(questionDockSelector)
|
||||||
|
const first = dock.locator('[data-slot="question-option"]').first()
|
||||||
|
|
||||||
|
await expectQuestionBlocked(page)
|
||||||
|
await expect(first).toBeFocused()
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expectQuestionOpen(page)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ trackSession: project.trackSession },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blocked permission flow supports allow once", async ({ page, project }) => {
|
||||||
|
await project.open()
|
||||||
|
await withDockSession(
|
||||||
|
project.sdk,
|
||||||
|
"e2e composer dock permission once",
|
||||||
|
async (session) => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
await setAutoAccept(page, false)
|
||||||
await withMockPermission(
|
await withMockPermission(
|
||||||
page,
|
page,
|
||||||
{
|
{
|
||||||
id: "per_e2e_child",
|
id: "per_e2e_once",
|
||||||
sessionID: child.id,
|
sessionID: session.id,
|
||||||
permission: "bash",
|
permission: "bash",
|
||||||
patterns: ["/tmp/opencode-e2e-perm-child"],
|
patterns: ["/tmp/opencode-e2e-perm-once"],
|
||||||
metadata: { description: "Need child permission" },
|
metadata: { description: "Need permission for command" },
|
||||||
},
|
},
|
||||||
{ child },
|
undefined,
|
||||||
async () => {
|
async (state) => {
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
const dock = page.locator(permissionDockSelector)
|
await expectPermissionBlocked(page)
|
||||||
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /allow once/i)
|
await clearPermissionDock(page, /allow once/i)
|
||||||
|
await state.resolved()
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
|
await expectPermissionOpen(page)
|
||||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} finally {
|
},
|
||||||
await cleanupSession({ sdk, sessionID: child.id })
|
{ trackSession: project.trackSession },
|
||||||
}
|
)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
|
test("blocked permission flow supports reject", async ({ page, project }) => {
|
||||||
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
|
await project.open()
|
||||||
await withDockSeed(sdk, session.id, async () => {
|
await withDockSession(
|
||||||
await gotoSession(session.id)
|
project.sdk,
|
||||||
|
"e2e composer dock permission reject",
|
||||||
|
async (session) => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
await setAutoAccept(page, false)
|
||||||
|
await withMockPermission(
|
||||||
|
page,
|
||||||
|
{
|
||||||
|
id: "per_e2e_reject",
|
||||||
|
sessionID: session.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
async (state) => {
|
||||||
|
await page.goto(page.url())
|
||||||
|
await expectPermissionBlocked(page)
|
||||||
|
|
||||||
await seedSessionTodos(sdk, {
|
await clearPermissionDock(page, /deny/i)
|
||||||
sessionID: session.id,
|
await state.resolved()
|
||||||
todos: [
|
await page.goto(page.url())
|
||||||
|
await expectPermissionOpen(page)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ trackSession: project.trackSession },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blocked permission flow supports allow always", async ({ page, project }) => {
|
||||||
|
await project.open()
|
||||||
|
await withDockSession(
|
||||||
|
project.sdk,
|
||||||
|
"e2e composer dock permission always",
|
||||||
|
async (session) => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
await setAutoAccept(page, false)
|
||||||
|
await withMockPermission(
|
||||||
|
page,
|
||||||
|
{
|
||||||
|
id: "per_e2e_always",
|
||||||
|
sessionID: session.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["/tmp/opencode-e2e-perm-always"],
|
||||||
|
metadata: { description: "Need permission for command" },
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
async (state) => {
|
||||||
|
await page.goto(page.url())
|
||||||
|
await expectPermissionBlocked(page)
|
||||||
|
|
||||||
|
await clearPermissionDock(page, /allow always/i)
|
||||||
|
await state.resolved()
|
||||||
|
await page.goto(page.url())
|
||||||
|
await expectPermissionOpen(page)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ trackSession: project.trackSession },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("child session question request blocks parent dock and unblocks after submit", async ({ page, llm, project }) => {
|
||||||
|
const questions = [
|
||||||
|
{
|
||||||
|
header: "Child input",
|
||||||
|
question: "Pick one child option",
|
||||||
|
options: [
|
||||||
|
{ label: "Continue", description: "Continue child" },
|
||||||
|
{ label: "Stop", description: "Stop child" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
await project.open()
|
||||||
|
await withDockSession(
|
||||||
|
project.sdk,
|
||||||
|
"e2e composer dock child question parent",
|
||||||
|
async (session) => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
|
const child = await project.sdk.session
|
||||||
|
.create({
|
||||||
|
title: "e2e composer dock child question",
|
||||||
|
parentID: session.id,
|
||||||
|
})
|
||||||
|
.then((r) => r.data)
|
||||||
|
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||||
|
project.trackSession(child.id)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withDockSeed(project.sdk, child.id, async () => {
|
||||||
|
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
|
||||||
|
await seedSessionQuestion(project.sdk, {
|
||||||
|
sessionID: child.id,
|
||||||
|
questions,
|
||||||
|
})
|
||||||
|
|
||||||
|
const dock = page.locator(questionDockSelector)
|
||||||
|
await expectQuestionBlocked(page)
|
||||||
|
|
||||||
|
await dock.locator('[data-slot="question-option"]').first().click()
|
||||||
|
await dock.getByRole("button", { name: /submit/i }).click()
|
||||||
|
|
||||||
|
await expectQuestionOpen(page)
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ trackSession: project.trackSession },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("child session permission request blocks parent dock and supports allow once", async ({ page, project }) => {
|
||||||
|
await project.open()
|
||||||
|
await withDockSession(
|
||||||
|
project.sdk,
|
||||||
|
"e2e composer dock child permission parent",
|
||||||
|
async (session) => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
await setAutoAccept(page, false)
|
||||||
|
|
||||||
|
const child = await project.sdk.session
|
||||||
|
.create({
|
||||||
|
title: "e2e composer dock child permission",
|
||||||
|
parentID: session.id,
|
||||||
|
})
|
||||||
|
.then((r) => r.data)
|
||||||
|
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||||
|
project.trackSession(child.id)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withMockPermission(
|
||||||
|
page,
|
||||||
|
{
|
||||||
|
id: "per_e2e_child",
|
||||||
|
sessionID: child.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["/tmp/opencode-e2e-perm-child"],
|
||||||
|
metadata: { description: "Need child permission" },
|
||||||
|
},
|
||||||
|
{ child },
|
||||||
|
async (state) => {
|
||||||
|
await page.goto(page.url())
|
||||||
|
await expectPermissionBlocked(page)
|
||||||
|
|
||||||
|
await clearPermissionDock(page, /allow once/i)
|
||||||
|
await state.resolved()
|
||||||
|
await page.goto(page.url())
|
||||||
|
|
||||||
|
await expectPermissionOpen(page)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ trackSession: project.trackSession },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("todo dock transitions and collapse behavior", async ({ page, project }) => {
|
||||||
|
await project.open()
|
||||||
|
await withDockSession(
|
||||||
|
project.sdk,
|
||||||
|
"e2e composer dock todo",
|
||||||
|
async (session) => {
|
||||||
|
const dock = await todoDock(page, session.id)
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dock.open([
|
||||||
{ content: "first task", status: "pending", priority: "high" },
|
{ content: "first task", status: "pending", priority: "high" },
|
||||||
{ content: "second task", status: "in_progress", priority: "medium" },
|
{ content: "second task", status: "in_progress", priority: "medium" },
|
||||||
],
|
])
|
||||||
})
|
await dock.expectOpen(["pending", "in_progress"])
|
||||||
|
|
||||||
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
await dock.collapse()
|
||||||
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
|
await dock.expectCollapsed(["pending", "in_progress"])
|
||||||
|
|
||||||
await page.locator(sessionTodoToggleButtonSelector).click()
|
await dock.expand()
|
||||||
await expect(page.locator(sessionTodoListSelector)).toBeHidden()
|
await dock.expectOpen(["pending", "in_progress"])
|
||||||
|
|
||||||
await page.locator(sessionTodoToggleButtonSelector).click()
|
await dock.finish([
|
||||||
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
|
|
||||||
|
|
||||||
await seedSessionTodos(sdk, {
|
|
||||||
sessionID: session.id,
|
|
||||||
todos: [
|
|
||||||
{ content: "first task", status: "completed", priority: "high" },
|
{ content: "first task", status: "completed", priority: "high" },
|
||||||
{ content: "second task", status: "cancelled", priority: "medium" },
|
{ content: "second task", status: "cancelled", priority: "medium" },
|
||||||
],
|
])
|
||||||
})
|
await dock.expectClosed()
|
||||||
|
} finally {
|
||||||
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
await dock.clear()
|
||||||
})
|
}
|
||||||
})
|
},
|
||||||
|
{ trackSession: project.trackSession },
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
|
test("keyboard focus stays off prompt while blocked", async ({ page, llm, project }) => {
|
||||||
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
|
const questions = [
|
||||||
await withDockSeed(sdk, session.id, async () => {
|
{
|
||||||
await gotoSession(session.id)
|
header: "Need input",
|
||||||
|
question: "Pick one option",
|
||||||
|
options: [{ label: "Continue", description: "Continue now" }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
await project.open()
|
||||||
|
await withDockSession(
|
||||||
|
project.sdk,
|
||||||
|
"e2e composer dock keyboard",
|
||||||
|
async (session) => {
|
||||||
|
await withDockSeed(project.sdk, session.id, async () => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
await seedSessionQuestion(sdk, {
|
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
|
||||||
sessionID: session.id,
|
await seedSessionQuestion(project.sdk, {
|
||||||
questions: [
|
sessionID: session.id,
|
||||||
{
|
questions,
|
||||||
header: "Need input",
|
})
|
||||||
question: "Pick one option",
|
|
||||||
options: [{ label: "Continue", description: "Continue now" }],
|
await expectQuestionBlocked(page)
|
||||||
},
|
|
||||||
],
|
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
||||||
|
await page.keyboard.type("abc")
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
},
|
||||||
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
{ trackSession: project.trackSession },
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
)
|
||||||
|
|
||||||
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
|
||||||
await page.keyboard.type("abc")
|
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,362 @@
|
||||||
|
import type { Locator, Page } from "@playwright/test"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { openSidebar, resolveSlug, setWorkspacesEnabled, waitSession, waitSlug } from "../actions"
|
||||||
|
import {
|
||||||
|
promptAgentSelector,
|
||||||
|
promptModelSelector,
|
||||||
|
promptVariantSelector,
|
||||||
|
workspaceItemSelector,
|
||||||
|
workspaceNewSessionSelector,
|
||||||
|
} from "../selectors"
|
||||||
|
import { createSdk, sessionPath } from "../utils"
|
||||||
|
|
||||||
|
type Footer = {
|
||||||
|
agent: string
|
||||||
|
model: string
|
||||||
|
variant: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Probe = {
|
||||||
|
dir?: string
|
||||||
|
sessionID?: string
|
||||||
|
agent?: string
|
||||||
|
model?: { providerID: string; modelID: string; name?: string }
|
||||||
|
variant?: string | null
|
||||||
|
pick?: {
|
||||||
|
agent?: string
|
||||||
|
model?: { providerID: string; modelID: string }
|
||||||
|
variant?: string | null
|
||||||
|
}
|
||||||
|
variants?: string[]
|
||||||
|
models?: Array<{ providerID: string; modelID: string; name: string }>
|
||||||
|
agents?: Array<{ name: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||||
|
|
||||||
|
const text = async (locator: Locator) => ((await locator.textContent()) ?? "").trim()
|
||||||
|
|
||||||
|
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
|
||||||
|
|
||||||
|
async function probe(page: Page): Promise<Probe | null> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const win = window as Window & {
|
||||||
|
__opencode_e2e?: {
|
||||||
|
model?: {
|
||||||
|
current?: Probe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return win.__opencode_e2e?.model?.current ?? null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function currentModel(page: Page) {
|
||||||
|
await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).not.toBe(null)
|
||||||
|
const value = await probe(page).then(modelKey)
|
||||||
|
if (!value) throw new Error("Failed to resolve current model key")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitControl(page: Page, key: "setAgent" | "setModel" | "setVariant") {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() =>
|
||||||
|
page.evaluate((key) => {
|
||||||
|
const win = window as Window & {
|
||||||
|
__opencode_e2e?: {
|
||||||
|
model?: {
|
||||||
|
controls?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return !!win.__opencode_e2e?.model?.controls?.[key]
|
||||||
|
}, key),
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickAgent(page: Page, value: string) {
|
||||||
|
await waitControl(page, "setAgent")
|
||||||
|
await page.evaluate((value) => {
|
||||||
|
const win = window as Window & {
|
||||||
|
__opencode_e2e?: {
|
||||||
|
model?: {
|
||||||
|
controls?: {
|
||||||
|
setAgent?: (value: string | undefined) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fn = win.__opencode_e2e?.model?.controls?.setAgent
|
||||||
|
if (!fn) throw new Error("Model e2e agent control is not enabled")
|
||||||
|
fn(value)
|
||||||
|
}, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickModel(page: Page, value: { providerID: string; modelID: string }) {
|
||||||
|
await waitControl(page, "setModel")
|
||||||
|
await page.evaluate((value) => {
|
||||||
|
const win = window as Window & {
|
||||||
|
__opencode_e2e?: {
|
||||||
|
model?: {
|
||||||
|
controls?: {
|
||||||
|
setModel?: (value: { providerID: string; modelID: string } | undefined) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fn = win.__opencode_e2e?.model?.controls?.setModel
|
||||||
|
if (!fn) throw new Error("Model e2e model control is not enabled")
|
||||||
|
fn(value)
|
||||||
|
}, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickVariant(page: Page, value: string) {
|
||||||
|
await waitControl(page, "setVariant")
|
||||||
|
await page.evaluate((value) => {
|
||||||
|
const win = window as Window & {
|
||||||
|
__opencode_e2e?: {
|
||||||
|
model?: {
|
||||||
|
controls?: {
|
||||||
|
setVariant?: (value: string | undefined) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fn = win.__opencode_e2e?.model?.controls?.setVariant
|
||||||
|
if (!fn) throw new Error("Model e2e variant control is not enabled")
|
||||||
|
fn(value)
|
||||||
|
}, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function read(page: Page): Promise<Footer> {
|
||||||
|
return {
|
||||||
|
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
|
||||||
|
model: await text(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()),
|
||||||
|
variant: await text(page.locator(`${promptVariantSelector} [data-slot="select-select-trigger-value"]`).first()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFooter(page: Page, expected: Partial<Footer>) {
|
||||||
|
let hit: Footer | null = null
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const state = await read(page)
|
||||||
|
const ok = Object.entries(expected).every(([key, value]) => state[key as keyof Footer] === value)
|
||||||
|
if (ok) hit = state
|
||||||
|
return ok
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
if (!hit) throw new Error("Failed to resolve prompt footer state")
|
||||||
|
return hit
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitModel(page: Page, value: string) {
|
||||||
|
await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).toBe(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function choose(page: Page, root: string, value: string) {
|
||||||
|
const select = page.locator(root)
|
||||||
|
await expect(select).toBeVisible()
|
||||||
|
await pickAgent(page, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function variantCount(page: Page) {
|
||||||
|
return (await probe(page))?.variants?.length ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function agents(page: Page) {
|
||||||
|
return ((await probe(page))?.agents ?? []).map((item) => item.name).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureVariant(page: Page, directory: string): Promise<Footer> {
|
||||||
|
const current = await read(page)
|
||||||
|
if ((await variantCount(page)) >= 2) return current
|
||||||
|
|
||||||
|
const cfg = await createSdk(directory)
|
||||||
|
.config.get()
|
||||||
|
.then((x) => x.data)
|
||||||
|
const visible = new Set(await agents(page))
|
||||||
|
const entry = Object.entries(cfg?.agent ?? {}).find((item) => {
|
||||||
|
const value = item[1]
|
||||||
|
return !!value && typeof value === "object" && "variant" in value && "model" in value && visible.has(item[0])
|
||||||
|
})
|
||||||
|
const name = entry?.[0]
|
||||||
|
test.skip(!name, "no agent with alternate variants available")
|
||||||
|
if (!name) return current
|
||||||
|
|
||||||
|
await choose(page, promptAgentSelector, name)
|
||||||
|
await expect.poll(() => variantCount(page), { timeout: 30_000 }).toBeGreaterThanOrEqual(2)
|
||||||
|
return waitFooter(page, { agent: name })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseDifferentVariant(page: Page): Promise<Footer> {
|
||||||
|
const current = await read(page)
|
||||||
|
const next = (await probe(page))?.variants?.find((item) => item !== current.variant)
|
||||||
|
if (!next) throw new Error("Current model has no alternate variant to select")
|
||||||
|
|
||||||
|
await pickVariant(page, next)
|
||||||
|
return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseOtherModel(page: Page, skip: string[] = []): Promise<Footer> {
|
||||||
|
const current = await currentModel(page)
|
||||||
|
const next = (await probe(page))?.models?.find((item) => {
|
||||||
|
const key = `${item.providerID}:${item.modelID}`
|
||||||
|
return key !== current && !skip.includes(key)
|
||||||
|
})
|
||||||
|
if (!next) throw new Error("Failed to choose a different model")
|
||||||
|
await pickModel(page, { providerID: next.providerID, modelID: next.modelID })
|
||||||
|
await expect.poll(async () => (await read(page)).model, { timeout: 30_000 }).toBe(next.name)
|
||||||
|
return read(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goto(page: Page, directory: string, sessionID?: string) {
|
||||||
|
await page.goto(sessionPath(directory, sessionID))
|
||||||
|
await waitSession(page, { directory, sessionID })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(project: Parameters<typeof test>[0]["project"], value: string) {
|
||||||
|
return project.prompt(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||||
|
await openSidebar(page)
|
||||||
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
|
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
|
||||||
|
await waitSession(page, { directory: next.directory })
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitWorkspace(page: Page, slug: string) {
|
||||||
|
await openSidebar(page)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||||
|
try {
|
||||||
|
await item.hover({ timeout: 500 })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newWorkspaceSession(page: Page, slug: string) {
|
||||||
|
await waitWorkspace(page, slug)
|
||||||
|
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||||
|
await item.hover()
|
||||||
|
|
||||||
|
const button = page.locator(workspaceNewSessionSelector(slug)).first()
|
||||||
|
await expect(button).toBeVisible()
|
||||||
|
await button.click({ force: true })
|
||||||
|
|
||||||
|
const next = await resolveSlug(await waitSlug(page))
|
||||||
|
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("session model restore per session without leaking into new sessions", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
|
||||||
|
await project.open()
|
||||||
|
await project.gotoSession()
|
||||||
|
|
||||||
|
const firstState = await chooseOtherModel(page)
|
||||||
|
const firstKey = await currentModel(page)
|
||||||
|
const first = await submit(project, `session variant ${Date.now()}`)
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
await waitSession(page, { directory: project.directory, sessionID: first })
|
||||||
|
await waitFooter(page, firstState)
|
||||||
|
|
||||||
|
await project.gotoSession()
|
||||||
|
const fresh = await read(page)
|
||||||
|
expect(fresh.model).not.toBe(firstState.model)
|
||||||
|
|
||||||
|
const secondState = await chooseOtherModel(page, [firstKey])
|
||||||
|
const second = await submit(project, `session model ${Date.now()}`)
|
||||||
|
|
||||||
|
await goto(page, project.directory, first)
|
||||||
|
await waitFooter(page, firstState)
|
||||||
|
|
||||||
|
await goto(page, project.directory, second)
|
||||||
|
await waitFooter(page, secondState)
|
||||||
|
|
||||||
|
await project.gotoSession()
|
||||||
|
await page.reload()
|
||||||
|
await waitSession(page, { directory: project.directory })
|
||||||
|
await waitFooter(page, fresh)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("session model restore across workspaces", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
|
||||||
|
await project.open()
|
||||||
|
const root = project.directory
|
||||||
|
await project.gotoSession()
|
||||||
|
|
||||||
|
const firstState = await chooseOtherModel(page)
|
||||||
|
const firstKey = await currentModel(page)
|
||||||
|
const first = await submit(project, `root session ${Date.now()}`)
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
|
|
||||||
|
const one = await createWorkspace(page, project.slug, [])
|
||||||
|
const oneDir = await newWorkspaceSession(page, one.slug)
|
||||||
|
project.trackDirectory(oneDir)
|
||||||
|
|
||||||
|
const secondState = await chooseOtherModel(page, [firstKey])
|
||||||
|
const secondKey = await currentModel(page)
|
||||||
|
const second = await submit(project, `workspace one ${Date.now()}`)
|
||||||
|
|
||||||
|
const two = await createWorkspace(page, project.slug, [one.slug])
|
||||||
|
const twoDir = await newWorkspaceSession(page, two.slug)
|
||||||
|
project.trackDirectory(twoDir)
|
||||||
|
|
||||||
|
const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
|
||||||
|
const third = await submit(project, `workspace two ${Date.now()}`)
|
||||||
|
|
||||||
|
await goto(page, root, first)
|
||||||
|
await waitFooter(page, firstState)
|
||||||
|
|
||||||
|
await goto(page, oneDir, second)
|
||||||
|
await waitFooter(page, secondState)
|
||||||
|
|
||||||
|
await goto(page, twoDir, third)
|
||||||
|
await waitFooter(page, thirdState)
|
||||||
|
|
||||||
|
await goto(page, root, first)
|
||||||
|
await waitFooter(page, firstState)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("variant preserved when switching agent modes", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
|
||||||
|
await project.open()
|
||||||
|
await project.gotoSession()
|
||||||
|
|
||||||
|
await ensureVariant(page, project.directory)
|
||||||
|
const updated = await chooseDifferentVariant(page)
|
||||||
|
|
||||||
|
const available = await agents(page)
|
||||||
|
const other = available.find((name) => name !== updated.agent)
|
||||||
|
test.skip(!other, "only one agent available")
|
||||||
|
if (!other) return
|
||||||
|
|
||||||
|
await choose(page, promptAgentSelector, other)
|
||||||
|
await waitFooter(page, { agent: other, variant: updated.variant })
|
||||||
|
|
||||||
|
await choose(page, promptAgentSelector, updated.agent)
|
||||||
|
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,440 @@
|
||||||
|
import { waitSessionIdle, withSession } from "../actions"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { bodyText } from "../prompt/mock"
|
||||||
|
|
||||||
|
const count = 14
|
||||||
|
|
||||||
|
function body(mark: string) {
|
||||||
|
return [
|
||||||
|
`title ${mark}`,
|
||||||
|
`mark ${mark}`,
|
||||||
|
...Array.from({ length: 32 }, (_, i) => `line ${String(i + 1).padStart(2, "0")} ${mark}`),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function files(tag: string) {
|
||||||
|
return Array.from({ length: count }, (_, i) => {
|
||||||
|
const id = String(i).padStart(2, "0")
|
||||||
|
return {
|
||||||
|
file: `review-scroll-${id}.txt`,
|
||||||
|
mark: `${tag}-${id}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function seed(list: ReturnType<typeof files>) {
|
||||||
|
const out = ["*** Begin Patch"]
|
||||||
|
|
||||||
|
for (const item of list) {
|
||||||
|
out.push(`*** Add File: ${item.file}`)
|
||||||
|
for (const line of body(item.mark)) out.push(`+${line}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push("*** End Patch")
|
||||||
|
return out.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function edit(file: string, prev: string, next: string) {
|
||||||
|
return ["*** Begin Patch", `*** Update File: ${file}`, "@@", `-mark ${prev}`, `+mark ${next}`, "*** End Patch"].join(
|
||||||
|
"\n",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchWithMock(
|
||||||
|
llm: Parameters<typeof test>[0]["llm"],
|
||||||
|
sdk: Parameters<typeof withSession>[0],
|
||||||
|
sessionID: string,
|
||||||
|
patchText: string,
|
||||||
|
) {
|
||||||
|
const callsBefore = await llm.calls()
|
||||||
|
await llm.toolMatch(
|
||||||
|
(hit) => bodyText(hit).includes("Your only valid response is one apply_patch tool call."),
|
||||||
|
"apply_patch",
|
||||||
|
{ patchText },
|
||||||
|
)
|
||||||
|
await sdk.session.prompt({
|
||||||
|
sessionID,
|
||||||
|
agent: "build",
|
||||||
|
system: [
|
||||||
|
"You are seeding deterministic e2e UI state.",
|
||||||
|
"Your only valid response is one apply_patch tool call.",
|
||||||
|
`Use this JSON input: ${JSON.stringify({ patchText })}`,
|
||||||
|
"Do not call any other tools.",
|
||||||
|
"Do not output plain text.",
|
||||||
|
].join("\n"),
|
||||||
|
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const diff = await sdk.session.diff({ sessionID }).then((res) => res.data ?? [])
|
||||||
|
return diff.length
|
||||||
|
},
|
||||||
|
{ timeout: 120_000 },
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function show(page: Parameters<typeof test>[0]["page"]) {
|
||||||
|
const btn = page.getByRole("button", { name: "Toggle review" }).first()
|
||||||
|
await expect(btn).toBeVisible()
|
||||||
|
if ((await btn.getAttribute("aria-expanded")) !== "true") await btn.click()
|
||||||
|
await expect(btn).toHaveAttribute("aria-expanded", "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expand(page: Parameters<typeof test>[0]["page"]) {
|
||||||
|
const close = page.getByRole("button", { name: /^Collapse all$/i }).first()
|
||||||
|
const open = await close
|
||||||
|
.isVisible()
|
||||||
|
.then((value) => value)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
const btn = page.getByRole("button", { name: /^Expand all$/i }).first()
|
||||||
|
if (open) {
|
||||||
|
await close.click()
|
||||||
|
await expect(btn).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(btn).toBeVisible()
|
||||||
|
await btn.click()
|
||||||
|
await expect(close).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitMark(page: Parameters<typeof test>[0]["page"], file: string, mark: string) {
|
||||||
|
await page.waitForFunction(
|
||||||
|
({ file, mark }) => {
|
||||||
|
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
|
||||||
|
if (!(view instanceof HTMLElement)) return false
|
||||||
|
|
||||||
|
const head = Array.from(view.querySelectorAll("h3")).find(
|
||||||
|
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
|
||||||
|
)
|
||||||
|
if (!(head instanceof HTMLElement)) return false
|
||||||
|
|
||||||
|
return Array.from(head.parentElement?.querySelectorAll("diffs-container") ?? []).some((host) => {
|
||||||
|
if (!(host instanceof HTMLElement)) return false
|
||||||
|
const root = host.shadowRoot
|
||||||
|
return root?.textContent?.includes(`mark ${mark}`) ?? false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ file, mark },
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
|
||||||
|
return page.evaluate((file) => {
|
||||||
|
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
|
||||||
|
if (!(view instanceof HTMLElement)) return null
|
||||||
|
|
||||||
|
const row = Array.from(view.querySelectorAll("h3")).find(
|
||||||
|
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
|
||||||
|
)
|
||||||
|
if (!(row instanceof HTMLElement)) return null
|
||||||
|
|
||||||
|
const a = row.getBoundingClientRect()
|
||||||
|
const b = view.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
top: a.top - b.top,
|
||||||
|
y: view.scrollTop,
|
||||||
|
}
|
||||||
|
}, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function comment(page: Parameters<typeof test>[0]["page"], file: string, note: string) {
|
||||||
|
const row = page.locator(`[data-file="${file}"]`).first()
|
||||||
|
await expect(row).toBeVisible()
|
||||||
|
|
||||||
|
const line = row.locator('diffs-container [data-line="2"]').first()
|
||||||
|
await expect(line).toBeVisible()
|
||||||
|
await line.hover()
|
||||||
|
|
||||||
|
const add = row.getByRole("button", { name: /^Comment$/ }).first()
|
||||||
|
await expect(add).toBeVisible()
|
||||||
|
await add.click()
|
||||||
|
|
||||||
|
const area = row.locator('[data-slot="line-comment-textarea"]').first()
|
||||||
|
await expect(area).toBeVisible()
|
||||||
|
await area.fill(note)
|
||||||
|
|
||||||
|
const submit = row.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
|
||||||
|
await expect(submit).toBeEnabled()
|
||||||
|
await submit.click()
|
||||||
|
|
||||||
|
await expect(row.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
|
||||||
|
await expect(row.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function overflow(page: Parameters<typeof test>[0]["page"], file: string) {
|
||||||
|
const row = page.locator(`[data-file="${file}"]`).first()
|
||||||
|
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
||||||
|
const pop = row.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
|
||||||
|
const tools = row.locator('[data-slot="line-comment-tools"]').first()
|
||||||
|
|
||||||
|
const [width, viewBox, popBox, toolsBox] = await Promise.all([
|
||||||
|
view.evaluate((el) => el.scrollWidth - el.clientWidth),
|
||||||
|
view.boundingBox(),
|
||||||
|
pop.boundingBox(),
|
||||||
|
tools.boundingBox(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!viewBox || !popBox || !toolsBox) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
|
||||||
|
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openReviewFile(page: Parameters<typeof test>[0]["page"], file: string) {
|
||||||
|
const row = page.locator(`[data-file="${file}"]`).first()
|
||||||
|
await expect(row).toBeVisible()
|
||||||
|
await row.hover()
|
||||||
|
|
||||||
|
const open = row.getByRole("button", { name: /^Open file$/i }).first()
|
||||||
|
await expect(open).toBeVisible()
|
||||||
|
await open.click()
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: file }).first()
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
|
||||||
|
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||||
|
await expect(viewer).toBeVisible()
|
||||||
|
return viewer
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileComment(page: Parameters<typeof test>[0]["page"], note: string) {
|
||||||
|
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||||
|
await expect(viewer).toBeVisible()
|
||||||
|
|
||||||
|
const line = viewer.locator('diffs-container [data-line="2"]').first()
|
||||||
|
await expect(line).toBeVisible()
|
||||||
|
await line.hover()
|
||||||
|
|
||||||
|
const add = viewer.getByRole("button", { name: /^Comment$/ }).first()
|
||||||
|
await expect(add).toBeVisible()
|
||||||
|
await add.click()
|
||||||
|
|
||||||
|
const area = viewer.locator('[data-slot="line-comment-textarea"]').first()
|
||||||
|
await expect(area).toBeVisible()
|
||||||
|
await area.fill(note)
|
||||||
|
|
||||||
|
const submit = viewer.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
|
||||||
|
await expect(submit).toBeEnabled()
|
||||||
|
await submit.click()
|
||||||
|
|
||||||
|
await expect(viewer.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
|
||||||
|
await expect(viewer.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
|
||||||
|
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||||
|
const view = page.locator('[role="tabpanel"] .scroll-view__viewport').first()
|
||||||
|
const pop = viewer.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
|
||||||
|
const tools = viewer.locator('[data-slot="line-comment-tools"]').first()
|
||||||
|
|
||||||
|
const [width, viewBox, popBox, toolsBox] = await Promise.all([
|
||||||
|
view.evaluate((el) => el.scrollWidth - el.clientWidth),
|
||||||
|
view.boundingBox(),
|
||||||
|
pop.boundingBox(),
|
||||||
|
tools.boundingBox(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!viewBox || !popBox || !toolsBox) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
|
||||||
|
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, project }) => {
|
||||||
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
|
const tag = `review-comment-${Date.now()}`
|
||||||
|
const file = `review-comment-${tag}.txt`
|
||||||
|
const note = `comment ${tag}`
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 })
|
||||||
|
|
||||||
|
await project.open()
|
||||||
|
await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => {
|
||||||
|
project.trackSession(session.id)
|
||||||
|
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
|
return diff.length
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(1)
|
||||||
|
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
await show(page)
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
|
||||||
|
await expand(page)
|
||||||
|
await waitMark(page, file, tag)
|
||||||
|
await comment(page, file, note)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
|
.toBeLessThanOrEqual(1)
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
|
.toBeLessThanOrEqual(1)
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
|
.toBeLessThanOrEqual(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("review file comments submit on click without clipping actions", async ({ page, llm, project }) => {
|
||||||
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
|
const tag = `review-file-comment-${Date.now()}`
|
||||||
|
const file = `review-file-comment-${tag}.txt`
|
||||||
|
const note = `comment ${tag}`
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 })
|
||||||
|
|
||||||
|
await project.open()
|
||||||
|
await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => {
|
||||||
|
project.trackSession(session.id)
|
||||||
|
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
|
return diff.length
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(1)
|
||||||
|
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
await show(page)
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
|
||||||
|
await expand(page)
|
||||||
|
await waitMark(page, file, tag)
|
||||||
|
await openReviewFile(page, file)
|
||||||
|
await fileComment(page, note)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
|
.toBeLessThanOrEqual(1)
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
|
.toBeLessThanOrEqual(1)
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
|
.toBeLessThanOrEqual(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, project }) => {
|
||||||
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
|
const tag = `review-${Date.now()}`
|
||||||
|
const list = files(tag)
|
||||||
|
const hit = list[list.length - 4]!
|
||||||
|
const next = `${tag}-live`
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1600, height: 1000 })
|
||||||
|
|
||||||
|
await project.open()
|
||||||
|
await withSession(project.sdk, `e2e review ${tag}`, async (session) => {
|
||||||
|
project.trackSession(session.id)
|
||||||
|
await patchWithMock(llm, project.sdk, session.id, seed(list))
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const info = await project.sdk.session.get({ sessionID: session.id }).then((res) => res.data)
|
||||||
|
return info?.summary?.files ?? 0
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(list.length)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
|
return diff.length
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(list.length)
|
||||||
|
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
await show(page)
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
|
||||||
|
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
||||||
|
await expect(view).toBeVisible()
|
||||||
|
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
|
||||||
|
await expect(heads).toHaveCount(list.length, { timeout: 60_000 })
|
||||||
|
|
||||||
|
await expand(page)
|
||||||
|
await waitMark(page, hit.file, hit.mark)
|
||||||
|
|
||||||
|
const row = page
|
||||||
|
.getByRole("heading", {
|
||||||
|
level: 3,
|
||||||
|
name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
||||||
|
})
|
||||||
|
.first()
|
||||||
|
await expect(row).toBeVisible()
|
||||||
|
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
|
||||||
|
|
||||||
|
await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
|
||||||
|
const prev = await spot(page, hit.file)
|
||||||
|
if (!prev) throw new Error(`missing review row for ${hit.file}`)
|
||||||
|
|
||||||
|
await patchWithMock(llm, project.sdk, session.id, edit(hit.file, hit.mark, next))
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
|
const item = diff.find((item) => item.file === hit.file)
|
||||||
|
return typeof item?.after === "string" ? item.after : ""
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toContain(`mark ${next}`)
|
||||||
|
|
||||||
|
await waitMark(page, hit.file, next)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const next = await spot(page, hit.file)
|
||||||
|
if (!next) return Number.POSITIVE_INFINITY
|
||||||
|
return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBeLessThanOrEqual(32)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -49,185 +49,185 @@ async function seedConversation(input: {
|
||||||
return { prompt, userMessageID }
|
return { prompt, userMessageID }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("slash undo sets revert and restores prior prompt", async ({ page, withProject }) => {
|
test("slash undo sets revert and restores prior prompt", async ({ page, project }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const token = `undo_${Date.now()}`
|
const token = `undo_${Date.now()}`
|
||||||
|
|
||||||
await withProject(async (project) => {
|
await project.open()
|
||||||
const sdk = createSdk(project.directory)
|
const sdk = project.sdk
|
||||||
|
|
||||||
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
|
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
|
||||||
await project.gotoSession(session.id)
|
project.trackSession(session.id)
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
||||||
|
|
||||||
await seeded.prompt.click()
|
await seeded.prompt.click()
|
||||||
await page.keyboard.type("/undo")
|
await page.keyboard.type("/undo")
|
||||||
|
|
||||||
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
||||||
await expect(undo).toBeVisible()
|
await expect(undo).toBeVisible()
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
})
|
})
|
||||||
.toBe(seeded.userMessageID)
|
.toBe(seeded.userMessageID)
|
||||||
|
|
||||||
await expect(seeded.prompt).toContainText(token)
|
await expect(seeded.prompt).toContainText(token)
|
||||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
|
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("slash redo clears revert and restores latest state", async ({ page, withProject }) => {
|
test("slash redo clears revert and restores latest state", async ({ page, project }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const token = `redo_${Date.now()}`
|
const token = `redo_${Date.now()}`
|
||||||
|
|
||||||
await withProject(async (project) => {
|
await project.open()
|
||||||
const sdk = createSdk(project.directory)
|
const sdk = project.sdk
|
||||||
|
|
||||||
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
|
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
|
||||||
await project.gotoSession(session.id)
|
project.trackSession(session.id)
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
||||||
|
|
||||||
await seeded.prompt.click()
|
await seeded.prompt.click()
|
||||||
await page.keyboard.type("/undo")
|
await page.keyboard.type("/undo")
|
||||||
|
|
||||||
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
||||||
await expect(undo).toBeVisible()
|
await expect(undo).toBeVisible()
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
})
|
})
|
||||||
.toBe(seeded.userMessageID)
|
.toBe(seeded.userMessageID)
|
||||||
|
|
||||||
await seeded.prompt.click()
|
await seeded.prompt.click()
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
await page.keyboard.press("Backspace")
|
await page.keyboard.press("Backspace")
|
||||||
await page.keyboard.type("/redo")
|
await page.keyboard.type("/redo")
|
||||||
|
|
||||||
const redo = page.locator('[data-slash-id="session.redo"]').first()
|
const redo = page.locator('[data-slash-id="session.redo"]').first()
|
||||||
await expect(redo).toBeVisible()
|
await expect(redo).toBeVisible()
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
})
|
})
|
||||||
.toBeUndefined()
|
.toBeUndefined()
|
||||||
|
|
||||||
await expect(seeded.prompt).not.toContainText(token)
|
await expect(seeded.prompt).not.toContainText(token)
|
||||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
|
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("slash undo/redo traverses multi-step revert stack", async ({ page, withProject }) => {
|
test("slash undo/redo traverses multi-step revert stack", async ({ page, project }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const firstToken = `undo_redo_first_${Date.now()}`
|
const firstToken = `undo_redo_first_${Date.now()}`
|
||||||
const secondToken = `undo_redo_second_${Date.now()}`
|
const secondToken = `undo_redo_second_${Date.now()}`
|
||||||
|
|
||||||
await withProject(async (project) => {
|
await project.open()
|
||||||
const sdk = createSdk(project.directory)
|
const sdk = project.sdk
|
||||||
|
|
||||||
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
|
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
|
||||||
await project.gotoSession(session.id)
|
project.trackSession(session.id)
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
const first = await seedConversation({
|
const first = await seedConversation({
|
||||||
page,
|
page,
|
||||||
sdk,
|
sdk,
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
token: firstToken,
|
token: firstToken,
|
||||||
})
|
|
||||||
const second = await seedConversation({
|
|
||||||
page,
|
|
||||||
sdk,
|
|
||||||
sessionID: session.id,
|
|
||||||
token: secondToken,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(first.userMessageID).not.toBe(second.userMessageID)
|
|
||||||
|
|
||||||
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
|
|
||||||
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
|
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(1)
|
|
||||||
await expect(secondMessage).toHaveCount(1)
|
|
||||||
|
|
||||||
await second.prompt.click()
|
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
|
||||||
await page.keyboard.press("Backspace")
|
|
||||||
await page.keyboard.type("/undo")
|
|
||||||
|
|
||||||
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
|
||||||
await expect(undo).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
.toBe(second.userMessageID)
|
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(1)
|
|
||||||
await expect(secondMessage).toHaveCount(0)
|
|
||||||
|
|
||||||
await second.prompt.click()
|
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
|
||||||
await page.keyboard.press("Backspace")
|
|
||||||
await page.keyboard.type("/undo")
|
|
||||||
await expect(undo).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
.toBe(first.userMessageID)
|
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(0)
|
|
||||||
await expect(secondMessage).toHaveCount(0)
|
|
||||||
|
|
||||||
await second.prompt.click()
|
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
|
||||||
await page.keyboard.press("Backspace")
|
|
||||||
await page.keyboard.type("/redo")
|
|
||||||
|
|
||||||
const redo = page.locator('[data-slash-id="session.redo"]').first()
|
|
||||||
await expect(redo).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
.toBe(second.userMessageID)
|
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(1)
|
|
||||||
await expect(secondMessage).toHaveCount(0)
|
|
||||||
|
|
||||||
await second.prompt.click()
|
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
|
||||||
await page.keyboard.press("Backspace")
|
|
||||||
await page.keyboard.type("/redo")
|
|
||||||
await expect(redo).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
.toBeUndefined()
|
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(1)
|
|
||||||
await expect(secondMessage).toHaveCount(1)
|
|
||||||
})
|
})
|
||||||
|
const second = await seedConversation({
|
||||||
|
page,
|
||||||
|
sdk,
|
||||||
|
sessionID: session.id,
|
||||||
|
token: secondToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(first.userMessageID).not.toBe(second.userMessageID)
|
||||||
|
|
||||||
|
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
|
||||||
|
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
|
||||||
|
|
||||||
|
await expect(firstMessage).toHaveCount(1)
|
||||||
|
await expect(secondMessage).toHaveCount(1)
|
||||||
|
|
||||||
|
await second.prompt.click()
|
||||||
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
|
await page.keyboard.press("Backspace")
|
||||||
|
await page.keyboard.type("/undo")
|
||||||
|
|
||||||
|
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
||||||
|
await expect(undo).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBe(second.userMessageID)
|
||||||
|
|
||||||
|
await expect(firstMessage).toHaveCount(1)
|
||||||
|
await expect(secondMessage).toHaveCount(0)
|
||||||
|
|
||||||
|
await second.prompt.click()
|
||||||
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
|
await page.keyboard.press("Backspace")
|
||||||
|
await page.keyboard.type("/undo")
|
||||||
|
await expect(undo).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBe(first.userMessageID)
|
||||||
|
|
||||||
|
await expect(firstMessage).toHaveCount(0)
|
||||||
|
await expect(secondMessage).toHaveCount(0)
|
||||||
|
|
||||||
|
await second.prompt.click()
|
||||||
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
|
await page.keyboard.press("Backspace")
|
||||||
|
await page.keyboard.type("/redo")
|
||||||
|
|
||||||
|
const redo = page.locator('[data-slash-id="session.redo"]').first()
|
||||||
|
await expect(redo).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBe(second.userMessageID)
|
||||||
|
|
||||||
|
await expect(firstMessage).toHaveCount(1)
|
||||||
|
await expect(secondMessage).toHaveCount(0)
|
||||||
|
|
||||||
|
await second.prompt.click()
|
||||||
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
|
await page.keyboard.press("Backspace")
|
||||||
|
await page.keyboard.type("/redo")
|
||||||
|
await expect(redo).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBeUndefined()
|
||||||
|
|
||||||
|
await expect(firstMessage).toHaveCount(1)
|
||||||
|
await expect(secondMessage).toHaveCount(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
openSharePopover,
|
openSharePopover,
|
||||||
withSession,
|
withSession,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { sessionItemSelector, inlineInputSelector, sessionTimelineHeaderSelector } from "../selectors"
|
import { sessionItemSelector, inlineInputSelector } from "../selectors"
|
||||||
|
|
||||||
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
||||||
|
|
||||||
|
|
@ -31,22 +31,22 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
|
||||||
.toBeGreaterThan(0)
|
.toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
|
test("session can be renamed via header menu", async ({ page, project }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const originalTitle = `e2e rename test ${stamp}`
|
const originalTitle = `e2e rename test ${stamp}`
|
||||||
const renamedTitle = `e2e renamed ${stamp}`
|
const renamedTitle = `e2e renamed ${stamp}`
|
||||||
|
|
||||||
await withSession(sdk, originalTitle, async (session) => {
|
await project.open()
|
||||||
await seedMessage(sdk, session.id)
|
await withSession(project.sdk, originalTitle, async (session) => {
|
||||||
await gotoSession(session.id)
|
project.trackSession(session.id)
|
||||||
await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText(
|
await seedMessage(project.sdk, session.id)
|
||||||
originalTitle,
|
await project.gotoSession(session.id)
|
||||||
)
|
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
|
||||||
|
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /rename/i)
|
await clickMenuItem(menu, /rename/i)
|
||||||
|
|
||||||
const input = page.locator(sessionTimelineHeaderSelector).locator(inlineInputSelector).first()
|
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
|
||||||
await expect(input).toBeVisible()
|
await expect(input).toBeVisible()
|
||||||
await expect(input).toBeFocused()
|
await expect(input).toBeFocused()
|
||||||
await input.fill(renamedTitle)
|
await input.fill(renamedTitle)
|
||||||
|
|
@ -56,33 +56,33 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.title
|
return data?.title
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
)
|
)
|
||||||
.toBe(renamedTitle)
|
.toBe(renamedTitle)
|
||||||
|
|
||||||
await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText(
|
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
|
||||||
renamedTitle,
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session can be archived via header menu", async ({ page, sdk, gotoSession }) => {
|
test("session can be archived via header menu", async ({ page, project }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const title = `e2e archive test ${stamp}`
|
const title = `e2e archive test ${stamp}`
|
||||||
|
|
||||||
await withSession(sdk, title, async (session) => {
|
await project.open()
|
||||||
await seedMessage(sdk, session.id)
|
await withSession(project.sdk, title, async (session) => {
|
||||||
await gotoSession(session.id)
|
project.trackSession(session.id)
|
||||||
|
await seedMessage(project.sdk, session.id)
|
||||||
|
await project.gotoSession(session.id)
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /archive/i)
|
await clickMenuItem(menu, /archive/i)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.time?.archived
|
return data?.time?.archived
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
|
|
@ -94,13 +94,15 @@ test("session can be archived via header menu", async ({ page, sdk, gotoSession
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session can be deleted via header menu", async ({ page, sdk, gotoSession }) => {
|
test("session can be deleted via header menu", async ({ page, project }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const title = `e2e delete test ${stamp}`
|
const title = `e2e delete test ${stamp}`
|
||||||
|
|
||||||
await withSession(sdk, title, async (session) => {
|
await project.open()
|
||||||
await seedMessage(sdk, session.id)
|
await withSession(project.sdk, title, async (session) => {
|
||||||
await gotoSession(session.id)
|
project.trackSession(session.id)
|
||||||
|
await seedMessage(project.sdk, session.id)
|
||||||
|
await project.gotoSession(session.id)
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /delete/i)
|
await clickMenuItem(menu, /delete/i)
|
||||||
await confirmDialog(page, /delete/i)
|
await confirmDialog(page, /delete/i)
|
||||||
|
|
@ -108,7 +110,7 @@ test("session can be deleted via header menu", async ({ page, sdk, gotoSession }
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await sdk.session
|
const data = await project.sdk.session
|
||||||
.get({ sessionID: session.id })
|
.get({ sessionID: session.id })
|
||||||
.then((r) => r.data)
|
.then((r) => r.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
|
|
@ -123,15 +125,17 @@ test("session can be deleted via header menu", async ({ page, sdk, gotoSession }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => {
|
test("session can be shared and unshared via header button", async ({ page, project }) => {
|
||||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||||
|
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const title = `e2e share test ${stamp}`
|
const title = `e2e share test ${stamp}`
|
||||||
|
|
||||||
await withSession(sdk, title, async (session) => {
|
await project.open()
|
||||||
await seedMessage(sdk, session.id)
|
await withSession(project.sdk, title, async (session) => {
|
||||||
await gotoSession(session.id)
|
project.trackSession(session.id)
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
await project.prompt(`share seed ${stamp}`)
|
||||||
|
|
||||||
const shared = await openSharePopover(page)
|
const shared = await openSharePopover(page)
|
||||||
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
||||||
|
|
@ -145,7 +149,7 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.share?.url || undefined
|
return data?.share?.url || undefined
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
|
|
@ -163,7 +167,7 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.share?.url || undefined
|
return data?.share?.url || undefined
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSettings, closeDialog, withSession } from "../actions"
|
import { openSettings, closeDialog, waitTerminalFocusIdle, withSession } from "../actions"
|
||||||
import { keybindButtonSelector, terminalSelector } from "../selectors"
|
import { keybindButtonSelector, terminalSelector } from "../selectors"
|
||||||
import { modKey } from "../utils"
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
|
|
@ -241,7 +241,7 @@ test("changing file open keybind works", async ({ page, gotoSession }) => {
|
||||||
await expect(keybindButton).toBeVisible()
|
await expect(keybindButton).toBeVisible()
|
||||||
|
|
||||||
const initialKeybind = await keybindButton.textContent()
|
const initialKeybind = await keybindButton.textContent()
|
||||||
expect(initialKeybind).toContain("P")
|
expect(initialKeybind).toContain("K")
|
||||||
|
|
||||||
await keybindButton.click()
|
await keybindButton.click()
|
||||||
await expect(keybindButton).toHaveText(/press/i)
|
await expect(keybindButton).toHaveText(/press/i)
|
||||||
|
|
@ -302,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
|
||||||
await expect(terminal).not.toBeVisible()
|
await expect(terminal).not.toBeVisible()
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Y`)
|
await page.keyboard.press(`${modKey}+Y`)
|
||||||
await expect(terminal).toBeVisible()
|
await waitTerminalFocusIdle(page, { term: terminal })
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Y`)
|
await page.keyboard.press(`${modKey}+Y`)
|
||||||
await expect(terminal).not.toBeVisible()
|
await expect(terminal).not.toBeVisible()
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { test, expect, settingsKey } from "../fixtures"
|
||||||
import { closeDialog, openSettings } from "../actions"
|
import { closeDialog, openSettings } from "../actions"
|
||||||
import {
|
import {
|
||||||
settingsColorSchemeSelector,
|
settingsColorSchemeSelector,
|
||||||
settingsFontSelector,
|
settingsCodeFontSelector,
|
||||||
settingsLanguageSelectSelector,
|
settingsLanguageSelectSelector,
|
||||||
settingsNotificationsAgentSelector,
|
settingsNotificationsAgentSelector,
|
||||||
settingsNotificationsErrorsSelector,
|
settingsNotificationsErrorsSelector,
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
settingsSoundsErrorsSelector,
|
settingsSoundsErrorsSelector,
|
||||||
settingsSoundsPermissionsSelector,
|
settingsSoundsPermissionsSelector,
|
||||||
settingsThemeSelector,
|
settingsThemeSelector,
|
||||||
|
settingsUIFontSelector,
|
||||||
settingsUpdatesStartupSelector,
|
settingsUpdatesStartupSelector,
|
||||||
} from "../selectors"
|
} from "../selectors"
|
||||||
|
|
||||||
|
|
@ -83,16 +84,33 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
||||||
const select = dialog.locator(settingsThemeSelector)
|
const select = dialog.locator(settingsThemeSelector)
|
||||||
await expect(select).toBeVisible()
|
await expect(select).toBeVisible()
|
||||||
|
|
||||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
const currentThemeId = await page.evaluate(() => {
|
||||||
|
return document.documentElement.getAttribute("data-theme")
|
||||||
|
})
|
||||||
|
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
||||||
|
const trigger = select.locator('[data-slot="select-select-trigger"]')
|
||||||
const items = page.locator('[data-slot="select-select-item"]')
|
const items = page.locator('[data-slot="select-select-item"]')
|
||||||
|
|
||||||
|
await trigger.click()
|
||||||
|
const open = await expect
|
||||||
|
.poll(async () => (await items.count()) > 0, { timeout: 5_000 })
|
||||||
|
.toBe(true)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (!open) {
|
||||||
|
await trigger.click()
|
||||||
|
await expect.poll(async () => (await items.count()) > 0, { timeout: 10_000 }).toBe(true)
|
||||||
|
}
|
||||||
|
await expect(items.first()).toBeVisible()
|
||||||
const count = await items.count()
|
const count = await items.count()
|
||||||
expect(count).toBeGreaterThan(1)
|
expect(count).toBeGreaterThan(1)
|
||||||
|
|
||||||
const firstTheme = await items.nth(1).locator('[data-slot="select-select-item-label"]').textContent()
|
const nextTheme = (await items.locator('[data-slot="select-select-item-label"]').allTextContents())
|
||||||
expect(firstTheme).toBeTruthy()
|
.map((x) => x.trim())
|
||||||
|
.find((x) => x && x !== currentTheme)
|
||||||
|
expect(nextTheme).toBeTruthy()
|
||||||
|
|
||||||
await items.nth(1).click()
|
await items.filter({ hasText: nextTheme! }).first().click()
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
await page.keyboard.press("Escape")
|
||||||
|
|
||||||
|
|
@ -101,7 +119,7 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(storedThemeId).not.toBeNull()
|
expect(storedThemeId).not.toBeNull()
|
||||||
expect(storedThemeId).not.toBe("oc-1")
|
expect(storedThemeId).not.toBe(currentThemeId)
|
||||||
|
|
||||||
const dataTheme = await page.evaluate(() => {
|
const dataTheme = await page.evaluate(() => {
|
||||||
return document.documentElement.getAttribute("data-theme")
|
return document.documentElement.getAttribute("data-theme")
|
||||||
|
|
@ -109,74 +127,64 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
||||||
expect(dataTheme).toBe(storedThemeId)
|
expect(dataTheme).toBe(storedThemeId)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
|
test("legacy oc-1 theme migrates to oc-2", async ({ page, gotoSession }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem("opencode-theme-id", "oc-1")
|
||||||
|
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
|
||||||
|
localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;")
|
||||||
|
})
|
||||||
|
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
await expect(page.locator("html")).toHaveAttribute("data-theme", "oc-2")
|
||||||
const select = dialog.locator(settingsFontSelector)
|
|
||||||
await expect(select).toBeVisible()
|
|
||||||
|
|
||||||
const initialFontFamily = await page.evaluate(() => {
|
await expect
|
||||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
|
.poll(async () => {
|
||||||
})
|
return await page.evaluate(() => {
|
||||||
expect(initialFontFamily).toContain("IBM Plex Mono")
|
return localStorage.getItem("opencode-theme-id")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.toBe("oc-2")
|
||||||
|
|
||||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
return localStorage.getItem("opencode-theme-css-light")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.toBeNull()
|
||||||
|
|
||||||
const items = page.locator('[data-slot="select-select-item"]')
|
await expect
|
||||||
await items.nth(2).click()
|
.poll(async () => {
|
||||||
|
return await page.evaluate(() => {
|
||||||
await page.waitForTimeout(100)
|
return localStorage.getItem("opencode-theme-css-dark")
|
||||||
|
})
|
||||||
const stored = await page.evaluate((key) => {
|
})
|
||||||
const raw = localStorage.getItem(key)
|
.toBeNull()
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
|
|
||||||
expect(stored?.appearance?.font).not.toBe("ibm-plex-mono")
|
|
||||||
|
|
||||||
const newFontFamily = await page.evaluate(() => {
|
|
||||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
|
|
||||||
})
|
|
||||||
expect(newFontFamily).not.toBe(initialFontFamily)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("color scheme and font rehydrate after reload", async ({ page, gotoSession }) => {
|
test("typing a code font with spaces persists and updates CSS variable", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
const dialog = await openSettings(page)
|
const dialog = await openSettings(page)
|
||||||
|
const input = dialog.locator(settingsCodeFontSelector)
|
||||||
|
await expect(input).toBeVisible()
|
||||||
|
await expect(input).toHaveAttribute("placeholder", "System Mono")
|
||||||
|
|
||||||
const colorSchemeSelect = dialog.locator(settingsColorSchemeSelector)
|
const initialFontFamily = await page.evaluate(() =>
|
||||||
await expect(colorSchemeSelect).toBeVisible()
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||||
await colorSchemeSelect.locator('[data-slot="select-select-trigger"]').click()
|
)
|
||||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
|
const initialUIFamily = await page.evaluate(() =>
|
||||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||||
|
)
|
||||||
|
expect(initialFontFamily).toContain("ui-monospace")
|
||||||
|
|
||||||
const fontSelect = dialog.locator(settingsFontSelector)
|
const next = "Test Mono"
|
||||||
await expect(fontSelect).toBeVisible()
|
|
||||||
|
|
||||||
const initialFontFamily = await page.evaluate(() => {
|
await input.click()
|
||||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
await input.clear()
|
||||||
})
|
await input.pressSequentially(next)
|
||||||
|
await expect(input).toHaveValue(next)
|
||||||
const initialSettings = await page.evaluate((key) => {
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : null
|
|
||||||
}, settingsKey)
|
|
||||||
|
|
||||||
const currentFont =
|
|
||||||
(await fontSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
|
||||||
await fontSelect.locator('[data-slot="select-select-trigger"]').click()
|
|
||||||
|
|
||||||
const fontItems = page.locator('[data-slot="select-select-item"]')
|
|
||||||
expect(await fontItems.count()).toBeGreaterThan(1)
|
|
||||||
|
|
||||||
if (currentFont) {
|
|
||||||
await fontItems.filter({ hasNotText: currentFont }).first().click()
|
|
||||||
}
|
|
||||||
if (!currentFont) {
|
|
||||||
await fontItems.nth(1).click()
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
|
|
@ -187,7 +195,218 @@ test("color scheme and font rehydrate after reload", async ({ page, gotoSession
|
||||||
})
|
})
|
||||||
.toMatchObject({
|
.toMatchObject({
|
||||||
appearance: {
|
appearance: {
|
||||||
font: expect.any(String),
|
mono: next,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const newFontFamily = await page.evaluate(() =>
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||||
|
)
|
||||||
|
const newUIFamily = await page.evaluate(() =>
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||||
|
)
|
||||||
|
expect(newFontFamily).toContain(next)
|
||||||
|
expect(newFontFamily).not.toBe(initialFontFamily)
|
||||||
|
expect(newUIFamily).toBe(initialUIFamily)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("typing a UI font with spaces persists and updates CSS variable", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
const input = dialog.locator(settingsUIFontSelector)
|
||||||
|
await expect(input).toBeVisible()
|
||||||
|
await expect(input).toHaveAttribute("placeholder", "System Sans")
|
||||||
|
|
||||||
|
const initialFontFamily = await page.evaluate(() =>
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||||
|
)
|
||||||
|
const initialCodeFamily = await page.evaluate(() =>
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||||
|
)
|
||||||
|
expect(initialFontFamily).toContain("ui-sans-serif")
|
||||||
|
|
||||||
|
const next = "Test Sans"
|
||||||
|
|
||||||
|
await input.click()
|
||||||
|
await input.clear()
|
||||||
|
await input.pressSequentially(next)
|
||||||
|
await expect(input).toHaveValue(next)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
})
|
||||||
|
.toMatchObject({
|
||||||
|
appearance: {
|
||||||
|
sans: next,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const newFontFamily = await page.evaluate(() =>
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||||
|
)
|
||||||
|
const newCodeFamily = await page.evaluate(() =>
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||||
|
)
|
||||||
|
expect(newFontFamily).toContain(next)
|
||||||
|
expect(newFontFamily).not.toBe(initialFontFamily)
|
||||||
|
expect(newCodeFamily).toBe(initialCodeFamily)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clearing the code font field restores the default placeholder and stack", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
const input = dialog.locator(settingsCodeFontSelector)
|
||||||
|
await expect(input).toBeVisible()
|
||||||
|
|
||||||
|
await input.click()
|
||||||
|
await input.clear()
|
||||||
|
await input.pressSequentially("Reset Mono")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
})
|
||||||
|
.toMatchObject({
|
||||||
|
appearance: {
|
||||||
|
mono: "Reset Mono",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await input.clear()
|
||||||
|
await input.press("Space")
|
||||||
|
await expect(input).toHaveValue("")
|
||||||
|
await expect(input).toHaveAttribute("placeholder", "System Mono")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
})
|
||||||
|
.toMatchObject({
|
||||||
|
appearance: {
|
||||||
|
mono: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const fontFamily = await page.evaluate(() =>
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||||
|
)
|
||||||
|
expect(fontFamily).toContain("ui-monospace")
|
||||||
|
expect(fontFamily).not.toContain("Reset Mono")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clearing the UI font field restores the default placeholder and stack", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
const input = dialog.locator(settingsUIFontSelector)
|
||||||
|
await expect(input).toBeVisible()
|
||||||
|
|
||||||
|
await input.click()
|
||||||
|
await input.clear()
|
||||||
|
await input.pressSequentially("Reset Sans")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
})
|
||||||
|
.toMatchObject({
|
||||||
|
appearance: {
|
||||||
|
sans: "Reset Sans",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await input.clear()
|
||||||
|
await input.press("Space")
|
||||||
|
await expect(input).toHaveValue("")
|
||||||
|
await expect(input).toHaveAttribute("placeholder", "System Sans")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
})
|
||||||
|
.toMatchObject({
|
||||||
|
appearance: {
|
||||||
|
sans: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const fontFamily = await page.evaluate(() =>
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||||
|
)
|
||||||
|
expect(fontFamily).toContain("ui-sans-serif")
|
||||||
|
expect(fontFamily).not.toContain("Reset Sans")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("color scheme, code font, and UI font rehydrate after reload", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
|
||||||
|
const colorSchemeSelect = dialog.locator(settingsColorSchemeSelector)
|
||||||
|
await expect(colorSchemeSelect).toBeVisible()
|
||||||
|
await colorSchemeSelect.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
|
||||||
|
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
||||||
|
|
||||||
|
const code = dialog.locator(settingsCodeFontSelector)
|
||||||
|
const ui = dialog.locator(settingsUIFontSelector)
|
||||||
|
await expect(code).toBeVisible()
|
||||||
|
await expect(ui).toBeVisible()
|
||||||
|
|
||||||
|
const initialMono = await page.evaluate(() =>
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||||
|
)
|
||||||
|
const initialSans = await page.evaluate(() =>
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const initialSettings = await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
|
||||||
|
const mono = initialSettings?.appearance?.mono === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
|
||||||
|
const sans = initialSettings?.appearance?.sans === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
|
||||||
|
|
||||||
|
await code.click()
|
||||||
|
await code.clear()
|
||||||
|
await code.pressSequentially(mono)
|
||||||
|
await expect(code).toHaveValue(mono)
|
||||||
|
|
||||||
|
await ui.click()
|
||||||
|
await ui.clear()
|
||||||
|
await ui.pressSequentially(sans)
|
||||||
|
await expect(ui).toHaveValue(sans)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : null
|
||||||
|
}, settingsKey)
|
||||||
|
})
|
||||||
|
.toMatchObject({
|
||||||
|
appearance: {
|
||||||
|
mono,
|
||||||
|
sans,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -196,11 +415,18 @@ test("color scheme and font rehydrate after reload", async ({ page, gotoSession
|
||||||
return raw ? JSON.parse(raw) : null
|
return raw ? JSON.parse(raw) : null
|
||||||
}, settingsKey)
|
}, settingsKey)
|
||||||
|
|
||||||
const updatedFontFamily = await page.evaluate(() => {
|
const updatedMono = await page.evaluate(() =>
|
||||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||||
})
|
)
|
||||||
expect(updatedFontFamily).not.toBe(initialFontFamily)
|
const updatedSans = await page.evaluate(() =>
|
||||||
expect(updatedSettings?.appearance?.font).not.toBe(initialSettings?.appearance?.font)
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||||
|
)
|
||||||
|
expect(updatedMono).toContain(mono)
|
||||||
|
expect(updatedMono).not.toBe(initialMono)
|
||||||
|
expect(updatedSans).toContain(sans)
|
||||||
|
expect(updatedSans).not.toBe(initialSans)
|
||||||
|
expect(updatedSettings?.appearance?.mono).toBe(mono)
|
||||||
|
expect(updatedSettings?.appearance?.sans).toBe(sans)
|
||||||
|
|
||||||
await closeDialog(page, dialog)
|
await closeDialog(page, dialog)
|
||||||
await page.reload()
|
await page.reload()
|
||||||
|
|
@ -216,7 +442,8 @@ test("color scheme and font rehydrate after reload", async ({ page, gotoSession
|
||||||
})
|
})
|
||||||
.toMatchObject({
|
.toMatchObject({
|
||||||
appearance: {
|
appearance: {
|
||||||
font: updatedSettings?.appearance?.font,
|
mono,
|
||||||
|
sans,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -227,17 +454,32 @@ test("color scheme and font rehydrate after reload", async ({ page, gotoSession
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => {
|
.poll(async () => {
|
||||||
return await page.evaluate(() => {
|
return await page.evaluate(() =>
|
||||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||||
})
|
)
|
||||||
})
|
})
|
||||||
.not.toBe(initialFontFamily)
|
.toContain(mono)
|
||||||
|
|
||||||
const rehydratedFontFamily = await page.evaluate(() => {
|
await expect
|
||||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
.poll(async () => {
|
||||||
})
|
return await page.evaluate(() =>
|
||||||
expect(rehydratedFontFamily).not.toBe(initialFontFamily)
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||||
expect(rehydratedSettings?.appearance?.font).toBe(updatedSettings?.appearance?.font)
|
)
|
||||||
|
})
|
||||||
|
.toContain(sans)
|
||||||
|
|
||||||
|
const rehydratedMono = await page.evaluate(() =>
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||||
|
)
|
||||||
|
const rehydratedSans = await page.evaluate(() =>
|
||||||
|
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||||
|
)
|
||||||
|
expect(rehydratedMono).toContain(mono)
|
||||||
|
expect(rehydratedMono).not.toBe(initialMono)
|
||||||
|
expect(rehydratedSans).toContain(sans)
|
||||||
|
expect(rehydratedSans).not.toBe(initialSans)
|
||||||
|
expect(rehydratedSettings?.appearance?.mono).toBe(mono)
|
||||||
|
expect(rehydratedSettings?.appearance?.sans).toBe(sans)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
|
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
|
import {
|
||||||
|
defocus,
|
||||||
|
cleanupSession,
|
||||||
|
cleanupTestProject,
|
||||||
|
closeSidebar,
|
||||||
|
createTestProject,
|
||||||
|
hoverSessionItem,
|
||||||
|
openSidebar,
|
||||||
|
waitSession,
|
||||||
|
} from "../actions"
|
||||||
import { projectSwitchSelector } from "../selectors"
|
import { projectSwitchSelector } from "../selectors"
|
||||||
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
|
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
|
|
@ -37,3 +47,63 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
||||||
await cleanupSession({ sdk, sessionID: two.id })
|
await cleanupSession({ sdk, sessionID: two.id })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("open sidebar project popover stays closed after clicking avatar", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const other = await createTestProject()
|
||||||
|
const slug = dirSlug(other)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await project.open({ extra: [other] })
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const projectButton = page.locator(projectSwitchSelector(slug)).first()
|
||||||
|
const card = page.locator('[data-component="hover-card-content"]')
|
||||||
|
|
||||||
|
await expect(projectButton).toBeVisible()
|
||||||
|
await projectButton.hover()
|
||||||
|
await expect(card.getByText(/recent sessions/i)).toBeVisible()
|
||||||
|
|
||||||
|
await projectButton.click()
|
||||||
|
await expect(card).toHaveCount(0)
|
||||||
|
|
||||||
|
await waitSession(page, { directory: other })
|
||||||
|
await expect(card).toHaveCount(0)
|
||||||
|
} finally {
|
||||||
|
await cleanupTestProject(other)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("open sidebar project switch activates on first tabbed enter", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const other = await createTestProject()
|
||||||
|
const slug = dirSlug(other)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await project.open({ extra: [other] })
|
||||||
|
await openSidebar(page)
|
||||||
|
await defocus(page)
|
||||||
|
|
||||||
|
const projectButton = page.locator(projectSwitchSelector(slug)).first()
|
||||||
|
|
||||||
|
await expect(projectButton).toBeVisible()
|
||||||
|
|
||||||
|
let hit = false
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
hit = await projectButton.evaluate((el) => {
|
||||||
|
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
|
||||||
|
})
|
||||||
|
if (hit) break
|
||||||
|
await page.keyboard.press("Tab")
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(hit).toBe(true)
|
||||||
|
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await waitSession(page, { directory: other })
|
||||||
|
} finally {
|
||||||
|
await cleanupTestProject(other)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
import { waitTerminalFocusIdle, waitTerminalReady } from "../actions"
|
||||||
import { promptSelector, terminalSelector } from "../selectors"
|
import { promptSelector, terminalSelector } from "../selectors"
|
||||||
import { terminalToggleKey } from "../utils"
|
import { terminalToggleKey } from "../utils"
|
||||||
|
|
||||||
|
|
@ -13,8 +14,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
|
||||||
await page.keyboard.press(terminalToggleKey)
|
await page.keyboard.press(terminalToggleKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
await expect(terminals.first()).toBeVisible()
|
await waitTerminalFocusIdle(page, { term: terminals.first() })
|
||||||
await expect(terminals.first().locator("textarea")).toHaveCount(1)
|
|
||||||
await expect(terminals).toHaveCount(1)
|
await expect(terminals).toHaveCount(1)
|
||||||
|
|
||||||
// Ghostty captures a lot of keybinds when focused; move focus back
|
// Ghostty captures a lot of keybinds when focused; move focus back
|
||||||
|
|
@ -24,5 +24,5 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
|
||||||
|
|
||||||
await expect(tabs).toHaveCount(2)
|
await expect(tabs).toHaveCount(2)
|
||||||
await expect(terminals).toHaveCount(1)
|
await expect(terminals).toHaveCount(1)
|
||||||
await expect(terminals.first().locator("textarea")).toHaveCount(1)
|
await waitTerminalReady(page, { term: terminals.first() })
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { disconnectTerminal, runTerminal, terminalConnects, waitTerminalReady } from "../actions"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { terminalSelector } from "../selectors"
|
||||||
|
import { terminalToggleKey } from "../utils"
|
||||||
|
|
||||||
|
async function open(page: Page) {
|
||||||
|
const term = page.locator(terminalSelector).first()
|
||||||
|
const visible = await term.isVisible().catch(() => false)
|
||||||
|
if (!visible) await page.keyboard.press(terminalToggleKey)
|
||||||
|
await waitTerminalReady(page, { term })
|
||||||
|
return term
|
||||||
|
}
|
||||||
|
|
||||||
|
test("terminal reconnects without replacing the pty", async ({ page, project }) => {
|
||||||
|
await project.open()
|
||||||
|
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
|
||||||
|
const token = `E2E_RECONNECT_${Date.now()}`
|
||||||
|
|
||||||
|
await project.gotoSession()
|
||||||
|
|
||||||
|
const term = await open(page)
|
||||||
|
const id = await term.getAttribute("data-pty-id")
|
||||||
|
if (!id) throw new Error("Active terminal missing data-pty-id")
|
||||||
|
|
||||||
|
const prev = await terminalConnects(page, { term })
|
||||||
|
|
||||||
|
await runTerminal(page, {
|
||||||
|
term,
|
||||||
|
cmd: `export ${name}=${token}; echo ${token}`,
|
||||||
|
token,
|
||||||
|
})
|
||||||
|
|
||||||
|
await disconnectTerminal(page, { term })
|
||||||
|
|
||||||
|
await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev)
|
||||||
|
await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id)
|
||||||
|
|
||||||
|
await runTerminal(page, {
|
||||||
|
term,
|
||||||
|
cmd: `echo $${name}`,
|
||||||
|
token,
|
||||||
|
timeout: 15_000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
|
import { runTerminal, waitTerminalReady } from "../actions"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { terminalSelector } from "../selectors"
|
import { dropdownMenuContentSelector, terminalSelector } from "../selectors"
|
||||||
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
|
|
@ -17,16 +18,7 @@ async function open(page: Page) {
|
||||||
const terminal = page.locator(terminalSelector)
|
const terminal = page.locator(terminalSelector)
|
||||||
const visible = await terminal.isVisible().catch(() => false)
|
const visible = await terminal.isVisible().catch(() => false)
|
||||||
if (!visible) await page.keyboard.press(terminalToggleKey)
|
if (!visible) await page.keyboard.press(terminalToggleKey)
|
||||||
await expect(terminal).toBeVisible()
|
await waitTerminalReady(page, { term: terminal })
|
||||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run(page: Page, cmd: string) {
|
|
||||||
const terminal = page.locator(terminalSelector)
|
|
||||||
await expect(terminal).toBeVisible()
|
|
||||||
await terminal.click()
|
|
||||||
await page.keyboard.type(cmd)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function store(page: Page, key: string) {
|
async function store(page: Page, key: string) {
|
||||||
|
|
@ -44,77 +36,130 @@ async function store(page: Page, key: string) {
|
||||||
}, key)
|
}, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
|
test("inactive terminal tab buffers persist across tab switches", async ({ page, project }) => {
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
await project.open()
|
||||||
const key = workspacePersistKey(directory, "terminal")
|
const key = workspacePersistKey(project.directory, "terminal")
|
||||||
const one = `E2E_TERM_ONE_${Date.now()}`
|
const one = `E2E_TERM_ONE_${Date.now()}`
|
||||||
const two = `E2E_TERM_TWO_${Date.now()}`
|
const two = `E2E_TERM_TWO_${Date.now()}`
|
||||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||||
|
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||||
|
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||||
|
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
await open(page)
|
await open(page)
|
||||||
|
|
||||||
await run(page, `echo ${one}`)
|
await runTerminal(page, { cmd: `echo ${one}`, token: one })
|
||||||
|
|
||||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||||
await expect(tabs).toHaveCount(2)
|
await expect(tabs).toHaveCount(2)
|
||||||
|
|
||||||
await run(page, `echo ${two}`)
|
await runTerminal(page, { cmd: `echo ${two}`, token: two })
|
||||||
|
|
||||||
await tabs
|
await first.click()
|
||||||
.filter({ hasText: /Terminal 1/ })
|
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const state = await store(page, key)
|
const state = await store(page, key)
|
||||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||||
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||||
return first.includes(one) && second.includes(two)
|
return {
|
||||||
},
|
first: first.includes(one),
|
||||||
{ timeout: 30_000 },
|
second: second.includes(two),
|
||||||
)
|
}
|
||||||
.toBe(true)
|
},
|
||||||
})
|
{ timeout: 5_000 },
|
||||||
|
)
|
||||||
|
.toEqual({ first: false, second: true })
|
||||||
|
|
||||||
|
await second.click()
|
||||||
|
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const state = await store(page, key)
|
||||||
|
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||||
|
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||||
|
return {
|
||||||
|
first: first.includes(one),
|
||||||
|
second: second.includes(two),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 5_000 },
|
||||||
|
)
|
||||||
|
.toEqual({ first: true, second: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
|
test("closing the active terminal tab falls back to the previous tab", async ({ page, project }) => {
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
await project.open()
|
||||||
const key = workspacePersistKey(directory, "terminal")
|
const key = workspacePersistKey(project.directory, "terminal")
|
||||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||||
|
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
await open(page)
|
await open(page)
|
||||||
|
|
||||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||||
await expect(tabs).toHaveCount(2)
|
await expect(tabs).toHaveCount(2)
|
||||||
|
|
||||||
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||||
await second.click()
|
await second.click()
|
||||||
await expect(second).toHaveAttribute("aria-selected", "true")
|
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
await second.hover()
|
await second.hover()
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: /close terminal/i })
|
.getByRole("button", { name: /close terminal/i })
|
||||||
.nth(1)
|
.nth(1)
|
||||||
.click({ force: true })
|
.click({ force: true })
|
||||||
|
|
||||||
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||||
await expect(tabs).toHaveCount(1)
|
await expect(tabs).toHaveCount(1)
|
||||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const state = await store(page, key)
|
const state = await store(page, key)
|
||||||
return {
|
return {
|
||||||
count: state?.all.length ?? 0,
|
count: state?.all.length ?? 0,
|
||||||
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
|
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
)
|
)
|
||||||
.toEqual({ count: 1, first: true })
|
.toEqual({ count: 1, first: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("terminal tab can be renamed from the context menu", async ({ page, project }) => {
|
||||||
|
await project.open()
|
||||||
|
const key = workspacePersistKey(project.directory, "terminal")
|
||||||
|
const rename = `E2E term ${Date.now()}`
|
||||||
|
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
|
||||||
|
|
||||||
|
await project.gotoSession()
|
||||||
|
await open(page)
|
||||||
|
|
||||||
|
await expect(tab).toContainText(/Terminal 1/)
|
||||||
|
await tab.click({ button: "right" })
|
||||||
|
|
||||||
|
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
|
||||||
|
await expect(menu).toHaveCount(0)
|
||||||
|
|
||||||
|
const input = page.locator('#terminal-panel input[type="text"]').first()
|
||||||
|
await expect(input).toBeVisible()
|
||||||
|
await input.fill(rename)
|
||||||
|
await input.press("Enter")
|
||||||
|
|
||||||
|
await expect(input).toHaveCount(0)
|
||||||
|
await expect(tab).toContainText(rename)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const state = await store(page, key)
|
||||||
|
return state?.all[0]?.title
|
||||||
|
},
|
||||||
|
{ timeout: 5_000 },
|
||||||
|
)
|
||||||
|
.toBe(rename)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
import { waitTerminalReady } from "../actions"
|
||||||
import { terminalSelector } from "../selectors"
|
import { terminalSelector } from "../selectors"
|
||||||
import { terminalToggleKey } from "../utils"
|
import { terminalToggleKey } from "../utils"
|
||||||
|
|
||||||
|
|
@ -13,5 +14,5 @@ test("terminal panel can be toggled", async ({ page, gotoSession }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.keyboard.press(terminalToggleKey)
|
await page.keyboard.press(terminalToggleKey)
|
||||||
await expect(terminal).toBeVisible()
|
await waitTerminalReady(page, { term: terminal })
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
"extends": "../tsconfig.json",
|
"extends": "../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
"rootDir": "..",
|
||||||
"types": ["node", "bun"]
|
"types": ["node", "bun"]
|
||||||
},
|
},
|
||||||
"include": ["./**/*.ts"]
|
"include": ["./**/*.ts", "../src/testing/terminal.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,21 +26,21 @@ export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("
|
||||||
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
||||||
export const terminalToggleKey = "Control+Backquote"
|
export const terminalToggleKey = "Control+Backquote"
|
||||||
|
|
||||||
export function createSdk(directory?: string) {
|
export function createSdk(directory?: string, baseUrl = serverUrl) {
|
||||||
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
return createOpencodeClient({ baseUrl, directory, throwOnError: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveDirectory(directory: string) {
|
export async function resolveDirectory(directory: string, baseUrl = serverUrl) {
|
||||||
return createSdk(directory)
|
return createSdk(directory, baseUrl)
|
||||||
.path.get()
|
.path.get()
|
||||||
.then((x) => x.data?.directory ?? directory)
|
.then((x) => x.data?.directory ?? directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWorktree() {
|
export async function getWorktree(baseUrl = serverUrl) {
|
||||||
const sdk = createSdk()
|
const sdk = createSdk(undefined, baseUrl)
|
||||||
const result = await sdk.path.get()
|
const result = await sdk.path.get()
|
||||||
const data = result.data
|
const data = result.data
|
||||||
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${serverUrl}/path`)
|
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${baseUrl}/path`)
|
||||||
return data.worktree
|
return data.worktree
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,7 +57,7 @@ export function sessionPath(directory: string, sessionID?: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function workspacePersistKey(directory: string, key: string) {
|
export function workspacePersistKey(directory: string, key: string) {
|
||||||
const head = directory.slice(0, 12) || "workspace"
|
const head = (directory.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||||
const sum = checksum(directory) ?? "0"
|
const sum = checksum(directory) ?? "0"
|
||||||
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
|
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<html lang="en" style="background-color: var(--background-base)">
|
<html lang="en" style="background-color: var(--background-base)">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
|
||||||
<title>OpenCode</title>
|
<title>OpenCode</title>
|
||||||
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
|
<link rel="icon" type="image/png" href="/favicon-96x96-v3.png" sizes="96x96" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon-v3.svg" />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.2.21",
|
"version": "1.4.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"test": "bun run test:unit",
|
"test": "bun run test:unit",
|
||||||
|
"test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||||
"test:unit": "bun test --preload ./happydom.ts ./src",
|
"test:unit": "bun test --preload ./happydom.ts ./src",
|
||||||
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
|
|
@ -45,17 +46,21 @@
|
||||||
"@shikijs/transformers": "3.9.2",
|
"@shikijs/transformers": "3.9.2",
|
||||||
"@solid-primitives/active-element": "2.1.3",
|
"@solid-primitives/active-element": "2.1.3",
|
||||||
"@solid-primitives/audio": "1.4.2",
|
"@solid-primitives/audio": "1.4.2",
|
||||||
"@solid-primitives/i18n": "2.2.1",
|
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
|
"@solid-primitives/event-listener": "2.4.5",
|
||||||
|
"@solid-primitives/i18n": "2.2.1",
|
||||||
"@solid-primitives/media": "2.3.3",
|
"@solid-primitives/media": "2.3.3",
|
||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.5",
|
||||||
"@solid-primitives/scroll": "2.1.3",
|
"@solid-primitives/scroll": "2.1.3",
|
||||||
"@solid-primitives/storage": "catalog:",
|
"@solid-primitives/storage": "catalog:",
|
||||||
|
"@solid-primitives/timer": "1.4.4",
|
||||||
"@solid-primitives/websocket": "1.3.1",
|
"@solid-primitives/websocket": "1.3.1",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
|
"@tanstack/solid-query": "5.91.4",
|
||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
||||||
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||||
const reuse = !process.env.CI
|
const reuse = !process.env.CI
|
||||||
|
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
||||||
|
const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const
|
||||||
|
|
||||||
|
if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) {
|
||||||
|
reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }])
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
|
|
@ -17,7 +23,8 @@ export default defineConfig({
|
||||||
fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1",
|
fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1",
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
workers,
|
||||||
|
reporter,
|
||||||
webServer: {
|
webServer: {
|
||||||
command,
|
command,
|
||||||
url: baseURL,
|
url: baseURL,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
;(function () {
|
;(function () {
|
||||||
var themeId = localStorage.getItem("opencode-theme-id")
|
var key = "opencode-theme-id"
|
||||||
if (!themeId) return
|
var themeId = localStorage.getItem(key) || "oc-2"
|
||||||
|
|
||||||
|
if (themeId === "oc-1") {
|
||||||
|
themeId = "oc-2"
|
||||||
|
localStorage.setItem(key, themeId)
|
||||||
|
localStorage.removeItem("opencode-theme-css-light")
|
||||||
|
localStorage.removeItem("opencode-theme-css-dark")
|
||||||
|
}
|
||||||
|
|
||||||
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
||||||
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
||||||
|
|
@ -9,9 +16,9 @@
|
||||||
document.documentElement.dataset.theme = themeId
|
document.documentElement.dataset.theme = themeId
|
||||||
document.documentElement.dataset.colorScheme = mode
|
document.documentElement.dataset.colorScheme = mode
|
||||||
|
|
||||||
if (themeId === "oc-1") return
|
if (themeId === "oc-2") return
|
||||||
|
|
||||||
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
var css = localStorage.getItem("opencode-theme-css-" + mode)
|
||||||
if (css) {
|
if (css) {
|
||||||
var style = document.createElement("style")
|
var style = document.createElement("style")
|
||||||
style.id = "oc-theme-preload"
|
style.id = "oc-theme-preload"
|
||||||
|
|
|
||||||
|
|
@ -71,8 +71,9 @@ const serverEnv = {
|
||||||
OPENCODE_E2E_PROJECT_DIR: repoDir,
|
OPENCODE_E2E_PROJECT_DIR: repoDir,
|
||||||
OPENCODE_E2E_SESSION_TITLE: "E2E Session",
|
OPENCODE_E2E_SESSION_TITLE: "E2E Session",
|
||||||
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
|
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
|
||||||
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
|
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano",
|
||||||
OPENCODE_CLIENT: "app",
|
OPENCODE_CLIENT: "app",
|
||||||
|
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
||||||
} satisfies Record<string, string>
|
} satisfies Record<string, string>
|
||||||
|
|
||||||
const runnerEnv = {
|
const runnerEnv = {
|
||||||
|
|
@ -86,7 +87,7 @@ const runnerEnv = {
|
||||||
|
|
||||||
let seed: ReturnType<typeof Bun.spawn> | undefined
|
let seed: ReturnType<typeof Bun.spawn> | undefined
|
||||||
let runner: ReturnType<typeof Bun.spawn> | undefined
|
let runner: ReturnType<typeof Bun.spawn> | undefined
|
||||||
let server: { stop: () => Promise<void> | void } | undefined
|
let server: { stop: (close?: boolean) => Promise<void> | void } | undefined
|
||||||
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
|
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
|
||||||
let cleaned = false
|
let cleaned = false
|
||||||
|
|
||||||
|
|
@ -99,7 +100,7 @@ const cleanup = async () => {
|
||||||
|
|
||||||
const jobs = [
|
const jobs = [
|
||||||
inst?.Instance.disposeAll(),
|
inst?.Instance.disposeAll(),
|
||||||
server?.stop(),
|
typeof server?.stop === "function" ? server.stop() : undefined,
|
||||||
keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }),
|
keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }),
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
await Promise.allSettled(jobs)
|
await Promise.allSettled(jobs)
|
||||||
|
|
@ -157,7 +158,7 @@ try {
|
||||||
|
|
||||||
const servermod = await import("../../opencode/src/server/server")
|
const servermod = await import("../../opencode/src/server/server")
|
||||||
inst = await import("../../opencode/src/project/instance")
|
inst = await import("../../opencode/src/project/instance")
|
||||||
server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
|
server = await servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
|
||||||
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
|
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
|
||||||
|
|
||||||
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)
|
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)
|
||||||
|
|
|
||||||
|
|
@ -1,50 +1,63 @@
|
||||||
import "@/index.css"
|
import "@/index.css"
|
||||||
import { File } from "@opencode-ai/ui/file"
|
|
||||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||||
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||||
|
import { File } from "@opencode-ai/ui/file"
|
||||||
import { Font } from "@opencode-ai/ui/font"
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
import { Splash } from "@opencode-ai/ui/logo"
|
||||||
|
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||||
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||||
|
import { type Duration, Effect } from "effect"
|
||||||
|
import {
|
||||||
|
type Component,
|
||||||
|
createMemo,
|
||||||
|
createResource,
|
||||||
|
createSignal,
|
||||||
|
ErrorBoundary,
|
||||||
|
For,
|
||||||
|
type JSX,
|
||||||
|
lazy,
|
||||||
|
onCleanup,
|
||||||
|
type ParentProps,
|
||||||
|
Show,
|
||||||
|
Suspense,
|
||||||
|
} from "solid-js"
|
||||||
|
import { Dynamic } from "solid-js/web"
|
||||||
import { CommandProvider } from "@/context/command"
|
import { CommandProvider } from "@/context/command"
|
||||||
import { CommentsProvider } from "@/context/comments"
|
import { CommentsProvider } from "@/context/comments"
|
||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
import { GlobalSDKProvider } from "@/context/global-sdk"
|
import { GlobalSDKProvider } from "@/context/global-sdk"
|
||||||
import { GlobalSyncProvider } from "@/context/global-sync"
|
import { GlobalSyncProvider } from "@/context/global-sync"
|
||||||
import { HighlightsProvider } from "@/context/highlights"
|
import { HighlightsProvider } from "@/context/highlights"
|
||||||
import { LanguageProvider, useLanguage } from "@/context/language"
|
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||||
import { LayoutProvider } from "@/context/layout"
|
import { LayoutProvider } from "@/context/layout"
|
||||||
import { ModelsProvider } from "@/context/models"
|
import { ModelsProvider } from "@/context/models"
|
||||||
import { NotificationProvider } from "@/context/notification"
|
import { NotificationProvider } from "@/context/notification"
|
||||||
import { PermissionProvider } from "@/context/permission"
|
import { PermissionProvider } from "@/context/permission"
|
||||||
import { usePlatform } from "@/context/platform"
|
|
||||||
import { PromptProvider } from "@/context/prompt"
|
import { PromptProvider } from "@/context/prompt"
|
||||||
import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
|
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||||
import { SettingsProvider } from "@/context/settings"
|
import { SettingsProvider } from "@/context/settings"
|
||||||
import { TerminalProvider } from "@/context/terminal"
|
import { TerminalProvider } from "@/context/terminal"
|
||||||
import DirectoryLayout from "@/pages/directory-layout"
|
import DirectoryLayout from "@/pages/directory-layout"
|
||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
|
|
||||||
const Home = lazy(() => import("@/pages/home"))
|
const HomeRoute = lazy(() => import("@/pages/home"))
|
||||||
const Session = lazy(() => import("@/pages/session"))
|
const loadSession = () => import("@/pages/session")
|
||||||
|
const Session = lazy(loadSession)
|
||||||
const Loading = () => <div class="size-full" />
|
const Loading = () => <div class="size-full" />
|
||||||
|
|
||||||
const HomeRoute = () => (
|
if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname)) {
|
||||||
<Suspense fallback={<Loading />}>
|
void loadSession()
|
||||||
<Home />
|
}
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
|
|
||||||
const SessionRoute = () => (
|
const SessionRoute = () => (
|
||||||
<SessionProviders>
|
<SessionProviders>
|
||||||
<Suspense fallback={<Loading />}>
|
<Session />
|
||||||
<Session />
|
|
||||||
</Suspense>
|
|
||||||
</SessionProviders>
|
</SessionProviders>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -52,7 +65,7 @@ const SessionIndexRoute = () => <Navigate href="session" />
|
||||||
|
|
||||||
function UiI18nBridge(props: ParentProps) {
|
function UiI18nBridge(props: ParentProps) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider>
|
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|
@ -62,12 +75,15 @@ declare global {
|
||||||
deepLinks?: string[]
|
deepLinks?: string[]
|
||||||
wsl?: boolean
|
wsl?: boolean
|
||||||
}
|
}
|
||||||
|
api?: {
|
||||||
|
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function MarkedProviderWithNativeParser(props: ParentProps) {
|
function QueryProvider(props: ParentProps) {
|
||||||
const platform = usePlatform()
|
const client = new QueryClient()
|
||||||
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppShellProviders(props: ParentProps) {
|
function AppShellProviders(props: ParentProps) {
|
||||||
|
|
@ -105,25 +121,33 @@ function SessionProviders(props: ParentProps) {
|
||||||
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||||
return (
|
return (
|
||||||
<AppShellProviders>
|
<AppShellProviders>
|
||||||
{props.appChildren}
|
<Suspense fallback={<Loading />}>
|
||||||
{props.children}
|
{props.appChildren}
|
||||||
|
{props.children}
|
||||||
|
</Suspense>
|
||||||
</AppShellProviders>
|
</AppShellProviders>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppBaseProviders(props: ParentProps) {
|
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||||
return (
|
return (
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
<Font />
|
<Font />
|
||||||
<ThemeProvider>
|
<ThemeProvider
|
||||||
<LanguageProvider>
|
onThemeApplied={(_, mode) => {
|
||||||
|
void window.api?.setTitlebar?.({ mode })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LanguageProvider locale={props.locale}>
|
||||||
<UiI18nBridge>
|
<UiI18nBridge>
|
||||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||||
<DialogProvider>
|
<QueryProvider>
|
||||||
<MarkedProviderWithNativeParser>
|
<DialogProvider>
|
||||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
<MarkedProvider>
|
||||||
</MarkedProviderWithNativeParser>
|
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||||
</DialogProvider>
|
</MarkedProvider>
|
||||||
|
</DialogProvider>
|
||||||
|
</QueryProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</UiI18nBridge>
|
</UiI18nBridge>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
|
|
@ -132,6 +156,116 @@ export function AppBaseProviders(props: ParentProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const effectMinDuration =
|
||||||
|
(duration: Duration.Input) =>
|
||||||
|
<A, E, R>(e: Effect.Effect<A, E, R>) =>
|
||||||
|
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
|
||||||
|
|
||||||
|
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
||||||
|
const server = useServer()
|
||||||
|
const checkServerHealth = useCheckServerHealth()
|
||||||
|
|
||||||
|
const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
|
||||||
|
|
||||||
|
// performs repeated health check with a grace period for
|
||||||
|
// non-http connections, otherwise fails instantly
|
||||||
|
const [startupHealthCheck, healthCheckActions] = createResource(() =>
|
||||||
|
props.disableHealthCheck
|
||||||
|
? true
|
||||||
|
: Effect.gen(function* () {
|
||||||
|
if (!server.current) return true
|
||||||
|
const { http, type } = server.current
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const res = yield* Effect.promise(() => checkServerHealth(http))
|
||||||
|
if (res.healthy) return true
|
||||||
|
if (checkMode() === "background" || type === "http") return false
|
||||||
|
}
|
||||||
|
}).pipe(
|
||||||
|
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
|
||||||
|
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
|
||||||
|
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
||||||
|
Effect.runPromise,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show
|
||||||
|
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
|
||||||
|
fallback={
|
||||||
|
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
||||||
|
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={startupHealthCheck()}
|
||||||
|
fallback={
|
||||||
|
<ConnectionError
|
||||||
|
onRetry={() => {
|
||||||
|
if (checkMode() === "background") healthCheckActions.refetch()
|
||||||
|
}}
|
||||||
|
onServerSelected={(key) => {
|
||||||
|
setCheckMode("blocking")
|
||||||
|
server.setActive(key)
|
||||||
|
healthCheckActions.refetch()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const server = useServer()
|
||||||
|
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
|
||||||
|
const name = createMemo(() => server.name || server.key)
|
||||||
|
const serverToken = "\u0000server\u0000"
|
||||||
|
const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken))
|
||||||
|
|
||||||
|
const timer = setInterval(() => props.onRetry?.(), 1000)
|
||||||
|
onCleanup(() => clearInterval(timer))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
|
||||||
|
<div class="flex flex-col items-center max-w-md text-center">
|
||||||
|
<Splash class="w-12 h-15 mb-4" />
|
||||||
|
<p class="text-14-regular text-text-base">
|
||||||
|
{unreachable()[0]}
|
||||||
|
<span class="text-text-strong font-medium">{name()}</span>
|
||||||
|
{unreachable()[1]}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-12-regular text-text-weak">{language.t("app.server.retrying")}</p>
|
||||||
|
</div>
|
||||||
|
<Show when={others().length > 0}>
|
||||||
|
<div class="flex flex-col gap-2 w-full max-w-sm">
|
||||||
|
<span class="text-12-regular text-text-base text-center">{language.t("app.server.otherServers")}</span>
|
||||||
|
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
|
||||||
|
<For each={others()}>
|
||||||
|
{(conn) => {
|
||||||
|
const key = ServerConnection.key(conn)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||||
|
onClick={() => props.onServerSelected?.(key)}
|
||||||
|
>
|
||||||
|
<span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ServerKey(props: ParentProps) {
|
function ServerKey(props: ParentProps) {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
return (
|
return (
|
||||||
|
|
@ -146,25 +280,32 @@ export function AppInterface(props: {
|
||||||
defaultServer: ServerConnection.Key
|
defaultServer: ServerConnection.Key
|
||||||
servers?: Array<ServerConnection.Any>
|
servers?: Array<ServerConnection.Any>
|
||||||
router?: Component<BaseRouterProps>
|
router?: Component<BaseRouterProps>
|
||||||
|
disableHealthCheck?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
<ServerProvider
|
||||||
<ServerKey>
|
defaultServer={props.defaultServer}
|
||||||
<GlobalSDKProvider>
|
disableHealthCheck={props.disableHealthCheck}
|
||||||
<GlobalSyncProvider>
|
servers={props.servers}
|
||||||
<Dynamic
|
>
|
||||||
component={props.router ?? Router}
|
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||||
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
<ServerKey>
|
||||||
>
|
<GlobalSDKProvider>
|
||||||
<Route path="/" component={HomeRoute} />
|
<GlobalSyncProvider>
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
<Dynamic
|
||||||
<Route path="/" component={SessionIndexRoute} />
|
component={props.router ?? Router}
|
||||||
<Route path="/session/:id?" component={SessionRoute} />
|
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||||
</Route>
|
>
|
||||||
</Dynamic>
|
<Route path="/" component={HomeRoute} />
|
||||||
</GlobalSyncProvider>
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
</GlobalSDKProvider>
|
<Route path="/" component={SessionIndexRoute} />
|
||||||
</ServerKey>
|
<Route path="/session/:id?" component={SessionRoute} />
|
||||||
|
</Route>
|
||||||
|
</Dynamic>
|
||||||
|
</GlobalSyncProvider>
|
||||||
|
</GlobalSDKProvider>
|
||||||
|
</ServerKey>
|
||||||
|
</ConnectionGate>
|
||||||
</ServerProvider>
|
</ServerProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,443 @@
|
||||||
|
import { useIsRouting, useLocation } from "@solidjs/router"
|
||||||
|
import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
|
type Mem = Performance & {
|
||||||
|
memory?: {
|
||||||
|
usedJSHeapSize: number
|
||||||
|
jsHeapSizeLimit: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Evt = PerformanceEntry & {
|
||||||
|
interactionId?: number
|
||||||
|
processingStart?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Shift = PerformanceEntry & {
|
||||||
|
hadRecentInput: boolean
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Obs = PerformanceObserverInit & {
|
||||||
|
durationThreshold?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = 5000
|
||||||
|
|
||||||
|
const ms = (n?: number, d = 0) => {
|
||||||
|
if (n === undefined || Number.isNaN(n)) return
|
||||||
|
return `${n.toFixed(d)}ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = (n?: number) => {
|
||||||
|
if (n === undefined || Number.isNaN(n)) return
|
||||||
|
return `${Math.round(n)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const mb = (n?: number) => {
|
||||||
|
if (n === undefined || Number.isNaN(n)) return
|
||||||
|
const v = n / 1024 / 1024
|
||||||
|
return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
const bad = (n: number | undefined, limit: number, low = false) => {
|
||||||
|
if (n === undefined || Number.isNaN(n)) return false
|
||||||
|
return low ? n < limit : n > limit
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = (path: string) => path.includes("/session")
|
||||||
|
|
||||||
|
function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) {
|
||||||
|
return (
|
||||||
|
<Tooltip value={props.tip} placement="top">
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] px-0.5 py-1 text-center": true,
|
||||||
|
"col-span-2": !!props.wide,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70">{props.label}</div>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"text-[13px] leading-none font-bold tabular-nums sm:text-[14px]": true,
|
||||||
|
"text-text-on-critical-base": !!props.bad,
|
||||||
|
"opacity-70": !!props.dim,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DebugBar() {
|
||||||
|
const language = useLanguage()
|
||||||
|
const location = useLocation()
|
||||||
|
const routing = useIsRouting()
|
||||||
|
const [state, setState] = createStore({
|
||||||
|
cls: undefined as number | undefined,
|
||||||
|
delay: undefined as number | undefined,
|
||||||
|
fps: undefined as number | undefined,
|
||||||
|
gap: undefined as number | undefined,
|
||||||
|
heap: {
|
||||||
|
limit: undefined as number | undefined,
|
||||||
|
used: undefined as number | undefined,
|
||||||
|
},
|
||||||
|
inp: undefined as number | undefined,
|
||||||
|
jank: undefined as number | undefined,
|
||||||
|
long: {
|
||||||
|
block: undefined as number | undefined,
|
||||||
|
count: undefined as number | undefined,
|
||||||
|
max: undefined as number | undefined,
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
dur: undefined as number | undefined,
|
||||||
|
pending: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const na = () => language.t("debugBar.na")
|
||||||
|
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
|
||||||
|
const heapv = () => {
|
||||||
|
const value = heap()
|
||||||
|
if (value === undefined) return na()
|
||||||
|
return `${Math.round(value * 100)}%`
|
||||||
|
}
|
||||||
|
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
|
||||||
|
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
|
||||||
|
|
||||||
|
let prev = ""
|
||||||
|
let start = 0
|
||||||
|
let init = false
|
||||||
|
let one = 0
|
||||||
|
let two = 0
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const busy = routing()
|
||||||
|
const next = `${location.pathname}${location.search}`
|
||||||
|
|
||||||
|
if (!init) {
|
||||||
|
init = true
|
||||||
|
prev = next
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (busy) {
|
||||||
|
if (one !== 0) cancelAnimationFrame(one)
|
||||||
|
if (two !== 0) cancelAnimationFrame(two)
|
||||||
|
one = 0
|
||||||
|
two = 0
|
||||||
|
if (start !== 0) return
|
||||||
|
start = performance.now()
|
||||||
|
if (session(prev)) setState("nav", { dur: undefined, pending: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start === 0) {
|
||||||
|
prev = next
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const at = start
|
||||||
|
const from = prev
|
||||||
|
start = 0
|
||||||
|
prev = next
|
||||||
|
|
||||||
|
if (!(session(from) || session(next))) return
|
||||||
|
|
||||||
|
if (one !== 0) cancelAnimationFrame(one)
|
||||||
|
if (two !== 0) cancelAnimationFrame(two)
|
||||||
|
one = requestAnimationFrame(() => {
|
||||||
|
one = 0
|
||||||
|
two = requestAnimationFrame(() => {
|
||||||
|
two = 0
|
||||||
|
setState("nav", { dur: performance.now() - at, pending: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const obs: PerformanceObserver[] = []
|
||||||
|
const fps: Array<{ at: number; dur: number }> = []
|
||||||
|
const long: Array<{ at: number; dur: number }> = []
|
||||||
|
const seen = new Map<number | string, { at: number; delay: number; dur: number }>()
|
||||||
|
let hasLong = false
|
||||||
|
let poll: number | undefined
|
||||||
|
let raf = 0
|
||||||
|
let last = 0
|
||||||
|
let snap = 0
|
||||||
|
|
||||||
|
const trim = (list: Array<{ at: number; dur: number }>, span: number, at: number) => {
|
||||||
|
while (list[0] && at - list[0].at > span) list.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncFrame = (at: number) => {
|
||||||
|
trim(fps, span, at)
|
||||||
|
const total = fps.reduce((sum, entry) => sum + entry.dur, 0)
|
||||||
|
const gap = fps.reduce((max, entry) => Math.max(max, entry.dur), 0)
|
||||||
|
const jank = fps.filter((entry) => entry.dur > 32).length
|
||||||
|
batch(() => {
|
||||||
|
setState("fps", total > 0 ? (fps.length * 1000) / total : undefined)
|
||||||
|
setState("gap", gap > 0 ? gap : undefined)
|
||||||
|
setState("jank", jank)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncLong = (at = performance.now()) => {
|
||||||
|
if (!hasLong) return
|
||||||
|
trim(long, span, at)
|
||||||
|
const block = long.reduce((sum, entry) => sum + Math.max(0, entry.dur - 50), 0)
|
||||||
|
const max = long.reduce((hi, entry) => Math.max(hi, entry.dur), 0)
|
||||||
|
setState("long", { block, count: long.length, max })
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncInp = (at = performance.now()) => {
|
||||||
|
for (const [key, entry] of seen) {
|
||||||
|
if (at - entry.at > span) seen.delete(key)
|
||||||
|
}
|
||||||
|
let delay = 0
|
||||||
|
let inp = 0
|
||||||
|
for (const entry of seen.values()) {
|
||||||
|
delay = Math.max(delay, entry.delay)
|
||||||
|
inp = Math.max(inp, entry.dur)
|
||||||
|
}
|
||||||
|
batch(() => {
|
||||||
|
setState("delay", delay > 0 ? delay : undefined)
|
||||||
|
setState("inp", inp > 0 ? inp : undefined)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncHeap = () => {
|
||||||
|
const mem = (performance as Mem).memory
|
||||||
|
if (!mem) return
|
||||||
|
setState("heap", { limit: mem.jsHeapSizeLimit, used: mem.usedJSHeapSize })
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
fps.length = 0
|
||||||
|
long.length = 0
|
||||||
|
seen.clear()
|
||||||
|
last = 0
|
||||||
|
snap = 0
|
||||||
|
batch(() => {
|
||||||
|
setState("fps", undefined)
|
||||||
|
setState("gap", undefined)
|
||||||
|
setState("jank", undefined)
|
||||||
|
setState("delay", undefined)
|
||||||
|
setState("inp", undefined)
|
||||||
|
if (hasLong) setState("long", { block: 0, count: 0, max: 0 })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const watch = (type: string, init: Obs, fn: (entries: PerformanceEntry[]) => void) => {
|
||||||
|
if (typeof PerformanceObserver === "undefined") return false
|
||||||
|
if (!(PerformanceObserver.supportedEntryTypes ?? []).includes(type)) return false
|
||||||
|
const ob = new PerformanceObserver((list) => fn(list.getEntries()))
|
||||||
|
try {
|
||||||
|
ob.observe(init)
|
||||||
|
obs.push(ob)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
ob.disconnect()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
watch("layout-shift", { buffered: true, type: "layout-shift" }, (entries) => {
|
||||||
|
const add = entries.reduce((sum, entry) => {
|
||||||
|
const item = entry as Shift
|
||||||
|
if (item.hadRecentInput) return sum
|
||||||
|
return sum + item.value
|
||||||
|
}, 0)
|
||||||
|
if (add === 0) return
|
||||||
|
setState("cls", (value) => (value ?? 0) + add)
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
setState("cls", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
watch("longtask", { buffered: true, type: "longtask" }, (entries) => {
|
||||||
|
const at = performance.now()
|
||||||
|
long.push(...entries.map((entry) => ({ at: entry.startTime, dur: entry.duration })))
|
||||||
|
syncLong(at)
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
hasLong = true
|
||||||
|
setState("long", { block: 0, count: 0, max: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
watch("event", { buffered: true, durationThreshold: 16, type: "event" }, (entries) => {
|
||||||
|
for (const raw of entries) {
|
||||||
|
const entry = raw as Evt
|
||||||
|
if (entry.duration < 16) continue
|
||||||
|
const key =
|
||||||
|
entry.interactionId && entry.interactionId > 0
|
||||||
|
? entry.interactionId
|
||||||
|
: `${entry.name}:${Math.round(entry.startTime)}`
|
||||||
|
const prev = seen.get(key)
|
||||||
|
const delay = Math.max(0, (entry.processingStart ?? entry.startTime) - entry.startTime)
|
||||||
|
seen.set(key, {
|
||||||
|
at: entry.startTime,
|
||||||
|
delay: Math.max(prev?.delay ?? 0, delay),
|
||||||
|
dur: Math.max(prev?.dur ?? 0, entry.duration),
|
||||||
|
})
|
||||||
|
if (seen.size <= 200) continue
|
||||||
|
const first = seen.keys().next().value
|
||||||
|
if (first !== undefined) seen.delete(first)
|
||||||
|
}
|
||||||
|
syncInp()
|
||||||
|
})
|
||||||
|
|
||||||
|
const loop = (at: number) => {
|
||||||
|
if (document.visibilityState !== "visible") {
|
||||||
|
raf = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (last === 0) {
|
||||||
|
last = at
|
||||||
|
raf = requestAnimationFrame(loop)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fps.push({ at, dur: at - last })
|
||||||
|
last = at
|
||||||
|
|
||||||
|
if (at - snap >= 250) {
|
||||||
|
snap = at
|
||||||
|
syncFrame(at)
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(loop)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (raf !== 0) cancelAnimationFrame(raf)
|
||||||
|
raf = 0
|
||||||
|
if (poll === undefined) return
|
||||||
|
clearInterval(poll)
|
||||||
|
poll = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (document.visibilityState !== "visible") return
|
||||||
|
if (poll === undefined) {
|
||||||
|
poll = window.setInterval(() => {
|
||||||
|
syncLong()
|
||||||
|
syncInp()
|
||||||
|
syncHeap()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
if (raf !== 0) return
|
||||||
|
raf = requestAnimationFrame(loop)
|
||||||
|
}
|
||||||
|
|
||||||
|
const vis = () => {
|
||||||
|
if (document.visibilityState !== "visible") {
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reset()
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
syncHeap()
|
||||||
|
start()
|
||||||
|
makeEventListener(document, "visibilitychange", vis)
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (one !== 0) cancelAnimationFrame(one)
|
||||||
|
if (two !== 0) cancelAnimationFrame(two)
|
||||||
|
stop()
|
||||||
|
for (const ob of obs) ob.disconnect()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
aria-label={language.t("debugBar.ariaLabel")}
|
||||||
|
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-5 gap-px font-mono">
|
||||||
|
<Cell
|
||||||
|
label={language.t("debugBar.nav.label")}
|
||||||
|
tip={language.t("debugBar.nav.tip")}
|
||||||
|
value={navv()}
|
||||||
|
bad={bad(state.nav.dur, 400)}
|
||||||
|
dim={state.nav.dur === undefined && !state.nav.pending}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label={language.t("debugBar.fps.label")}
|
||||||
|
tip={language.t("debugBar.fps.tip")}
|
||||||
|
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
|
||||||
|
bad={bad(state.fps, 50, true)}
|
||||||
|
dim={state.fps === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label={language.t("debugBar.frame.label")}
|
||||||
|
tip={language.t("debugBar.frame.tip")}
|
||||||
|
value={time(state.gap) ?? na()}
|
||||||
|
bad={bad(state.gap, 50)}
|
||||||
|
dim={state.gap === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label={language.t("debugBar.jank.label")}
|
||||||
|
tip={language.t("debugBar.jank.tip")}
|
||||||
|
value={state.jank === undefined ? na() : `${state.jank}`}
|
||||||
|
bad={bad(state.jank, 8)}
|
||||||
|
dim={state.jank === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label={language.t("debugBar.long.label")}
|
||||||
|
tip={language.t("debugBar.long.tip", { max: ms(state.long.max) ?? na() })}
|
||||||
|
value={longv()}
|
||||||
|
bad={bad(state.long.block, 200)}
|
||||||
|
dim={state.long.count === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label={language.t("debugBar.delay.label")}
|
||||||
|
tip={language.t("debugBar.delay.tip")}
|
||||||
|
value={time(state.delay) ?? na()}
|
||||||
|
bad={bad(state.delay, 100)}
|
||||||
|
dim={state.delay === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label={language.t("debugBar.inp.label")}
|
||||||
|
tip={language.t("debugBar.inp.tip")}
|
||||||
|
value={time(state.inp) ?? na()}
|
||||||
|
bad={bad(state.inp, 200)}
|
||||||
|
dim={state.inp === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label={language.t("debugBar.cls.label")}
|
||||||
|
tip={language.t("debugBar.cls.tip")}
|
||||||
|
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
|
||||||
|
bad={bad(state.cls, 0.1)}
|
||||||
|
dim={state.cls === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label={language.t("debugBar.mem.label")}
|
||||||
|
tip={
|
||||||
|
state.heap.used === undefined
|
||||||
|
? language.t("debugBar.mem.tipUnavailable")
|
||||||
|
: language.t("debugBar.mem.tip", {
|
||||||
|
used: mb(state.heap.used) ?? na(),
|
||||||
|
limit: mb(state.heap.limit) ?? na(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
value={heapv()}
|
||||||
|
bad={bad(heap(), 0.8)}
|
||||||
|
dim={state.heap.used === undefined}
|
||||||
|
wide
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
|
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
|
|
@ -9,22 +9,26 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { useLanguage } from "@/context/language"
|
||||||
import { DialogSelectModel } from "./dialog-select-model"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
|
||||||
|
|
||||||
export function DialogConnectProvider(props: { provider: string }) {
|
export function DialogConnectProvider(props: { provider: string }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const platform = usePlatform()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const providers = useProviders()
|
||||||
|
|
||||||
|
const all = () => {
|
||||||
|
void import("./dialog-select-provider").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogSelectProvider />)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const alive = { value: true }
|
const alive = { value: true }
|
||||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||||
|
|
@ -36,26 +40,41 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
timer.current = undefined
|
timer.current = undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
|
const provider = createMemo(
|
||||||
const methods = createMemo(
|
|
||||||
() =>
|
() =>
|
||||||
globalSync.data.provider_auth[props.provider] ?? [
|
providers.all().find((x) => x.id === props.provider) ??
|
||||||
{
|
globalSync.data.provider.all.find((x) => x.id === props.provider)!,
|
||||||
type: "api",
|
|
||||||
label: language.t("provider.connect.method.apiKey"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||||
|
{
|
||||||
|
type: "api" as const,
|
||||||
|
label: language.t("provider.connect.method.apiKey"),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const [auth] = createResource(
|
||||||
|
() => props.provider,
|
||||||
|
async () => {
|
||||||
|
const cached = globalSync.data.provider_auth[props.provider]
|
||||||
|
if (cached) return cached
|
||||||
|
const res = await globalSDK.client.provider.auth()
|
||||||
|
if (!alive.value) return fallback()
|
||||||
|
globalSync.set("provider_auth", res.data ?? {})
|
||||||
|
return res.data?.[props.provider] ?? fallback()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
|
||||||
|
const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
methodIndex: undefined as undefined | number,
|
methodIndex: undefined as undefined | number,
|
||||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||||
state: "pending" as undefined | "pending" | "complete" | "error",
|
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| { type: "method.select"; index: number }
|
| { type: "method.select"; index: number }
|
||||||
| { type: "method.reset" }
|
| { type: "method.reset" }
|
||||||
|
| { type: "auth.prompt" }
|
||||||
| { type: "auth.pending" }
|
| { type: "auth.pending" }
|
||||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||||
| { type: "auth.error"; error: string }
|
| { type: "auth.error"; error: string }
|
||||||
|
|
@ -77,6 +96,11 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
draft.error = undefined
|
draft.error = undefined
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (action.type === "auth.prompt") {
|
||||||
|
draft.state = "prompt"
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
if (action.type === "auth.pending") {
|
if (action.type === "auth.pending") {
|
||||||
draft.state = "pending"
|
draft.state = "pending"
|
||||||
draft.error = undefined
|
draft.error = undefined
|
||||||
|
|
@ -120,7 +144,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectMethod(index: number) {
|
async function selectMethod(index: number, inputs?: Record<string, string>) {
|
||||||
if (timer.current !== undefined) {
|
if (timer.current !== undefined) {
|
||||||
clearTimeout(timer.current)
|
clearTimeout(timer.current)
|
||||||
timer.current = undefined
|
timer.current = undefined
|
||||||
|
|
@ -130,6 +154,10 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
dispatch({ type: "method.select", index })
|
dispatch({ type: "method.select", index })
|
||||||
|
|
||||||
if (method.type === "oauth") {
|
if (method.type === "oauth") {
|
||||||
|
if (method.prompts?.length && !inputs) {
|
||||||
|
dispatch({ type: "auth.prompt" })
|
||||||
|
return
|
||||||
|
}
|
||||||
dispatch({ type: "auth.pending" })
|
dispatch({ type: "auth.pending" })
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
await globalSDK.client.provider.oauth
|
await globalSDK.client.provider.oauth
|
||||||
|
|
@ -137,6 +165,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
{
|
{
|
||||||
providerID: props.provider,
|
providerID: props.provider,
|
||||||
method: index,
|
method: index,
|
||||||
|
inputs,
|
||||||
},
|
},
|
||||||
{ throwOnError: true },
|
{ throwOnError: true },
|
||||||
)
|
)
|
||||||
|
|
@ -163,6 +192,126 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OAuthPromptsView() {
|
||||||
|
const [formStore, setFormStore] = createStore({
|
||||||
|
value: {} as Record<string, string>,
|
||||||
|
index: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
||||||
|
const value = method()
|
||||||
|
if (value?.type !== "oauth") return []
|
||||||
|
return value.prompts ?? []
|
||||||
|
})
|
||||||
|
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||||
|
if (!prompt.when) return true
|
||||||
|
const actual = value[prompt.when.key]
|
||||||
|
if (actual === undefined) return false
|
||||||
|
return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value
|
||||||
|
}
|
||||||
|
const current = createMemo(() => {
|
||||||
|
const all = prompts()
|
||||||
|
const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
|
||||||
|
if (index === -1) return
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
prompt: all[index],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const valid = createMemo(() => {
|
||||||
|
const item = current()
|
||||||
|
if (!item || item.prompt.type !== "text") return false
|
||||||
|
const value = formStore.value[item.prompt.key] ?? ""
|
||||||
|
return value.trim().length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
async function next(index: number, value: Record<string, string>) {
|
||||||
|
if (store.methodIndex === undefined) return
|
||||||
|
const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
|
||||||
|
if (next !== -1) {
|
||||||
|
setFormStore("index", next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await selectMethod(store.methodIndex, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const item = current()
|
||||||
|
if (!item || item.prompt.type !== "text") return
|
||||||
|
if (!valid()) return
|
||||||
|
await next(item.index, formStore.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = () => current()
|
||||||
|
const text = createMemo(() => {
|
||||||
|
const prompt = item()?.prompt
|
||||||
|
if (!prompt || prompt.type !== "text") return
|
||||||
|
return prompt
|
||||||
|
})
|
||||||
|
const select = createMemo(() => {
|
||||||
|
const prompt = item()?.prompt
|
||||||
|
if (!prompt || prompt.type !== "select") return
|
||||||
|
return prompt
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||||
|
<Switch>
|
||||||
|
<Match when={item()?.prompt.type === "text"}>
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
label={text()?.message ?? ""}
|
||||||
|
placeholder={text()?.placeholder}
|
||||||
|
value={text() ? (formStore.value[text()!.key] ?? "") : ""}
|
||||||
|
onChange={(value) => {
|
||||||
|
const prompt = text()
|
||||||
|
if (!prompt) return
|
||||||
|
setFormStore("value", prompt.key, value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}>
|
||||||
|
{language.t("common.continue")}
|
||||||
|
</Button>
|
||||||
|
</Match>
|
||||||
|
<Match when={item()?.prompt.type === "select"}>
|
||||||
|
<div class="w-full flex flex-col gap-1.5">
|
||||||
|
<div class="text-14-regular text-text-base">{select()?.message}</div>
|
||||||
|
<div>
|
||||||
|
<List
|
||||||
|
items={select()?.options ?? []}
|
||||||
|
key={(x) => x.value}
|
||||||
|
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
|
||||||
|
onSelect={(value) => {
|
||||||
|
if (!value) return
|
||||||
|
const prompt = select()
|
||||||
|
if (!prompt) return
|
||||||
|
const nextValue = {
|
||||||
|
...formStore.value,
|
||||||
|
[prompt.key]: value.value,
|
||||||
|
}
|
||||||
|
setFormStore("value", prompt.key, value.value)
|
||||||
|
void next(item()!.index, nextValue)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(option) => (
|
||||||
|
<div class="w-full flex items-center gap-x-2">
|
||||||
|
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
||||||
|
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
||||||
|
</div>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
<span class="text-14-regular text-text-weak">{option.hint}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let listRef: ListRef | undefined
|
let listRef: ListRef | undefined
|
||||||
function handleKey(e: KeyboardEvent) {
|
function handleKey(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
|
if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
|
||||||
|
|
@ -172,8 +321,12 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
listRef?.onKeyDown(e)
|
listRef?.onKeyDown(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
let auto = false
|
||||||
|
createEffect(() => {
|
||||||
|
if (auto) return
|
||||||
|
if (loading()) return
|
||||||
if (methods().length === 1) {
|
if (methods().length === 1) {
|
||||||
|
auto = true
|
||||||
selectMethod(0)
|
selectMethod(0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -191,7 +344,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
if (methods().length === 1) {
|
if (methods().length === 1) {
|
||||||
dialog.show(() => <DialogSelectProvider />)
|
all()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (store.authorization) {
|
if (store.authorization) {
|
||||||
|
|
@ -202,7 +355,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
dispatch({ type: "method.reset" })
|
dispatch({ type: "method.reset" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dialog.show(() => <DialogSelectProvider />)
|
all()
|
||||||
}
|
}
|
||||||
|
|
||||||
function MethodSelection() {
|
function MethodSelection() {
|
||||||
|
|
@ -301,7 +454,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
error={formStore.error}
|
error={formStore.error}
|
||||||
/>
|
/>
|
||||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||||
{language.t("common.submit")}
|
{language.t("common.continue")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -314,12 +467,6 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (store.authorization?.method === "code" && store.authorization?.url) {
|
|
||||||
platform.openLink(store.authorization.url)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
|
|
@ -368,7 +515,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
error={formStore.error}
|
error={formStore.error}
|
||||||
/>
|
/>
|
||||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||||
{language.t("common.submit")}
|
{language.t("common.continue")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -386,10 +533,6 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (store.authorization?.url) {
|
|
||||||
platform.openLink(store.authorization.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await globalSDK.client.provider.oauth
|
const result = await globalSDK.client.provider.oauth
|
||||||
.callback({
|
.callback({
|
||||||
providerID: props.provider,
|
providerID: props.provider,
|
||||||
|
|
@ -459,6 +602,14 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
||||||
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
||||||
<Switch>
|
<Switch>
|
||||||
|
<Match when={loading()}>
|
||||||
|
<div class="text-14-regular text-text-base">
|
||||||
|
<div class="flex items-center gap-x-2">
|
||||||
|
<Spinner />
|
||||||
|
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
<Match when={store.methodIndex === undefined}>
|
<Match when={store.methodIndex === undefined}>
|
||||||
<MethodSelection />
|
<MethodSelection />
|
||||||
</Match>
|
</Match>
|
||||||
|
|
@ -470,6 +621,9 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
|
<Match when={store.state === "prompt"}>
|
||||||
|
<OAuthPromptsView />
|
||||||
|
</Match>
|
||||||
<Match when={store.state === "error"}>
|
<Match when={store.state === "error"}>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
<div class="flex items-center gap-x-2">
|
<div class="flex items-center gap-x-2">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
||||||
|
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
||||||
|
|
||||||
|
type Translator = (key: string, vars?: Record<string, string | number | boolean>) => string
|
||||||
|
|
||||||
|
export type ModelErr = {
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HeaderErr = {
|
||||||
|
key?: string
|
||||||
|
value?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelRow = {
|
||||||
|
row: string
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
err: ModelErr
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HeaderRow = {
|
||||||
|
row: string
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
err: HeaderErr
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormState = {
|
||||||
|
providerID: string
|
||||||
|
name: string
|
||||||
|
baseURL: string
|
||||||
|
apiKey: string
|
||||||
|
models: ModelRow[]
|
||||||
|
headers: HeaderRow[]
|
||||||
|
err: {
|
||||||
|
providerID?: string
|
||||||
|
name?: string
|
||||||
|
baseURL?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidateArgs = {
|
||||||
|
form: FormState
|
||||||
|
t: Translator
|
||||||
|
disabledProviders: string[]
|
||||||
|
existingProviderIDs: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCustomProvider(input: ValidateArgs) {
|
||||||
|
const providerID = input.form.providerID.trim()
|
||||||
|
const name = input.form.name.trim()
|
||||||
|
const baseURL = input.form.baseURL.trim()
|
||||||
|
const apiKey = input.form.apiKey.trim()
|
||||||
|
|
||||||
|
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
||||||
|
const key = apiKey && !env ? apiKey : undefined
|
||||||
|
|
||||||
|
const idError = !providerID
|
||||||
|
? input.t("provider.custom.error.providerID.required")
|
||||||
|
: !PROVIDER_ID.test(providerID)
|
||||||
|
? input.t("provider.custom.error.providerID.format")
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
|
||||||
|
const urlError = !baseURL
|
||||||
|
? input.t("provider.custom.error.baseURL.required")
|
||||||
|
: !/^https?:\/\//.test(baseURL)
|
||||||
|
? input.t("provider.custom.error.baseURL.format")
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const disabled = input.disabledProviders.includes(providerID)
|
||||||
|
const existsError = idError
|
||||||
|
? undefined
|
||||||
|
: input.existingProviderIDs.has(providerID) && !disabled
|
||||||
|
? input.t("provider.custom.error.providerID.exists")
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const seenModels = new Set<string>()
|
||||||
|
const models = input.form.models.map((m) => {
|
||||||
|
const id = m.id.trim()
|
||||||
|
const idError = !id
|
||||||
|
? input.t("provider.custom.error.required")
|
||||||
|
: seenModels.has(id)
|
||||||
|
? input.t("provider.custom.error.duplicate")
|
||||||
|
: (() => {
|
||||||
|
seenModels.add(id)
|
||||||
|
return undefined
|
||||||
|
})()
|
||||||
|
const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
|
||||||
|
return { id: idError, name: nameError }
|
||||||
|
})
|
||||||
|
const modelsValid = models.every((m) => !m.id && !m.name)
|
||||||
|
const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
||||||
|
|
||||||
|
const seenHeaders = new Set<string>()
|
||||||
|
const headers = input.form.headers.map((h) => {
|
||||||
|
const key = h.key.trim()
|
||||||
|
const value = h.value.trim()
|
||||||
|
|
||||||
|
if (!key && !value) return {}
|
||||||
|
const keyError = !key
|
||||||
|
? input.t("provider.custom.error.required")
|
||||||
|
: seenHeaders.has(key.toLowerCase())
|
||||||
|
? input.t("provider.custom.error.duplicate")
|
||||||
|
: (() => {
|
||||||
|
seenHeaders.add(key.toLowerCase())
|
||||||
|
return undefined
|
||||||
|
})()
|
||||||
|
const valueError = !value ? input.t("provider.custom.error.required") : undefined
|
||||||
|
return { key: keyError, value: valueError }
|
||||||
|
})
|
||||||
|
const headersValid = headers.every((h) => !h.key && !h.value)
|
||||||
|
const headerConfig = Object.fromEntries(
|
||||||
|
input.form.headers
|
||||||
|
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
||||||
|
.filter((h) => !!h.key && !!h.value)
|
||||||
|
.map((h) => [h.key, h.value]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const err = {
|
||||||
|
providerID: idError ?? existsError,
|
||||||
|
name: nameError,
|
||||||
|
baseURL: urlError,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
||||||
|
if (!ok) return { err, models, headers }
|
||||||
|
|
||||||
|
return {
|
||||||
|
err,
|
||||||
|
models,
|
||||||
|
headers,
|
||||||
|
result: {
|
||||||
|
providerID,
|
||||||
|
name,
|
||||||
|
key,
|
||||||
|
config: {
|
||||||
|
npm: OPENAI_COMPATIBLE,
|
||||||
|
name,
|
||||||
|
...(env ? { env: [env] } : {}),
|
||||||
|
options: {
|
||||||
|
baseURL,
|
||||||
|
...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
|
||||||
|
},
|
||||||
|
models: modelConfig,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = 0
|
||||||
|
|
||||||
|
const nextRow = () => `row-${row++}`
|
||||||
|
|
||||||
|
export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} })
|
||||||
|
export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} })
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { validateCustomProvider } from "./dialog-custom-provider-form"
|
||||||
|
|
||||||
|
const t = (key: string) => key
|
||||||
|
|
||||||
|
describe("validateCustomProvider", () => {
|
||||||
|
test("builds trimmed config payload", () => {
|
||||||
|
const result = validateCustomProvider({
|
||||||
|
form: {
|
||||||
|
providerID: "custom-provider",
|
||||||
|
name: " Custom Provider ",
|
||||||
|
baseURL: "https://api.example.com ",
|
||||||
|
apiKey: " {env: CUSTOM_PROVIDER_KEY} ",
|
||||||
|
models: [{ row: "m0", id: " model-a ", name: " Model A ", err: {} }],
|
||||||
|
headers: [
|
||||||
|
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
|
||||||
|
{ row: "h1", key: "", value: "", err: {} },
|
||||||
|
],
|
||||||
|
err: {},
|
||||||
|
},
|
||||||
|
t,
|
||||||
|
disabledProviders: [],
|
||||||
|
existingProviderIDs: new Set(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.result).toEqual({
|
||||||
|
providerID: "custom-provider",
|
||||||
|
name: "Custom Provider",
|
||||||
|
key: undefined,
|
||||||
|
config: {
|
||||||
|
npm: "@ai-sdk/openai-compatible",
|
||||||
|
name: "Custom Provider",
|
||||||
|
env: ["CUSTOM_PROVIDER_KEY"],
|
||||||
|
options: {
|
||||||
|
baseURL: "https://api.example.com",
|
||||||
|
headers: {
|
||||||
|
"X-Test": "enabled",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
"model-a": { name: "Model A" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("flags duplicate rows and allows reconnecting disabled providers", () => {
|
||||||
|
const result = validateCustomProvider({
|
||||||
|
form: {
|
||||||
|
providerID: "custom-provider",
|
||||||
|
name: "Provider",
|
||||||
|
baseURL: "https://api.example.com",
|
||||||
|
apiKey: "secret",
|
||||||
|
models: [
|
||||||
|
{ row: "m0", id: "model-a", name: "Model A", err: {} },
|
||||||
|
{ row: "m1", id: "model-a", name: "Model A 2", err: {} },
|
||||||
|
],
|
||||||
|
headers: [
|
||||||
|
{ row: "h0", key: "Authorization", value: "one", err: {} },
|
||||||
|
{ row: "h1", key: "authorization", value: "two", err: {} },
|
||||||
|
],
|
||||||
|
err: {},
|
||||||
|
},
|
||||||
|
t,
|
||||||
|
disabledProviders: ["custom-provider"],
|
||||||
|
existingProviderIDs: new Set(["custom-provider"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.result).toBeUndefined()
|
||||||
|
expect(result.err.providerID).toBeUndefined()
|
||||||
|
expect(result.models[1]).toEqual({
|
||||||
|
id: "provider.custom.error.duplicate",
|
||||||
|
name: undefined,
|
||||||
|
})
|
||||||
|
expect(result.headers[1]).toEqual({
|
||||||
|
key: "provider.custom.error.duplicate",
|
||||||
|
value: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -3,160 +3,18 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { For } from "solid-js"
|
import { batch, For } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
|
||||||
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
|
||||||
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
|
||||||
|
|
||||||
type Translator = ReturnType<typeof useLanguage>["t"]
|
|
||||||
|
|
||||||
type ModelRow = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type HeaderRow = {
|
|
||||||
key: string
|
|
||||||
value: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type FormState = {
|
|
||||||
providerID: string
|
|
||||||
name: string
|
|
||||||
baseURL: string
|
|
||||||
apiKey: string
|
|
||||||
models: ModelRow[]
|
|
||||||
headers: HeaderRow[]
|
|
||||||
saving: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type FormErrors = {
|
|
||||||
providerID: string | undefined
|
|
||||||
name: string | undefined
|
|
||||||
baseURL: string | undefined
|
|
||||||
models: Array<{ id?: string; name?: string }>
|
|
||||||
headers: Array<{ key?: string; value?: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
type ValidateArgs = {
|
|
||||||
form: FormState
|
|
||||||
t: Translator
|
|
||||||
disabledProviders: string[]
|
|
||||||
existingProviderIDs: Set<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateCustomProvider(input: ValidateArgs) {
|
|
||||||
const providerID = input.form.providerID.trim()
|
|
||||||
const name = input.form.name.trim()
|
|
||||||
const baseURL = input.form.baseURL.trim()
|
|
||||||
const apiKey = input.form.apiKey.trim()
|
|
||||||
|
|
||||||
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
|
||||||
const key = apiKey && !env ? apiKey : undefined
|
|
||||||
|
|
||||||
const idError = !providerID
|
|
||||||
? input.t("provider.custom.error.providerID.required")
|
|
||||||
: !PROVIDER_ID.test(providerID)
|
|
||||||
? input.t("provider.custom.error.providerID.format")
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
|
|
||||||
const urlError = !baseURL
|
|
||||||
? input.t("provider.custom.error.baseURL.required")
|
|
||||||
: !/^https?:\/\//.test(baseURL)
|
|
||||||
? input.t("provider.custom.error.baseURL.format")
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const disabled = input.disabledProviders.includes(providerID)
|
|
||||||
const existsError = idError
|
|
||||||
? undefined
|
|
||||||
: input.existingProviderIDs.has(providerID) && !disabled
|
|
||||||
? input.t("provider.custom.error.providerID.exists")
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const seenModels = new Set<string>()
|
|
||||||
const modelErrors = input.form.models.map((m) => {
|
|
||||||
const id = m.id.trim()
|
|
||||||
const modelIdError = !id
|
|
||||||
? input.t("provider.custom.error.required")
|
|
||||||
: seenModels.has(id)
|
|
||||||
? input.t("provider.custom.error.duplicate")
|
|
||||||
: (() => {
|
|
||||||
seenModels.add(id)
|
|
||||||
return undefined
|
|
||||||
})()
|
|
||||||
const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
|
|
||||||
return { id: modelIdError, name: modelNameError }
|
|
||||||
})
|
|
||||||
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
|
||||||
const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
|
||||||
|
|
||||||
const seenHeaders = new Set<string>()
|
|
||||||
const headerErrors = input.form.headers.map((h) => {
|
|
||||||
const key = h.key.trim()
|
|
||||||
const value = h.value.trim()
|
|
||||||
|
|
||||||
if (!key && !value) return {}
|
|
||||||
const keyError = !key
|
|
||||||
? input.t("provider.custom.error.required")
|
|
||||||
: seenHeaders.has(key.toLowerCase())
|
|
||||||
? input.t("provider.custom.error.duplicate")
|
|
||||||
: (() => {
|
|
||||||
seenHeaders.add(key.toLowerCase())
|
|
||||||
return undefined
|
|
||||||
})()
|
|
||||||
const valueError = !value ? input.t("provider.custom.error.required") : undefined
|
|
||||||
return { key: keyError, value: valueError }
|
|
||||||
})
|
|
||||||
const headersValid = headerErrors.every((h) => !h.key && !h.value)
|
|
||||||
const headers = Object.fromEntries(
|
|
||||||
input.form.headers
|
|
||||||
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
|
||||||
.filter((h) => !!h.key && !!h.value)
|
|
||||||
.map((h) => [h.key, h.value]),
|
|
||||||
)
|
|
||||||
|
|
||||||
const errors: FormErrors = {
|
|
||||||
providerID: idError ?? existsError,
|
|
||||||
name: nameError,
|
|
||||||
baseURL: urlError,
|
|
||||||
models: modelErrors,
|
|
||||||
headers: headerErrors,
|
|
||||||
}
|
|
||||||
|
|
||||||
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
|
||||||
if (!ok) return { errors }
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
baseURL,
|
|
||||||
...(Object.keys(headers).length ? { headers } : {}),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
errors,
|
|
||||||
result: {
|
|
||||||
providerID,
|
|
||||||
name,
|
|
||||||
key,
|
|
||||||
config: {
|
|
||||||
npm: OPENAI_COMPATIBLE,
|
|
||||||
name,
|
|
||||||
...(env ? { env: [env] } : {}),
|
|
||||||
options,
|
|
||||||
models,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
back?: "providers" | "close"
|
back?: "providers" | "close"
|
||||||
}
|
}
|
||||||
|
|
@ -172,17 +30,9 @@ export function DialogCustomProvider(props: Props) {
|
||||||
name: "",
|
name: "",
|
||||||
baseURL: "",
|
baseURL: "",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
models: [{ id: "", name: "" }],
|
models: [modelRow()],
|
||||||
headers: [{ key: "", value: "" }],
|
headers: [headerRow()],
|
||||||
saving: false,
|
err: {},
|
||||||
})
|
|
||||||
|
|
||||||
const [errors, setErrors] = createStore<FormErrors>({
|
|
||||||
providerID: undefined,
|
|
||||||
name: undefined,
|
|
||||||
baseURL: undefined,
|
|
||||||
models: [{}],
|
|
||||||
headers: [{}],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
|
|
@ -194,25 +44,61 @@ export function DialogCustomProvider(props: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const addModel = () => {
|
const addModel = () => {
|
||||||
setForm("models", (v) => [...v, { id: "", name: "" }])
|
setForm(
|
||||||
setErrors("models", (v) => [...v, {}])
|
"models",
|
||||||
|
produce((rows) => {
|
||||||
|
rows.push(modelRow())
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeModel = (index: number) => {
|
const removeModel = (index: number) => {
|
||||||
if (form.models.length <= 1) return
|
if (form.models.length <= 1) return
|
||||||
setForm("models", (v) => v.filter((_, i) => i !== index))
|
setForm(
|
||||||
setErrors("models", (v) => v.filter((_, i) => i !== index))
|
"models",
|
||||||
|
produce((rows) => {
|
||||||
|
rows.splice(index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addHeader = () => {
|
const addHeader = () => {
|
||||||
setForm("headers", (v) => [...v, { key: "", value: "" }])
|
setForm(
|
||||||
setErrors("headers", (v) => [...v, {}])
|
"headers",
|
||||||
|
produce((rows) => {
|
||||||
|
rows.push(headerRow())
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeHeader = (index: number) => {
|
const removeHeader = (index: number) => {
|
||||||
if (form.headers.length <= 1) return
|
if (form.headers.length <= 1) return
|
||||||
setForm("headers", (v) => v.filter((_, i) => i !== index))
|
setForm(
|
||||||
setErrors("headers", (v) => v.filter((_, i) => i !== index))
|
"headers",
|
||||||
|
produce((rows) => {
|
||||||
|
rows.splice(index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setField = (key: "providerID" | "name" | "baseURL" | "apiKey", value: string) => {
|
||||||
|
setForm(key, value)
|
||||||
|
if (key === "apiKey") return
|
||||||
|
setForm("err", key, undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setModel = (index: number, key: "id" | "name", value: string) => {
|
||||||
|
batch(() => {
|
||||||
|
setForm("models", index, key, value)
|
||||||
|
setForm("models", index, "err", key, undefined)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setHeader = (index: number, key: "key" | "value", value: string) => {
|
||||||
|
batch(() => {
|
||||||
|
setForm("headers", index, key, value)
|
||||||
|
setForm("headers", index, "err", key, undefined)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
|
|
@ -222,52 +108,57 @@ export function DialogCustomProvider(props: Props) {
|
||||||
disabledProviders: globalSync.data.config.disabled_providers ?? [],
|
disabledProviders: globalSync.data.config.disabled_providers ?? [],
|
||||||
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
|
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
|
||||||
})
|
})
|
||||||
setErrors(output.errors)
|
batch(() => {
|
||||||
|
setForm("err", output.err)
|
||||||
|
output.models.forEach((err, index) => setForm("models", index, "err", err))
|
||||||
|
output.headers.forEach((err, index) => setForm("headers", index, "err", err))
|
||||||
|
})
|
||||||
return output.result
|
return output.result
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = async (e: SubmitEvent) => {
|
const saveMutation = useMutation(() => ({
|
||||||
e.preventDefault()
|
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||||
if (form.saving) return
|
const disabledProviders = globalSync.data.config.disabled_providers ?? []
|
||||||
|
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||||
|
|
||||||
const result = validate()
|
if (result.key) {
|
||||||
if (!result) return
|
await globalSDK.client.auth.set({
|
||||||
|
|
||||||
setForm("saving", true)
|
|
||||||
|
|
||||||
const disabledProviders = globalSync.data.config.disabled_providers ?? []
|
|
||||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
|
||||||
|
|
||||||
const auth = result.key
|
|
||||||
? globalSDK.client.auth.set({
|
|
||||||
providerID: result.providerID,
|
providerID: result.providerID,
|
||||||
auth: {
|
auth: {
|
||||||
type: "api",
|
type: "api",
|
||||||
key: result.key,
|
key: result.key,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: Promise.resolve()
|
}
|
||||||
|
|
||||||
auth
|
await globalSync.updateConfig({
|
||||||
.then(() =>
|
provider: { [result.providerID]: result.config },
|
||||||
globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }),
|
disabled_providers: nextDisabled,
|
||||||
)
|
|
||||||
.then(() => {
|
|
||||||
dialog.close()
|
|
||||||
showToast({
|
|
||||||
variant: "success",
|
|
||||||
icon: "circle-check",
|
|
||||||
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
|
|
||||||
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
return result
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
},
|
||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
onSuccess: (result) => {
|
||||||
})
|
dialog.close()
|
||||||
.finally(() => {
|
showToast({
|
||||||
setForm("saving", false)
|
variant: "success",
|
||||||
|
icon: "circle-check",
|
||||||
|
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
|
||||||
|
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const save = (e: SubmitEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (saveMutation.isPending) return
|
||||||
|
|
||||||
|
const result = validate()
|
||||||
|
if (!result) return
|
||||||
|
saveMutation.mutate(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -305,32 +196,32 @@ export function DialogCustomProvider(props: Props) {
|
||||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||||
description={language.t("provider.custom.field.providerID.description")}
|
description={language.t("provider.custom.field.providerID.description")}
|
||||||
value={form.providerID}
|
value={form.providerID}
|
||||||
onChange={(v) => setForm("providerID", v)}
|
onChange={(v) => setField("providerID", v)}
|
||||||
validationState={errors.providerID ? "invalid" : undefined}
|
validationState={form.err.providerID ? "invalid" : undefined}
|
||||||
error={errors.providerID}
|
error={form.err.providerID}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.field.name.label")}
|
label={language.t("provider.custom.field.name.label")}
|
||||||
placeholder={language.t("provider.custom.field.name.placeholder")}
|
placeholder={language.t("provider.custom.field.name.placeholder")}
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(v) => setForm("name", v)}
|
onChange={(v) => setField("name", v)}
|
||||||
validationState={errors.name ? "invalid" : undefined}
|
validationState={form.err.name ? "invalid" : undefined}
|
||||||
error={errors.name}
|
error={form.err.name}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.field.baseURL.label")}
|
label={language.t("provider.custom.field.baseURL.label")}
|
||||||
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
||||||
value={form.baseURL}
|
value={form.baseURL}
|
||||||
onChange={(v) => setForm("baseURL", v)}
|
onChange={(v) => setField("baseURL", v)}
|
||||||
validationState={errors.baseURL ? "invalid" : undefined}
|
validationState={form.err.baseURL ? "invalid" : undefined}
|
||||||
error={errors.baseURL}
|
error={form.err.baseURL}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.field.apiKey.label")}
|
label={language.t("provider.custom.field.apiKey.label")}
|
||||||
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
||||||
description={language.t("provider.custom.field.apiKey.description")}
|
description={language.t("provider.custom.field.apiKey.description")}
|
||||||
value={form.apiKey}
|
value={form.apiKey}
|
||||||
onChange={(v) => setForm("apiKey", v)}
|
onChange={(v) => setField("apiKey", v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -338,16 +229,16 @@ export function DialogCustomProvider(props: Props) {
|
||||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
|
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
|
||||||
<For each={form.models}>
|
<For each={form.models}>
|
||||||
{(m, i) => (
|
{(m, i) => (
|
||||||
<div class="flex gap-2 items-start">
|
<div class="flex gap-2 items-start" data-row={m.row}>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.models.id.label")}
|
label={language.t("provider.custom.models.id.label")}
|
||||||
hideLabel
|
hideLabel
|
||||||
placeholder={language.t("provider.custom.models.id.placeholder")}
|
placeholder={language.t("provider.custom.models.id.placeholder")}
|
||||||
value={m.id}
|
value={m.id}
|
||||||
onChange={(v) => setForm("models", i(), "id", v)}
|
onChange={(v) => setModel(i(), "id", v)}
|
||||||
validationState={errors.models[i()]?.id ? "invalid" : undefined}
|
validationState={m.err.id ? "invalid" : undefined}
|
||||||
error={errors.models[i()]?.id}
|
error={m.err.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
|
|
@ -356,9 +247,9 @@ export function DialogCustomProvider(props: Props) {
|
||||||
hideLabel
|
hideLabel
|
||||||
placeholder={language.t("provider.custom.models.name.placeholder")}
|
placeholder={language.t("provider.custom.models.name.placeholder")}
|
||||||
value={m.name}
|
value={m.name}
|
||||||
onChange={(v) => setForm("models", i(), "name", v)}
|
onChange={(v) => setModel(i(), "name", v)}
|
||||||
validationState={errors.models[i()]?.name ? "invalid" : undefined}
|
validationState={m.err.name ? "invalid" : undefined}
|
||||||
error={errors.models[i()]?.name}
|
error={m.err.name}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|
@ -382,16 +273,16 @@ export function DialogCustomProvider(props: Props) {
|
||||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
|
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
|
||||||
<For each={form.headers}>
|
<For each={form.headers}>
|
||||||
{(h, i) => (
|
{(h, i) => (
|
||||||
<div class="flex gap-2 items-start">
|
<div class="flex gap-2 items-start" data-row={h.row}>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.headers.key.label")}
|
label={language.t("provider.custom.headers.key.label")}
|
||||||
hideLabel
|
hideLabel
|
||||||
placeholder={language.t("provider.custom.headers.key.placeholder")}
|
placeholder={language.t("provider.custom.headers.key.placeholder")}
|
||||||
value={h.key}
|
value={h.key}
|
||||||
onChange={(v) => setForm("headers", i(), "key", v)}
|
onChange={(v) => setHeader(i(), "key", v)}
|
||||||
validationState={errors.headers[i()]?.key ? "invalid" : undefined}
|
validationState={h.err.key ? "invalid" : undefined}
|
||||||
error={errors.headers[i()]?.key}
|
error={h.err.key}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
|
|
@ -400,9 +291,9 @@ export function DialogCustomProvider(props: Props) {
|
||||||
hideLabel
|
hideLabel
|
||||||
placeholder={language.t("provider.custom.headers.value.placeholder")}
|
placeholder={language.t("provider.custom.headers.value.placeholder")}
|
||||||
value={h.value}
|
value={h.value}
|
||||||
onChange={(v) => setForm("headers", i(), "value", v)}
|
onChange={(v) => setHeader(i(), "value", v)}
|
||||||
validationState={errors.headers[i()]?.value ? "invalid" : undefined}
|
validationState={h.err.value ? "invalid" : undefined}
|
||||||
error={errors.headers[i()]?.value}
|
error={h.err.value}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|
@ -422,8 +313,14 @@ export function DialogCustomProvider(props: Props) {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
|
<Button
|
||||||
{form.saving ? language.t("common.saving") : language.t("common.submit")}
|
class="w-auto self-start"
|
||||||
|
type="submit"
|
||||||
|
size="large"
|
||||||
|
variant="primary"
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { createMemo, For, Show } from "solid-js"
|
import { createMemo, For, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
|
@ -28,7 +29,6 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||||
color: props.project.icon?.color || "pink",
|
color: props.project.icon?.color || "pink",
|
||||||
iconUrl: props.project.icon?.override || "",
|
iconUrl: props.project.icon?.override || "",
|
||||||
startup: props.project.commands?.start ?? "",
|
startup: props.project.commands?.start ?? "",
|
||||||
saving: false,
|
|
||||||
dragOver: false,
|
dragOver: false,
|
||||||
iconHover: false,
|
iconHover: false,
|
||||||
})
|
})
|
||||||
|
|
@ -71,38 +71,37 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||||
setStore("iconUrl", "")
|
setStore("iconUrl", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
const saveMutation = useMutation(() => ({
|
||||||
e.preventDefault()
|
mutationFn: async () => {
|
||||||
|
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||||
|
const start = store.startup.trim()
|
||||||
|
|
||||||
await Promise.resolve()
|
if (props.project.id && props.project.id !== "global") {
|
||||||
.then(async () => {
|
await globalSDK.client.project.update({
|
||||||
setStore("saving", true)
|
projectID: props.project.id,
|
||||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
directory: props.project.worktree,
|
||||||
const start = store.startup.trim()
|
|
||||||
|
|
||||||
if (props.project.id && props.project.id !== "global") {
|
|
||||||
await globalSDK.client.project.update({
|
|
||||||
projectID: props.project.id,
|
|
||||||
directory: props.project.worktree,
|
|
||||||
name,
|
|
||||||
icon: { color: store.color, override: store.iconUrl },
|
|
||||||
commands: { start },
|
|
||||||
})
|
|
||||||
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
|
||||||
dialog.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
globalSync.project.meta(props.project.worktree, {
|
|
||||||
name,
|
name,
|
||||||
icon: { color: store.color, override: store.iconUrl || undefined },
|
icon: { color: store.color, override: store.iconUrl },
|
||||||
commands: { start: start || undefined },
|
commands: { start },
|
||||||
})
|
})
|
||||||
|
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
||||||
dialog.close()
|
dialog.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
globalSync.project.meta(props.project.worktree, {
|
||||||
|
name,
|
||||||
|
icon: { color: store.color, override: store.iconUrl || undefined },
|
||||||
|
commands: { start: start || undefined },
|
||||||
})
|
})
|
||||||
.finally(() => {
|
dialog.close()
|
||||||
setStore("saving", false)
|
},
|
||||||
})
|
}))
|
||||||
|
|
||||||
|
function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (saveMutation.isPending) return
|
||||||
|
saveMutation.mutate()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -246,8 +245,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||||
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||||
{language.t("common.cancel")}
|
{language.t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" variant="primary" size="large" disabled={store.saving}>
|
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
|
||||||
{store.saving ? language.t("common.saving") : language.t("common.save")}
|
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ export const DialogFork: Component = () => {
|
||||||
directory: sdk.directory,
|
directory: sdk.directory,
|
||||||
attachmentName: language.t("common.attachment"),
|
attachmentName: language.t("common.attachment"),
|
||||||
})
|
})
|
||||||
|
const dir = base64Encode(sdk.directory)
|
||||||
|
|
||||||
sdk.client.session
|
sdk.client.session
|
||||||
.fork({ sessionID, messageID: item.id })
|
.fork({ sessionID, messageID: item.id })
|
||||||
|
|
@ -75,10 +76,8 @@ export const DialogFork: Component = () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dialog.close()
|
dialog.close()
|
||||||
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
|
prompt.set(restored, undefined, { dir, id: forked.data.id })
|
||||||
requestAnimationFrame(() => {
|
navigate(`/${dir}/session/${forked.data.id}`)
|
||||||
prompt.set(restored)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { Keybind } from "@opencode-ai/ui/keybind"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
||||||
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
|
|
@ -14,6 +14,8 @@ import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { getRelativeTime } from "@/utils/time"
|
import { getRelativeTime } from "@/utils/time"
|
||||||
|
|
||||||
|
|
@ -132,9 +134,14 @@ function createFileEntries(props: {
|
||||||
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
}) {
|
}) {
|
||||||
|
const tabState = createSessionTabs({
|
||||||
|
tabs: props.tabs,
|
||||||
|
pathFromTab: props.file.pathFromTab,
|
||||||
|
normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
|
||||||
|
})
|
||||||
const recent = createMemo(() => {
|
const recent = createMemo(() => {
|
||||||
const all = props.tabs().all()
|
const all = tabState.openedTabs()
|
||||||
const active = props.tabs().active()
|
const active = tabState.activeFileTab()
|
||||||
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const category = props.language.t("palette.group.files")
|
const category = props.language.t("palette.group.files")
|
||||||
|
|
@ -259,14 +266,11 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const params = useParams()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
|
const { params, tabs, view } = useSessionLayout()
|
||||||
const filesOnly = () => props.mode === "files"
|
const filesOnly = () => props.mode === "files"
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
|
||||||
const view = createMemo(() => layout.view(sessionKey))
|
|
||||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||||
const [grouped, setGrouped] = createSignal(false)
|
const [grouped, setGrouped] = createSignal(false)
|
||||||
const commandEntries = createCommandEntries({ filesOnly, command, language })
|
const commandEntries = createCommandEntries({ filesOnly, command, language })
|
||||||
|
|
@ -422,7 +426,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={item.keybind}>
|
<Show when={item.keybind}>
|
||||||
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
|
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", language.t)}</Keybind>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { Component, createMemo, createSignal, Show } from "solid-js"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
|
import { Component, createEffect, createMemo, on, Show } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
const statusLabels = {
|
const statusLabels = {
|
||||||
|
|
@ -17,7 +20,48 @@ export const DialogSelectMcp: Component = () => {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [loading, setLoading] = createSignal<string | null>(null)
|
const [state, setState] = createStore({
|
||||||
|
done: false,
|
||||||
|
loading: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => sync.data.mcp_ready,
|
||||||
|
(ready, prev) => {
|
||||||
|
if (!ready && prev) setState("done", false)
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (state.done || state.loading) return
|
||||||
|
if (sync.data.mcp_ready) {
|
||||||
|
setState("done", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setState("loading", true)
|
||||||
|
void sdk.client.mcp
|
||||||
|
.status()
|
||||||
|
.then((result) => {
|
||||||
|
sync.set("mcp", result.data ?? {})
|
||||||
|
sync.set("mcp_ready", true)
|
||||||
|
setState("done", true)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setState("done", true)
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: language.t("common.requestFailed"),
|
||||||
|
description: err instanceof Error ? err.message : String(err),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setState("loading", false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const items = createMemo(() =>
|
const items = createMemo(() =>
|
||||||
Object.entries(sync.data.mcp ?? {})
|
Object.entries(sync.data.mcp ?? {})
|
||||||
|
|
@ -25,10 +69,8 @@ export const DialogSelectMcp: Component = () => {
|
||||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggle = async (name: string) => {
|
const toggle = useMutation(() => ({
|
||||||
if (loading()) return
|
mutationFn: async (name: string) => {
|
||||||
setLoading(name)
|
|
||||||
try {
|
|
||||||
const status = sync.data.mcp[name]
|
const status = sync.data.mcp[name]
|
||||||
if (status?.status === "connected") {
|
if (status?.status === "connected") {
|
||||||
await sdk.client.mcp.disconnect({ name })
|
await sdk.client.mcp.disconnect({ name })
|
||||||
|
|
@ -38,10 +80,8 @@ export const DialogSelectMcp: Component = () => {
|
||||||
|
|
||||||
const result = await sdk.client.mcp.status()
|
const result = await sdk.client.mcp.status()
|
||||||
if (result.data) sync.set("mcp", result.data)
|
if (result.data) sync.set("mcp", result.data)
|
||||||
} finally {
|
},
|
||||||
setLoading(null)
|
}))
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||||
const totalCount = createMemo(() => items().length)
|
const totalCount = createMemo(() => items().length)
|
||||||
|
|
@ -59,7 +99,8 @@ export const DialogSelectMcp: Component = () => {
|
||||||
filterKeys={["name", "status"]}
|
filterKeys={["name", "status"]}
|
||||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (x) toggle(x.name)
|
if (!x || toggle.isPending) return
|
||||||
|
toggle.mutate(x.name)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
|
|
@ -83,7 +124,7 @@ export const DialogSelectMcp: Component = () => {
|
||||||
<Show when={statusLabel()}>
|
<Show when={statusLabel()}>
|
||||||
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={loading() === i.name}>
|
<Show when={toggle.isPending && toggle.variables === i.name}>
|
||||||
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -92,7 +133,14 @@ export const DialogSelectMcp: Component = () => {
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
|
<Switch
|
||||||
|
checked={enabled()}
|
||||||
|
disabled={toggle.isPending && toggle.variables === i.name}
|
||||||
|
onChange={() => {
|
||||||
|
if (toggle.isPending) return
|
||||||
|
toggle.mutate(i.name)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,29 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { type Component, Show } from "solid-js"
|
import { type Component, Show } from "solid-js"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
|
||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
export const DialogSelectModelUnpaid: Component = () => {
|
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||||
const local = useLocal()
|
|
||||||
|
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
|
||||||
|
const model = props.model ?? useLocal().model
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
|
const connect = (provider: string) => {
|
||||||
|
void import("./dialog-connect-provider").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogConnectProvider provider={provider} />)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const all = () => {
|
||||||
|
void import("./dialog-select-provider").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogSelectProvider />)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let listRef: ListRef | undefined
|
let listRef: ListRef | undefined
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape") return
|
if (e.key === "Escape") return
|
||||||
|
|
@ -35,8 +47,8 @@ export const DialogSelectModelUnpaid: Component = () => {
|
||||||
<List
|
<List
|
||||||
class="[&_[data-slot=list-scroll]]:overflow-visible"
|
class="[&_[data-slot=list-scroll]]:overflow-visible"
|
||||||
ref={(ref) => (listRef = ref)}
|
ref={(ref) => (listRef = ref)}
|
||||||
items={local.model.list}
|
items={model.list}
|
||||||
current={local.model.current()}
|
current={model.current()}
|
||||||
key={(x) => `${x.provider.id}:${x.id}`}
|
key={(x) => `${x.provider.id}:${x.id}`}
|
||||||
itemWrapper={(item, node) => (
|
itemWrapper={(item, node) => (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
|
|
@ -55,7 +67,7 @@ export const DialogSelectModelUnpaid: Component = () => {
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||||
recent: true,
|
recent: true,
|
||||||
})
|
})
|
||||||
dialog.close()
|
dialog.close()
|
||||||
|
|
@ -89,7 +101,7 @@ export const DialogSelectModelUnpaid: Component = () => {
|
||||||
}}
|
}}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (!x) return
|
if (!x) return
|
||||||
dialog.show(() => <DialogConnectProvider provider={x.id} />)
|
connect(x.id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
|
|
@ -120,9 +132,7 @@ export const DialogSelectModelUnpaid: Component = () => {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
|
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
|
||||||
icon="dot-grid"
|
icon="dot-grid"
|
||||||
onClick={() => {
|
onClick={all}
|
||||||
dialog.show(() => <DialogSelectProvider />)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{language.t("dialog.provider.viewAll")}
|
{language.t("dialog.provider.viewAll")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -10,27 +10,28 @@ import { Tag } from "@opencode-ai/ui/tag"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
|
||||||
import { DialogManageModels } from "./dialog-manage-models"
|
|
||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||||
provider === "opencode" && (!cost || cost.input === 0)
|
provider === "opencode" && (!cost || cost.input === 0)
|
||||||
|
|
||||||
|
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||||
|
|
||||||
const ModelList: Component<{
|
const ModelList: Component<{
|
||||||
provider?: string
|
provider?: string
|
||||||
class?: string
|
class?: string
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
action?: JSX.Element
|
action?: JSX.Element
|
||||||
|
model?: ModelState
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const local = useLocal()
|
const model = props.model ?? useLocal().model
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const models = createMemo(() =>
|
const models = createMemo(() =>
|
||||||
local.model
|
model
|
||||||
.list()
|
.list()
|
||||||
.filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id }))
|
.filter((m) => model.visible({ modelID: m.id, providerID: m.provider.id }))
|
||||||
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
|
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -41,7 +42,7 @@ const ModelList: Component<{
|
||||||
emptyMessage={language.t("dialog.model.empty")}
|
emptyMessage={language.t("dialog.model.empty")}
|
||||||
key={(x) => `${x.provider.id}:${x.id}`}
|
key={(x) => `${x.provider.id}:${x.id}`}
|
||||||
items={models}
|
items={models}
|
||||||
current={local.model.current()}
|
current={model.current()}
|
||||||
filterKeys={["provider.name", "name", "id"]}
|
filterKeys={["provider.name", "name", "id"]}
|
||||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||||
groupBy={(x) => x.provider.name}
|
groupBy={(x) => x.provider.name}
|
||||||
|
|
@ -63,7 +64,7 @@ const ModelList: Component<{
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||||
recent: true,
|
recent: true,
|
||||||
})
|
})
|
||||||
props.onSelect()
|
props.onSelect()
|
||||||
|
|
@ -85,30 +86,42 @@ const ModelList: Component<{
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
||||||
|
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
||||||
|
|
||||||
export function ModelSelectorPopover(props: {
|
export function ModelSelectorPopover(props: {
|
||||||
provider?: string
|
provider?: string
|
||||||
|
model?: ModelState
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
triggerAs?: ValidComponent
|
triggerAs?: ValidComponent
|
||||||
triggerProps?: ModelSelectorTriggerProps
|
triggerProps?: ModelSelectorTriggerProps
|
||||||
|
onClose?: (cause: "escape" | "select") => void
|
||||||
}) {
|
}) {
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
open: boolean
|
open: boolean
|
||||||
dismiss: "escape" | "outside" | null
|
dismiss: Dismiss | null
|
||||||
}>({
|
}>({
|
||||||
open: false,
|
open: false,
|
||||||
dismiss: null,
|
dismiss: null,
|
||||||
})
|
})
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
||||||
const handleManage = () => {
|
const close = (dismiss: Dismiss) => {
|
||||||
|
setStore("dismiss", dismiss)
|
||||||
setStore("open", false)
|
setStore("open", false)
|
||||||
dialog.show(() => <DialogManageModels />)
|
}
|
||||||
|
|
||||||
|
const handleManage = () => {
|
||||||
|
close("manage")
|
||||||
|
void import("./dialog-manage-models").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogManageModels />)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConnectProvider = () => {
|
const handleConnectProvider = () => {
|
||||||
setStore("open", false)
|
close("provider")
|
||||||
dialog.show(() => <DialogSelectProvider />)
|
void import("./dialog-select-provider").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogSelectProvider />)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
|
|
@ -130,28 +143,27 @@ export function ModelSelectorPopover(props: {
|
||||||
<Kobalte.Content
|
<Kobalte.Content
|
||||||
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||||
onEscapeKeyDown={(event) => {
|
onEscapeKeyDown={(event) => {
|
||||||
setStore("dismiss", "escape")
|
close("escape")
|
||||||
setStore("open", false)
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}}
|
}}
|
||||||
onPointerDownOutside={() => {
|
onPointerDownOutside={() => close("outside")}
|
||||||
setStore("dismiss", "outside")
|
onFocusOutside={() => close("outside")}
|
||||||
setStore("open", false)
|
|
||||||
}}
|
|
||||||
onFocusOutside={() => {
|
|
||||||
setStore("dismiss", "outside")
|
|
||||||
setStore("open", false)
|
|
||||||
}}
|
|
||||||
onCloseAutoFocus={(event) => {
|
onCloseAutoFocus={(event) => {
|
||||||
if (store.dismiss === "outside") event.preventDefault()
|
const dismiss = store.dismiss
|
||||||
|
if (dismiss === "outside") event.preventDefault()
|
||||||
|
if (dismiss === "escape" || dismiss === "select") {
|
||||||
|
event.preventDefault()
|
||||||
|
props.onClose?.(dismiss)
|
||||||
|
}
|
||||||
setStore("dismiss", null)
|
setStore("dismiss", null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
|
<Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
|
||||||
<ModelList
|
<ModelList
|
||||||
provider={props.provider}
|
provider={props.provider}
|
||||||
onSelect={() => setStore("open", false)}
|
model={props.model}
|
||||||
|
onSelect={() => close("select")}
|
||||||
class="p-1"
|
class="p-1"
|
||||||
action={
|
action={
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
|
@ -184,30 +196,33 @@ export function ModelSelectorPopover(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
|
const provider = () => {
|
||||||
|
void import("./dialog-select-provider").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogSelectProvider />)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const manage = () => {
|
||||||
|
void import("./dialog-manage-models").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogManageModels />)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
title={language.t("dialog.model.select.title")}
|
title={language.t("dialog.model.select.title")}
|
||||||
action={
|
action={
|
||||||
<Button
|
<Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={provider}>
|
||||||
class="h-7 -my-1 text-14-medium"
|
|
||||||
icon="plus-small"
|
|
||||||
tabIndex={-1}
|
|
||||||
onClick={() => dialog.show(() => <DialogSelectProvider />)}
|
|
||||||
>
|
|
||||||
{language.t("command.provider.connect")}
|
{language.t("command.provider.connect")}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ModelList provider={props.provider} onSelect={() => dialog.close()} />
|
<ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
|
||||||
<Button
|
<Button variant="ghost" class="ml-3 mt-5 mb-6 text-text-base self-start" onClick={manage}>
|
||||||
variant="ghost"
|
|
||||||
class="ml-3 mt-5 mb-6 text-text-base self-start"
|
|
||||||
onClick={() => dialog.show(() => <DialogManageModels />)}
|
|
||||||
>
|
|
||||||
{language.t("dialog.model.manage")}
|
{language.t("dialog.model.manage")}
|
||||||
</Button>
|
</Button>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
||||||
|
|
@ -14,7 +15,9 @@ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
|
const DEFAULT_USERNAME = "opencode"
|
||||||
|
|
||||||
interface ServerFormProps {
|
interface ServerFormProps {
|
||||||
value: string
|
value: string
|
||||||
|
|
@ -41,13 +44,15 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
|
function useDefaultServer() {
|
||||||
const [defaultUrl, defaultUrlActions] = createResource(
|
const language = useLanguage()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const [defaultKey, defaultUrlActions] = createResource(
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
try {
|
||||||
const url = await platform.getDefaultServerUrl?.()
|
const key = await platform.getDefaultServer?.()
|
||||||
if (!url) return null
|
if (!key) return null
|
||||||
return normalizeServerUrl(url) ?? null
|
return key
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showRequestError(language, err)
|
showRequestError(language, err)
|
||||||
return null
|
return null
|
||||||
|
|
@ -56,20 +61,22 @@ function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: Re
|
||||||
{ initialValue: null },
|
{ initialValue: null },
|
||||||
)
|
)
|
||||||
|
|
||||||
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
||||||
const setDefault = async (url: string | null) => {
|
const setDefault = async (key: ServerConnection.Key | null) => {
|
||||||
try {
|
try {
|
||||||
await platform.setDefaultServerUrl?.(url)
|
await platform.setDefaultServer?.(key)
|
||||||
defaultUrlActions.mutate(url)
|
defaultUrlActions.mutate(key)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showRequestError(language, err)
|
showRequestError(language, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { defaultUrl, canDefault, setDefault }
|
return { defaultKey, canDefault, setDefault }
|
||||||
}
|
}
|
||||||
|
|
||||||
function useServerPreview(fetcher: typeof fetch) {
|
function useServerPreview() {
|
||||||
|
const checkServerHealth = useCheckServerHealth()
|
||||||
|
|
||||||
const looksComplete = (value: string) => {
|
const looksComplete = (value: string) => {
|
||||||
const normalized = normalizeServerUrl(value)
|
const normalized = normalizeServerUrl(value)
|
||||||
if (!normalized) return false
|
if (!normalized) return false
|
||||||
|
|
@ -92,7 +99,7 @@ function useServerPreview(fetcher: typeof fetch) {
|
||||||
const http: ServerConnection.HttpBase = { url: normalized }
|
const http: ServerConnection.HttpBase = { url: normalized }
|
||||||
if (username) http.username = username
|
if (username) http.username = username
|
||||||
if (password) http.password = password
|
if (password) http.password = password
|
||||||
const result = await checkServerHealth(http, fetcher)
|
const result = await checkServerHealth(http)
|
||||||
setStatus(result.healthy)
|
setStatus(result.healthy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,7 +122,7 @@ function ServerForm(props: ServerFormProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="px-5">
|
<div class="px-5">
|
||||||
<div class="bg-surface-raised-base rounded-md p-5 flex flex-col gap-3">
|
<div class="bg-surface-base rounded-md p-5 flex flex-col gap-3">
|
||||||
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
|
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
|
||||||
<TextField
|
<TextField
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -143,7 +150,7 @@ function ServerForm(props: ServerFormProps) {
|
||||||
<TextField
|
<TextField
|
||||||
type="text"
|
type="text"
|
||||||
label={language.t("dialog.server.add.username")}
|
label={language.t("dialog.server.add.username")}
|
||||||
placeholder="username"
|
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
||||||
value={props.username}
|
value={props.username}
|
||||||
disabled={props.busy}
|
disabled={props.busy}
|
||||||
onChange={props.onUsernameChange}
|
onChange={props.onUsernameChange}
|
||||||
|
|
@ -152,7 +159,7 @@ function ServerForm(props: ServerFormProps) {
|
||||||
<TextField
|
<TextField
|
||||||
type="password"
|
type="password"
|
||||||
label={language.t("dialog.server.add.password")}
|
label={language.t("dialog.server.add.password")}
|
||||||
placeholder="password"
|
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||||
value={props.password}
|
value={props.password}
|
||||||
disabled={props.busy}
|
disabled={props.busy}
|
||||||
onChange={props.onPasswordChange}
|
onChange={props.onPasswordChange}
|
||||||
|
|
@ -170,17 +177,16 @@ export function DialogSelectServer() {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const fetcher = platform.fetch ?? globalThis.fetch
|
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
||||||
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
|
const { previewStatus } = useServerPreview()
|
||||||
const { previewStatus } = useServerPreview(fetcher)
|
const checkServerHealth = useCheckServerHealth()
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||||
addServer: {
|
addServer: {
|
||||||
url: "",
|
url: "",
|
||||||
name: "",
|
name: "",
|
||||||
username: "",
|
username: DEFAULT_USERNAME,
|
||||||
password: "",
|
password: "",
|
||||||
adding: false,
|
|
||||||
error: "",
|
error: "",
|
||||||
showForm: false,
|
showForm: false,
|
||||||
status: undefined as boolean | undefined,
|
status: undefined as boolean | undefined,
|
||||||
|
|
@ -192,7 +198,6 @@ export function DialogSelectServer() {
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
busy: false,
|
|
||||||
status: undefined as boolean | undefined,
|
status: undefined as boolean | undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -201,9 +206,8 @@ export function DialogSelectServer() {
|
||||||
setStore("addServer", {
|
setStore("addServer", {
|
||||||
url: "",
|
url: "",
|
||||||
name: "",
|
name: "",
|
||||||
username: "",
|
username: DEFAULT_USERNAME,
|
||||||
password: "",
|
password: "",
|
||||||
adding: false,
|
|
||||||
error: "",
|
error: "",
|
||||||
showForm: false,
|
showForm: false,
|
||||||
status: undefined,
|
status: undefined,
|
||||||
|
|
@ -218,10 +222,78 @@ export function DialogSelectServer() {
|
||||||
password: "",
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
status: undefined,
|
status: undefined,
|
||||||
busy: false,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addMutation = useMutation(() => ({
|
||||||
|
mutationFn: async (value: string) => {
|
||||||
|
const normalized = normalizeServerUrl(value)
|
||||||
|
if (!normalized) {
|
||||||
|
resetAdd()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn: ServerConnection.Http = {
|
||||||
|
type: "http",
|
||||||
|
http: { url: normalized },
|
||||||
|
}
|
||||||
|
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||||
|
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||||
|
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
||||||
|
const result = await checkServerHealth(conn.http)
|
||||||
|
if (!result.healthy) {
|
||||||
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetAdd()
|
||||||
|
await select(conn, true)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const editMutation = useMutation(() => ({
|
||||||
|
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
|
||||||
|
if (input.original.type !== "http") return
|
||||||
|
const normalized = normalizeServerUrl(input.value)
|
||||||
|
if (!normalized) {
|
||||||
|
resetEdit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = store.editServer.name.trim() || undefined
|
||||||
|
const username = store.editServer.username || undefined
|
||||||
|
const password = store.editServer.password || undefined
|
||||||
|
const existingName = input.original.displayName
|
||||||
|
if (
|
||||||
|
normalized === input.original.http.url &&
|
||||||
|
name === existingName &&
|
||||||
|
username === input.original.http.username &&
|
||||||
|
password === input.original.http.password
|
||||||
|
) {
|
||||||
|
resetEdit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn: ServerConnection.Http = {
|
||||||
|
type: "http",
|
||||||
|
displayName: name,
|
||||||
|
http: { url: normalized, username, password },
|
||||||
|
}
|
||||||
|
const result = await checkServerHealth(conn.http)
|
||||||
|
if (!result.healthy) {
|
||||||
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalized === input.original.http.url) {
|
||||||
|
server.add(conn)
|
||||||
|
} else {
|
||||||
|
replaceServer(input.original, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetEdit()
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
||||||
const active = server.key
|
const active = server.key
|
||||||
const newConn = server.add(next)
|
const newConn = server.add(next)
|
||||||
|
|
@ -264,7 +336,7 @@ export function DialogSelectServer() {
|
||||||
const results: Record<ServerConnection.Key, ServerHealth> = {}
|
const results: Record<ServerConnection.Key, ServerHealth> = {}
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
items().map(async (conn) => {
|
items().map(async (conn) => {
|
||||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
|
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
setStore("status", reconcile(results))
|
setStore("status", reconcile(results))
|
||||||
|
|
@ -285,12 +357,12 @@ export function DialogSelectServer() {
|
||||||
navigate("/")
|
navigate("/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
server.setActive(ServerConnection.key(conn))
|
|
||||||
navigate("/")
|
navigate("/")
|
||||||
|
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddChange = (value: string) => {
|
const handleAddChange = (value: string) => {
|
||||||
if (store.addServer.adding) return
|
if (addMutation.isPending) return
|
||||||
setStore("addServer", { url: value, error: "" })
|
setStore("addServer", { url: value, error: "" })
|
||||||
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
||||||
setStore("addServer", { status: next }),
|
setStore("addServer", { status: next }),
|
||||||
|
|
@ -298,12 +370,12 @@ export function DialogSelectServer() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddNameChange = (value: string) => {
|
const handleAddNameChange = (value: string) => {
|
||||||
if (store.addServer.adding) return
|
if (addMutation.isPending) return
|
||||||
setStore("addServer", { name: value, error: "" })
|
setStore("addServer", { name: value, error: "" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddUsernameChange = (value: string) => {
|
const handleAddUsernameChange = (value: string) => {
|
||||||
if (store.addServer.adding) return
|
if (addMutation.isPending) return
|
||||||
setStore("addServer", { username: value, error: "" })
|
setStore("addServer", { username: value, error: "" })
|
||||||
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
||||||
setStore("addServer", { status: next }),
|
setStore("addServer", { status: next }),
|
||||||
|
|
@ -311,7 +383,7 @@ export function DialogSelectServer() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddPasswordChange = (value: string) => {
|
const handleAddPasswordChange = (value: string) => {
|
||||||
if (store.addServer.adding) return
|
if (addMutation.isPending) return
|
||||||
setStore("addServer", { password: value, error: "" })
|
setStore("addServer", { password: value, error: "" })
|
||||||
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
||||||
setStore("addServer", { status: next }),
|
setStore("addServer", { status: next }),
|
||||||
|
|
@ -319,7 +391,7 @@ export function DialogSelectServer() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditChange = (value: string) => {
|
const handleEditChange = (value: string) => {
|
||||||
if (store.editServer.busy) return
|
if (editMutation.isPending) return
|
||||||
setStore("editServer", { value, error: "" })
|
setStore("editServer", { value, error: "" })
|
||||||
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
||||||
setStore("editServer", { status: next }),
|
setStore("editServer", { status: next }),
|
||||||
|
|
@ -327,12 +399,12 @@ export function DialogSelectServer() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditNameChange = (value: string) => {
|
const handleEditNameChange = (value: string) => {
|
||||||
if (store.editServer.busy) return
|
if (editMutation.isPending) return
|
||||||
setStore("editServer", { name: value, error: "" })
|
setStore("editServer", { name: value, error: "" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditUsernameChange = (value: string) => {
|
const handleEditUsernameChange = (value: string) => {
|
||||||
if (store.editServer.busy) return
|
if (editMutation.isPending) return
|
||||||
setStore("editServer", { username: value, error: "" })
|
setStore("editServer", { username: value, error: "" })
|
||||||
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
||||||
setStore("editServer", { status: next }),
|
setStore("editServer", { status: next }),
|
||||||
|
|
@ -340,85 +412,13 @@ export function DialogSelectServer() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditPasswordChange = (value: string) => {
|
const handleEditPasswordChange = (value: string) => {
|
||||||
if (store.editServer.busy) return
|
if (editMutation.isPending) return
|
||||||
setStore("editServer", { password: value, error: "" })
|
setStore("editServer", { password: value, error: "" })
|
||||||
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
||||||
setStore("editServer", { status: next }),
|
setStore("editServer", { status: next }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAdd(value: string) {
|
|
||||||
if (store.addServer.adding) return
|
|
||||||
const normalized = normalizeServerUrl(value)
|
|
||||||
if (!normalized) {
|
|
||||||
resetAdd()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setStore("addServer", { adding: true, error: "" })
|
|
||||||
|
|
||||||
const conn: ServerConnection.Http = {
|
|
||||||
type: "http",
|
|
||||||
http: { url: normalized },
|
|
||||||
}
|
|
||||||
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
|
||||||
if (store.addServer.username) conn.http.username = store.addServer.username
|
|
||||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
|
||||||
const result = await checkServerHealth(conn.http, fetcher)
|
|
||||||
setStore("addServer", { adding: false })
|
|
||||||
if (!result.healthy) {
|
|
||||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resetAdd()
|
|
||||||
await select(conn, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEdit(original: ServerConnection.Any, value: string) {
|
|
||||||
if (store.editServer.busy || original.type !== "http") return
|
|
||||||
const normalized = normalizeServerUrl(value)
|
|
||||||
if (!normalized) {
|
|
||||||
resetEdit()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = store.editServer.name.trim() || undefined
|
|
||||||
const username = store.editServer.username || undefined
|
|
||||||
const password = store.editServer.password || undefined
|
|
||||||
const existingName = original.displayName
|
|
||||||
if (
|
|
||||||
normalized === original.http.url &&
|
|
||||||
name === existingName &&
|
|
||||||
username === original.http.username &&
|
|
||||||
password === original.http.password
|
|
||||||
) {
|
|
||||||
resetEdit()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setStore("editServer", { busy: true, error: "" })
|
|
||||||
|
|
||||||
const conn: ServerConnection.Http = {
|
|
||||||
type: "http",
|
|
||||||
displayName: name,
|
|
||||||
http: { url: normalized, username, password },
|
|
||||||
}
|
|
||||||
const result = await checkServerHealth(conn.http, fetcher)
|
|
||||||
setStore("editServer", { busy: false })
|
|
||||||
if (!result.healthy) {
|
|
||||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (normalized === original.http.url) {
|
|
||||||
server.add(conn)
|
|
||||||
} else {
|
|
||||||
replaceServer(original, conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
resetEdit()
|
|
||||||
}
|
|
||||||
|
|
||||||
const mode = createMemo<"list" | "add" | "edit">(() => {
|
const mode = createMemo<"list" | "add" | "edit">(() => {
|
||||||
if (store.editServer.id) return "edit"
|
if (store.editServer.id) return "edit"
|
||||||
if (store.addServer.showForm) return "add"
|
if (store.addServer.showForm) return "add"
|
||||||
|
|
@ -441,7 +441,7 @@ export function DialogSelectServer() {
|
||||||
showForm: true,
|
showForm: true,
|
||||||
url: "",
|
url: "",
|
||||||
name: "",
|
name: "",
|
||||||
username: "",
|
username: DEFAULT_USERNAME,
|
||||||
password: "",
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
status: undefined,
|
status: undefined,
|
||||||
|
|
@ -458,23 +458,26 @@ export function DialogSelectServer() {
|
||||||
password: conn.http.password ?? "",
|
password: conn.http.password ?? "",
|
||||||
error: "",
|
error: "",
|
||||||
status: store.status[ServerConnection.key(conn)]?.healthy,
|
status: store.status[ServerConnection.key(conn)]?.healthy,
|
||||||
busy: false,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitForm = () => {
|
const submitForm = () => {
|
||||||
if (mode() === "add") {
|
if (mode() === "add") {
|
||||||
void handleAdd(store.addServer.url)
|
if (addMutation.isPending) return
|
||||||
|
setStore("addServer", { error: "" })
|
||||||
|
addMutation.mutate(store.addServer.url)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const original = editing()
|
const original = editing()
|
||||||
if (!original) return
|
if (!original) return
|
||||||
void handleEdit(original, store.editServer.value)
|
if (editMutation.isPending) return
|
||||||
|
setStore("editServer", { error: "" })
|
||||||
|
editMutation.mutate({ original, value: store.editServer.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFormMode = createMemo(() => mode() !== "list")
|
const isFormMode = createMemo(() => mode() !== "list")
|
||||||
const isAddMode = createMemo(() => mode() === "add")
|
const isAddMode = createMemo(() => mode() === "add")
|
||||||
const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy))
|
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
|
||||||
|
|
||||||
const formTitle = createMemo(() => {
|
const formTitle = createMemo(() => {
|
||||||
if (!isFormMode()) return language.t("dialog.server.title")
|
if (!isFormMode()) return language.t("dialog.server.title")
|
||||||
|
|
@ -494,8 +497,8 @@ export function DialogSelectServer() {
|
||||||
|
|
||||||
async function handleRemove(url: ServerConnection.Key) {
|
async function handleRemove(url: ServerConnection.Key) {
|
||||||
server.remove(url)
|
server.remove(url)
|
||||||
if ((await platform.getDefaultServerUrl?.()) === url) {
|
if ((await platform.getDefaultServer?.()) === url) {
|
||||||
platform.setDefaultServerUrl?.(null)
|
platform.setDefaultServer?.(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -536,7 +539,7 @@ export function DialogSelectServer() {
|
||||||
if (x) select(x)
|
if (x) select(x)
|
||||||
}}
|
}}
|
||||||
divider={true}
|
divider={true}
|
||||||
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
|
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
|
||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
const key = ServerConnection.key(i)
|
const key = ServerConnection.key(i)
|
||||||
|
|
@ -551,7 +554,7 @@ export function DialogSelectServer() {
|
||||||
status={store.status[key]}
|
status={store.status[key]}
|
||||||
class="flex items-center gap-3 min-w-0 flex-1"
|
class="flex items-center gap-3 min-w-0 flex-1"
|
||||||
badge={
|
badge={
|
||||||
<Show when={defaultUrl() === i.http.url}>
|
<Show when={defaultKey() === ServerConnection.key(i)}>
|
||||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||||
{language.t("dialog.server.status.default")}
|
{language.t("dialog.server.status.default")}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -584,14 +587,14 @@ export function DialogSelectServer() {
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<Show when={canDefault() && defaultUrl() !== i.http.url}>
|
<Show when={canDefault() && defaultKey() !== key}>
|
||||||
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
|
<DropdownMenu.Item onSelect={() => setDefault(key)}>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.default")}
|
{language.t("dialog.server.menu.default")}
|
||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={canDefault() && defaultUrl() === i.http.url}>
|
<Show when={canDefault() && defaultKey() === key}>
|
||||||
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||||
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
|
import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||||
import {
|
import {
|
||||||
|
|
@ -17,7 +16,6 @@ import {
|
||||||
} from "@/context/prompt"
|
} from "@/context/prompt"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useParams } from "@solidjs/router"
|
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
|
@ -27,18 +25,20 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Select } from "@opencode-ai/ui/select"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
import { RadioGroup } from "@opencode-ai/ui/radio-group"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
|
import { promptEnabled, promptProbe } from "@/testing/prompt"
|
||||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||||
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
|
import { createPromptAttachments } from "./prompt-input/attachments"
|
||||||
|
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
|
||||||
import {
|
import {
|
||||||
canNavigateHistoryAtCursor,
|
canNavigateHistoryAtCursor,
|
||||||
navigatePromptHistory,
|
navigatePromptHistory,
|
||||||
|
|
@ -48,7 +48,7 @@ import {
|
||||||
type PromptHistoryStoredEntry,
|
type PromptHistoryStoredEntry,
|
||||||
promptLength,
|
promptLength,
|
||||||
} from "./prompt-input/history"
|
} from "./prompt-input/history"
|
||||||
import { createPromptSubmit } from "./prompt-input/submit"
|
import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
|
||||||
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
|
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
|
||||||
import { PromptContextItems } from "./prompt-input/context-items"
|
import { PromptContextItems } from "./prompt-input/context-items"
|
||||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||||
|
|
@ -61,6 +61,11 @@ interface PromptInputProps {
|
||||||
ref?: (el: HTMLDivElement) => void
|
ref?: (el: HTMLDivElement) => void
|
||||||
newSessionWorktree?: string
|
newSessionWorktree?: string
|
||||||
onNewSessionWorktreeReset?: () => void
|
onNewSessionWorktreeReset?: () => void
|
||||||
|
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
|
||||||
|
onEditLoaded?: () => void
|
||||||
|
shouldQueue?: () => boolean
|
||||||
|
onQueue?: (draft: FollowupDraft) => void
|
||||||
|
onAbort?: () => void
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,20 +107,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
const params = useParams()
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const { params, tabs, view } = useSessionLayout()
|
||||||
let editorRef!: HTMLDivElement
|
let editorRef!: HTMLDivElement
|
||||||
let fileInputRef: HTMLInputElement | undefined
|
let fileInputRef: HTMLInputElement | undefined
|
||||||
let scrollRef!: HTMLDivElement
|
let scrollRef!: HTMLDivElement
|
||||||
let slashPopoverRef!: HTMLDivElement
|
let slashPopoverRef!: HTMLDivElement
|
||||||
|
|
||||||
const mirror = { input: false }
|
const mirror = { input: false }
|
||||||
const inset = 44
|
const inset = 56
|
||||||
|
const space = `${inset}px`
|
||||||
|
|
||||||
const scrollCursorIntoView = () => {
|
const scrollCursorIntoView = () => {
|
||||||
const container = scrollRef
|
const container = scrollRef
|
||||||
|
|
@ -150,13 +156,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const queueScroll = () => {
|
const queueScroll = (count = 2) => {
|
||||||
requestAnimationFrame(scrollCursorIntoView)
|
requestAnimationFrame(() => {
|
||||||
|
scrollCursorIntoView()
|
||||||
|
if (count > 1) queueScroll(count - 1)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const activeFileTab = createSessionTabs({
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
tabs,
|
||||||
const view = createMemo(() => layout.view(sessionKey))
|
pathFromTab: files.pathFromTab,
|
||||||
|
normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
|
||||||
|
}).activeFileTab
|
||||||
|
|
||||||
const commentInReview = (path: string) => {
|
const commentInReview = (path: string) => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
|
|
@ -209,7 +220,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
|
|
||||||
const recent = createMemo(() => {
|
const recent = createMemo(() => {
|
||||||
const all = tabs().all()
|
const all = tabs().all()
|
||||||
const active = tabs().active()
|
const active = activeFileTab()
|
||||||
const order = active ? [active, ...all.filter((x) => x !== active)] : all
|
const order = active ? [active, ...all.filter((x) => x !== active)] : all
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const paths: string[] = []
|
const paths: string[] = []
|
||||||
|
|
@ -255,11 +266,45 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
||||||
|
const motion = (value: number) => ({
|
||||||
|
opacity: value,
|
||||||
|
transform: `scale(${0.95 + value * 0.05})`,
|
||||||
|
filter: `blur(${(1 - value) * 2}px)`,
|
||||||
|
"pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const),
|
||||||
|
})
|
||||||
|
const buttons = createMemo(() => motion(buttonsSpring()))
|
||||||
|
const shell = createMemo(() => motion(1 - buttonsSpring()))
|
||||||
|
const control = createMemo(() => ({ height: "28px", ...buttons() }))
|
||||||
|
|
||||||
const commentCount = createMemo(() => {
|
const commentCount = createMemo(() => {
|
||||||
if (store.mode === "shell") return 0
|
if (store.mode === "shell") return 0
|
||||||
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
||||||
})
|
})
|
||||||
|
const blank = createMemo(() => {
|
||||||
|
const text = prompt
|
||||||
|
.current()
|
||||||
|
.map((part) => ("content" in part ? part.content : ""))
|
||||||
|
.join("")
|
||||||
|
return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0
|
||||||
|
})
|
||||||
|
const stopping = createMemo(() => working() && blank())
|
||||||
|
const tip = () => {
|
||||||
|
if (stopping()) {
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{language.t("prompt.action.stop")}</span>
|
||||||
|
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{language.t("prompt.action.send")}</span>
|
||||||
|
<Icon name="enter" size="small" class="text-icon-base" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const contextItems = createMemo(() => {
|
const contextItems = createMemo(() => {
|
||||||
const items = prompt.context.items()
|
const items = prompt.context.items()
|
||||||
|
|
@ -390,7 +435,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFocused = createFocusSignal(() => editorRef)
|
|
||||||
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
|
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
|
||||||
|
|
||||||
const pick = () => fileInputRef?.click()
|
const pick = () => fileInputRef?.click()
|
||||||
|
|
@ -466,6 +510,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
return getCursorPosition(editorRef)
|
return getCursorPosition(editorRef)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const restoreFocus = () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const cursor = prompt.cursor() ?? promptLength(prompt.current())
|
||||||
|
editorRef.focus()
|
||||||
|
setCursorPosition(editorRef, cursor)
|
||||||
|
queueScroll()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const renderEditorWithCursor = (parts: Prompt) => {
|
const renderEditorWithCursor = (parts: Prompt) => {
|
||||||
const cursor = currentCursor()
|
const cursor = currentCursor()
|
||||||
renderEditor(parts)
|
renderEditor(parts)
|
||||||
|
|
@ -490,6 +543,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
setComposing(false)
|
setComposing(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCompositionStart = () => {
|
||||||
|
setComposing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCompositionEnd = () => {
|
||||||
|
setComposing(false)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (composing()) return
|
||||||
|
reconcile(prompt.current().filter((part) => part.type !== "image"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const agentList = createMemo(() =>
|
const agentList = createMemo(() =>
|
||||||
sync.data.agent
|
sync.data.agent
|
||||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||||
|
|
@ -523,6 +588,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
const open = recent()
|
const open = recent()
|
||||||
const seen = new Set(open)
|
const seen = new Set(open)
|
||||||
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
|
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
|
||||||
|
if (!query.trim()) return [...agents, ...pinned]
|
||||||
const paths = await files.searchFilesAndDirectories(query)
|
const paths = await files.searchFilesAndDirectories(query)
|
||||||
const fileOptions: AtOption[] = paths
|
const fileOptions: AtOption[] = paths
|
||||||
.filter((path) => !seen.has(path))
|
.filter((path) => !seen.has(path))
|
||||||
|
|
@ -573,18 +639,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
|
|
||||||
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
||||||
if (!cmd) return
|
if (!cmd) return
|
||||||
|
promptProbe.select(cmd.id)
|
||||||
closePopover()
|
closePopover()
|
||||||
|
const images = imageAttachments()
|
||||||
|
|
||||||
if (cmd.type === "custom") {
|
if (cmd.type === "custom") {
|
||||||
const text = `/${cmd.trigger} `
|
const text = `/${cmd.trigger} `
|
||||||
setEditorText(text)
|
setEditorText(text)
|
||||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length)
|
||||||
focusEditorEnd()
|
focusEditorEnd()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
clearEditor()
|
clearEditor()
|
||||||
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
prompt.set([...DEFAULT_PROMPT, ...images], 0)
|
||||||
command.trigger(cmd.id, "slash")
|
command.trigger(cmd.id, "slash")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -661,6 +729,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (promptEnabled()) {
|
||||||
|
createEffect(() => {
|
||||||
|
promptProbe.set({
|
||||||
|
popover: store.popover,
|
||||||
|
slash: {
|
||||||
|
active: slashActive() ?? null,
|
||||||
|
ids: slashFlat().map((cmd) => cmd.id),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => promptProbe.clear())
|
||||||
|
}
|
||||||
|
|
||||||
const selectPopoverActive = () => {
|
const selectPopoverActive = () => {
|
||||||
if (store.popover === "at") {
|
if (store.popover === "at") {
|
||||||
const items = atFlat()
|
const items = atFlat()
|
||||||
|
|
@ -680,24 +762,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reconcile = (input: Prompt) => {
|
||||||
|
if (mirror.input) {
|
||||||
|
mirror.input = false
|
||||||
|
if (isNormalizedEditor()) return
|
||||||
|
|
||||||
|
renderEditorWithCursor(input)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const dom = parseFromDOM()
|
||||||
|
if (isNormalizedEditor() && isPromptEqual(input, dom)) return
|
||||||
|
|
||||||
|
renderEditorWithCursor(input)
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => prompt.current(),
|
() => prompt.current(),
|
||||||
(currentParts) => {
|
(parts) => {
|
||||||
const inputParts = currentParts.filter((part) => part.type !== "image")
|
if (composing()) return
|
||||||
|
reconcile(parts.filter((part) => part.type !== "image"))
|
||||||
if (mirror.input) {
|
|
||||||
mirror.input = false
|
|
||||||
if (isNormalizedEditor()) return
|
|
||||||
|
|
||||||
renderEditorWithCursor(inputParts)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const domParts = parseFromDOM()
|
|
||||||
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
|
|
||||||
|
|
||||||
renderEditorWithCursor(inputParts)
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -921,6 +1006,45 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
setCurrentHistory("entries", next)
|
setCurrentHistory("entries", next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => props.edit?.id,
|
||||||
|
(id) => {
|
||||||
|
const edit = props.edit
|
||||||
|
if (!id || !edit) return
|
||||||
|
|
||||||
|
for (const item of prompt.context.items()) {
|
||||||
|
prompt.context.remove(item.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of edit.context) {
|
||||||
|
prompt.context.add({
|
||||||
|
type: item.type,
|
||||||
|
path: item.path,
|
||||||
|
selection: item.selection,
|
||||||
|
comment: item.comment,
|
||||||
|
commentID: item.commentID,
|
||||||
|
commentOrigin: item.commentOrigin,
|
||||||
|
preview: item.preview,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setStore("mode", "normal")
|
||||||
|
setStore("popover", null)
|
||||||
|
setStore("historyIndex", -1)
|
||||||
|
setStore("savedPrompt", null)
|
||||||
|
prompt.set(edit.prompt, promptLength(edit.prompt))
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
editorRef.focus()
|
||||||
|
setCursorPosition(editorRef, promptLength(edit.prompt))
|
||||||
|
queueScroll()
|
||||||
|
})
|
||||||
|
props.onEditLoaded?.()
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const navigateHistory = (direction: "up" | "down") => {
|
const navigateHistory = (direction: "up" | "down") => {
|
||||||
const result = navigatePromptHistory({
|
const result = navigatePromptHistory({
|
||||||
direction,
|
direction,
|
||||||
|
|
@ -937,9 +1061,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const { addImageAttachment, removeImageAttachment, handlePaste } = createPromptAttachments({
|
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||||
editor: () => editorRef,
|
editor: () => editorRef,
|
||||||
isFocused,
|
|
||||||
isDialogActive: () => !!dialog.active,
|
isDialogActive: () => !!dialog.active,
|
||||||
setDraggingType: (type) => setStore("draggingType", type),
|
setDraggingType: (type) => setStore("draggingType", type),
|
||||||
focusEditor: () => {
|
focusEditor: () => {
|
||||||
|
|
@ -957,14 +1080,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
return permission.isAutoAccepting(id, sdk.directory)
|
return permission.isAutoAccepting(id, sdk.directory)
|
||||||
})
|
})
|
||||||
|
|
||||||
const flip = () => {
|
|
||||||
if (!params.id) {
|
|
||||||
permission.toggleAutoAcceptDirectory(sdk.directory)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
permission.toggleAutoAccept(params.id, sdk.directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { abort, handleSubmit } = createPromptSubmit({
|
const { abort, handleSubmit } = createPromptSubmit({
|
||||||
info,
|
info,
|
||||||
imageAttachments,
|
imageAttachments,
|
||||||
|
|
@ -983,6 +1098,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
setPopover: (popover) => setStore("popover", popover),
|
setPopover: (popover) => setStore("popover", popover),
|
||||||
newSessionWorktree: () => props.newSessionWorktree,
|
newSessionWorktree: () => props.newSessionWorktree,
|
||||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||||
|
shouldQueue: props.shouldQueue,
|
||||||
|
onQueue: props.onQueue,
|
||||||
|
onAbort: props.onAbort,
|
||||||
onSubmit: props.onSubmit,
|
onSubmit: props.onSubmit,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1130,6 +1248,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
|
|
||||||
// Note: Shift+Enter is handled earlier, before IME check
|
// Note: Shift+Enter is handled earlier, before IME check
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (event.repeat) return
|
||||||
|
if (
|
||||||
|
working() &&
|
||||||
|
prompt
|
||||||
|
.current()
|
||||||
|
.map((part) => ("content" in part ? part.content : ""))
|
||||||
|
.join("")
|
||||||
|
.trim().length === 0 &&
|
||||||
|
imageAttachments().length === 0 &&
|
||||||
|
commentCount() === 0
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
handleSubmit(event)
|
handleSubmit(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1182,7 +1314,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
onOpen={(attachment) =>
|
onOpen={(attachment) =>
|
||||||
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
||||||
}
|
}
|
||||||
onRemove={removeImageAttachment}
|
onRemove={removeAttachment}
|
||||||
removeLabel={language.t("prompt.attachment.remove")}
|
removeLabel={language.t("prompt.attachment.remove")}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
|
@ -1190,17 +1322,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
const target = e.target
|
const target = e.target
|
||||||
if (!(target instanceof HTMLElement)) return
|
if (!(target instanceof HTMLElement)) return
|
||||||
if (
|
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
|
||||||
target.closest(
|
|
||||||
'[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]',
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
editorRef?.focus()
|
editorRef?.focus()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="relative max-h-[240px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
|
<div
|
||||||
|
class="relative max-h-[240px] overflow-y-auto no-scrollbar"
|
||||||
|
ref={(el) => (scrollRef = el)}
|
||||||
|
style={{ "scroll-padding-bottom": space }}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-input"
|
data-component="prompt-input"
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
|
|
@ -1214,84 +1346,71 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||||
spellcheck={store.mode === "normal"}
|
spellcheck={store.mode === "normal"}
|
||||||
|
inputMode="text"
|
||||||
|
// @ts-expect-error
|
||||||
|
autocomplete="off"
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onCompositionStart={() => setComposing(true)}
|
onCompositionStart={handleCompositionStart}
|
||||||
onCompositionEnd={() => setComposing(false)}
|
onCompositionEnd={handleCompositionEnd}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
classList={{
|
classList={{
|
||||||
"select-text": true,
|
"select-text": true,
|
||||||
"w-full pl-3 pr-2 pt-2 pb-[76px] text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
"w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||||
"[&_[data-type=file]]:text-syntax-property": true,
|
"[&_[data-type=file]]:text-syntax-property": true,
|
||||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||||
"font-mono!": store.mode === "shell",
|
"font-mono!": store.mode === "shell",
|
||||||
}}
|
}}
|
||||||
|
style={{ "padding-bottom": space }}
|
||||||
/>
|
/>
|
||||||
<Show when={!prompt.dirty()}>
|
<Show when={!prompt.dirty()}>
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-[76px] text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
||||||
classList={{ "font-mono!": store.mode === "shell" }}
|
classList={{ "font-mono!": store.mode === "shell" }}
|
||||||
|
style={{ "padding-bottom": space }}
|
||||||
>
|
>
|
||||||
{placeholder()}
|
{placeholder()}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="pointer-events-none absolute inset-x-0 bottom-0"
|
||||||
|
style={{
|
||||||
|
height: space,
|
||||||
|
background:
|
||||||
|
"linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
|
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
multiple
|
||||||
accept={ACCEPTED_FILE_TYPES.join(",")}
|
accept={ACCEPTED_FILE_TYPES.join(",")}
|
||||||
class="hidden"
|
class="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.currentTarget.files?.[0]
|
const list = e.currentTarget.files
|
||||||
if (file) addImageAttachment(file)
|
if (list) void addAttachments(Array.from(list))
|
||||||
e.currentTarget.value = ""
|
e.currentTarget.value = ""
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div class="flex items-center gap-1 pointer-events-auto">
|
||||||
aria-hidden={store.mode !== "normal"}
|
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
|
||||||
class="flex items-center gap-1"
|
|
||||||
style={{
|
|
||||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tooltip
|
|
||||||
placement="top"
|
|
||||||
inactive={!prompt.dirty() && !working()}
|
|
||||||
value={
|
|
||||||
<Switch>
|
|
||||||
<Match when={working()}>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{language.t("prompt.action.stop")}</span>
|
|
||||||
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{language.t("prompt.action.send")}</span>
|
|
||||||
<Icon name="enter" size="small" class="text-icon-base" />
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton
|
<IconButton
|
||||||
data-action="prompt-submit"
|
data-action="prompt-submit"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
|
disabled={store.mode !== "normal" || (!working() && blank())}
|
||||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||||
icon={working() ? "stop" : "arrow-up"}
|
icon={stopping() ? "stop" : "arrow-up"}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
class="size-8"
|
class="size-8"
|
||||||
style={{
|
style={buttons()}
|
||||||
opacity: buttonsSpring(),
|
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
|
||||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
|
||||||
}}
|
|
||||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1299,14 +1418,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
|
|
||||||
<div class="pointer-events-none absolute bottom-2 left-2">
|
<div class="pointer-events-none absolute bottom-2 left-2">
|
||||||
<div
|
<div
|
||||||
class="pointer-events-auto flex items-center gap-1.5"
|
aria-hidden={store.mode !== "normal"}
|
||||||
|
class="pointer-events-auto"
|
||||||
style={{
|
style={{
|
||||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
gutter={8}
|
|
||||||
title={language.t("prompt.action.attachFile")}
|
title={language.t("prompt.action.attachFile")}
|
||||||
keybind={command.keybind("file.attach")}
|
keybind={command.keybind("file.attach")}
|
||||||
>
|
>
|
||||||
|
|
@ -1315,11 +1434,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="size-8 p-0"
|
class="size-8 p-0"
|
||||||
style={{
|
style={buttons()}
|
||||||
opacity: buttonsSpring(),
|
|
||||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
|
||||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
|
||||||
}}
|
|
||||||
onClick={pick}
|
onClick={pick}
|
||||||
disabled={store.mode !== "normal"}
|
disabled={store.mode !== "normal"}
|
||||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||||
|
|
@ -1328,99 +1443,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
<Icon name="plus" class="size-4.5" />
|
<Icon name="plus" class="size-4.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
|
|
||||||
<Show
|
|
||||||
when={providers.paid().length > 0}
|
|
||||||
fallback={
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={8}
|
|
||||||
title={language.t("command.model.choose")}
|
|
||||||
keybind={command.keybind("model.choose")}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
as="div"
|
|
||||||
variant="ghost"
|
|
||||||
size="small"
|
|
||||||
class="min-w-0 max-w-[240px] text-13-regular text-text-base group"
|
|
||||||
style={{
|
|
||||||
opacity: buttonsSpring(),
|
|
||||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
|
||||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
|
||||||
}}
|
|
||||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
|
||||||
>
|
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
|
||||||
<ProviderIcon
|
|
||||||
id={local.model.current()!.provider.id}
|
|
||||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
|
||||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<span class="truncate">
|
|
||||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
|
||||||
</span>
|
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={8}
|
|
||||||
title={language.t("command.model.choose")}
|
|
||||||
keybind={command.keybind("model.choose")}
|
|
||||||
>
|
|
||||||
<ModelSelectorPopover
|
|
||||||
triggerAs={Button}
|
|
||||||
triggerProps={{
|
|
||||||
variant: "ghost",
|
|
||||||
size: "small",
|
|
||||||
style: {
|
|
||||||
opacity: buttonsSpring(),
|
|
||||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
|
||||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
|
||||||
},
|
|
||||||
class: "min-w-0 max-w-[240px] text-13-regular text-text-base group",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
|
||||||
<ProviderIcon
|
|
||||||
id={local.model.current()!.provider.id}
|
|
||||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
|
||||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<span class="truncate">
|
|
||||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
|
||||||
</span>
|
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
|
||||||
</ModelSelectorPopover>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={8}
|
|
||||||
title={language.t("command.model.variant.cycle")}
|
|
||||||
keybind={command.keybind("model.variant.cycle")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
size="small"
|
|
||||||
options={variants()}
|
|
||||||
current={local.model.variant.current() ?? "default"}
|
|
||||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
|
||||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
|
||||||
class="capitalize max-w-[160px]"
|
|
||||||
valueClass="truncate text-13-regular text-text-base"
|
|
||||||
triggerStyle={{
|
|
||||||
opacity: buttonsSpring(),
|
|
||||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
|
||||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
|
||||||
}}
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1433,106 +1455,134 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
|
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
|
||||||
style={{
|
style={{
|
||||||
padding: "0 4px 0 8px",
|
padding: "0 4px 0 8px",
|
||||||
opacity: 1 - buttonsSpring(),
|
...shell(),
|
||||||
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
|
|
||||||
filter: `blur(${buttonsSpring() * 2}px)`,
|
|
||||||
"pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||||
<div class="size-4 shrink-0" />
|
<div class="size-4 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
<TooltipKeybind
|
<div data-component="prompt-agent-control">
|
||||||
placement="top"
|
|
||||||
gutter={4}
|
|
||||||
title={language.t("command.agent.cycle")}
|
|
||||||
keybind={command.keybind("agent.cycle")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
size="normal"
|
|
||||||
options={agentNames()}
|
|
||||||
current={local.agent.current()?.name ?? ""}
|
|
||||||
onSelect={local.agent.set}
|
|
||||||
class="capitalize max-w-[160px]"
|
|
||||||
valueClass="truncate text-13-regular text-text-base"
|
|
||||||
triggerStyle={{
|
|
||||||
height: "28px",
|
|
||||||
opacity: buttonsSpring(),
|
|
||||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
|
||||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
|
||||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
|
||||||
}}
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
</TooltipKeybind>
|
|
||||||
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={4}
|
|
||||||
title={language.t(
|
|
||||||
accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable",
|
|
||||||
)}
|
|
||||||
keybind={command.keybind("permissions.autoaccept")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
size="normal"
|
|
||||||
options={["default", "autoaccept"] as const}
|
|
||||||
current={accepting() ? "autoaccept" : "default"}
|
|
||||||
label={(x) =>
|
|
||||||
x === "autoaccept"
|
|
||||||
? language.t("command.permissions.autoaccept.enable")
|
|
||||||
: `${language.t("common.default")} ${language.t("command.category.permissions")}`
|
|
||||||
}
|
|
||||||
onSelect={(x) => {
|
|
||||||
if (!x) return
|
|
||||||
if (x === "autoaccept" && accepting()) return
|
|
||||||
if (x === "default" && !accepting()) return
|
|
||||||
flip()
|
|
||||||
}}
|
|
||||||
class="max-w-[220px]"
|
|
||||||
valueClass="truncate text-13-regular text-text-base"
|
|
||||||
triggerStyle={{
|
|
||||||
height: "28px",
|
|
||||||
opacity: buttonsSpring(),
|
|
||||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
|
||||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
|
||||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
|
||||||
}}
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="shrink-0">
|
|
||||||
<RadioGroup
|
|
||||||
options={["shell", "normal"] as const}
|
|
||||||
current={store.mode}
|
|
||||||
value={(mode) => mode}
|
|
||||||
label={(mode) => (
|
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
openDelay={2000}
|
title={language.t("command.agent.cycle")}
|
||||||
title={language.t(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
|
keybind={command.keybind("agent.cycle")}
|
||||||
keybind={command.keybind(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
|
|
||||||
class="size-full flex items-center justify-center"
|
|
||||||
>
|
>
|
||||||
<Icon
|
<Select
|
||||||
name={mode === "shell" ? "console" : "prompt"}
|
size="normal"
|
||||||
class="size-[18px]"
|
options={agentNames()}
|
||||||
classList={{
|
current={local.agent.current()?.name ?? ""}
|
||||||
"text-icon-strong-base": store.mode === mode,
|
onSelect={(value) => {
|
||||||
"text-icon-weak": store.mode !== mode,
|
local.agent.set(value)
|
||||||
|
restoreFocus()
|
||||||
}}
|
}}
|
||||||
|
class="capitalize max-w-[160px] text-text-base"
|
||||||
|
valueClass="truncate text-13-regular text-text-base"
|
||||||
|
triggerStyle={control()}
|
||||||
|
triggerProps={{ "data-action": "prompt-agent" }}
|
||||||
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
)}
|
</div>
|
||||||
onSelect={(mode) => mode && setMode(mode)}
|
<Show when={store.mode !== "shell"}>
|
||||||
fill
|
<div data-component="prompt-model-control">
|
||||||
pad="none"
|
<Show
|
||||||
class="w-[68px]"
|
when={providers.paid().length > 0}
|
||||||
/>
|
fallback={
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
title={language.t("command.model.choose")}
|
||||||
|
keybind={command.keybind("model.choose")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
data-action="prompt-model"
|
||||||
|
as="div"
|
||||||
|
variant="ghost"
|
||||||
|
size="normal"
|
||||||
|
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||||
|
style={control()}
|
||||||
|
onClick={() => {
|
||||||
|
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
|
<ProviderIcon
|
||||||
|
id={local.model.current()?.provider?.id ?? ""}
|
||||||
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<span class="truncate">
|
||||||
|
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
title={language.t("command.model.choose")}
|
||||||
|
keybind={command.keybind("model.choose")}
|
||||||
|
>
|
||||||
|
<ModelSelectorPopover
|
||||||
|
model={local.model}
|
||||||
|
triggerAs={Button}
|
||||||
|
triggerProps={{
|
||||||
|
variant: "ghost",
|
||||||
|
size: "normal",
|
||||||
|
style: control(),
|
||||||
|
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
||||||
|
"data-action": "prompt-model",
|
||||||
|
}}
|
||||||
|
onClose={restoreFocus}
|
||||||
|
>
|
||||||
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
|
<ProviderIcon
|
||||||
|
id={local.model.current()?.provider?.id ?? ""}
|
||||||
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<span class="truncate">
|
||||||
|
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
|
</ModelSelectorPopover>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div data-component="prompt-variant-control">
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
title={language.t("command.model.variant.cycle")}
|
||||||
|
keybind={command.keybind("model.variant.cycle")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
size="normal"
|
||||||
|
options={variants()}
|
||||||
|
current={local.model.variant.current() ?? "default"}
|
||||||
|
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||||
|
onSelect={(value) => {
|
||||||
|
local.model.variant.set(value === "default" ? undefined : value)
|
||||||
|
restoreFocus()
|
||||||
|
}}
|
||||||
|
class="capitalize max-w-[160px] text-text-base"
|
||||||
|
valueClass="truncate text-13-regular text-text-base"
|
||||||
|
triggerStyle={control()}
|
||||||
|
triggerProps={{ "data-action": "prompt-model-variant" }}
|
||||||
|
variant="ghost"
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DockTray>
|
</DockTray>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { attachmentMime } from "./files"
|
||||||
|
import { pasteMode } from "./paste"
|
||||||
|
|
||||||
|
describe("attachmentMime", () => {
|
||||||
|
test("keeps PDFs when the browser reports the mime", async () => {
|
||||||
|
const file = new File(["%PDF-1.7"], "guide.pdf", { type: "application/pdf" })
|
||||||
|
expect(await attachmentMime(file)).toBe("application/pdf")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("normalizes structured text types to text/plain", async () => {
|
||||||
|
const file = new File(['{"ok":true}\n'], "data.json", { type: "application/json" })
|
||||||
|
expect(await attachmentMime(file)).toBe("text/plain")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("accepts text files even with a misleading browser mime", async () => {
|
||||||
|
const file = new File(["export const x = 1\n"], "main.ts", { type: "video/mp2t" })
|
||||||
|
expect(await attachmentMime(file)).toBe("text/plain")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects binary files", async () => {
|
||||||
|
const file = new File([Uint8Array.of(0, 255, 1, 2)], "blob.bin", { type: "application/octet-stream" })
|
||||||
|
expect(await attachmentMime(file)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("pasteMode", () => {
|
||||||
|
test("uses native paste for short single-line text", () => {
|
||||||
|
expect(pasteMode("hello world")).toBe("native")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses manual paste for multiline text", () => {
|
||||||
|
expect(
|
||||||
|
pasteMode(`{
|
||||||
|
"ok": true
|
||||||
|
}`),
|
||||||
|
).toBe("manual")
|
||||||
|
expect(pasteMode("a\r\nb")).toBe("manual")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses manual paste for large text", () => {
|
||||||
|
expect(pasteMode("x".repeat(8000))).toBe("manual")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,29 +1,32 @@
|
||||||
import { onCleanup, onMount } from "solid-js"
|
import { onMount } from "solid-js"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
|
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { uuid } from "@/utils/uuid"
|
import { uuid } from "@/utils/uuid"
|
||||||
import { getCursorPosition } from "./editor-dom"
|
import { getCursorPosition } from "./editor-dom"
|
||||||
|
import { attachmentMime } from "./files"
|
||||||
|
import { normalizePaste, pasteMode } from "./paste"
|
||||||
|
|
||||||
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
function dataUrl(file: File, mime: string) {
|
||||||
export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
return new Promise<string>((resolve) => {
|
||||||
const LARGE_PASTE_CHARS = 8000
|
const reader = new FileReader()
|
||||||
const LARGE_PASTE_BREAKS = 120
|
reader.addEventListener("error", () => resolve(""))
|
||||||
|
reader.addEventListener("load", () => {
|
||||||
function largePaste(text: string) {
|
const value = typeof reader.result === "string" ? reader.result : ""
|
||||||
if (text.length >= LARGE_PASTE_CHARS) return true
|
const idx = value.indexOf(",")
|
||||||
let breaks = 0
|
if (idx === -1) {
|
||||||
for (const char of text) {
|
resolve(value)
|
||||||
if (char !== "\n") continue
|
return
|
||||||
breaks += 1
|
}
|
||||||
if (breaks >= LARGE_PASTE_BREAKS) return true
|
resolve(`data:${mime};base64,${value.slice(idx + 1)}`)
|
||||||
}
|
})
|
||||||
return false
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type PromptAttachmentsInput = {
|
type PromptAttachmentsInput = {
|
||||||
editor: () => HTMLDivElement | undefined
|
editor: () => HTMLDivElement | undefined
|
||||||
isFocused: () => boolean
|
|
||||||
isDialogActive: () => boolean
|
isDialogActive: () => boolean
|
||||||
setDraggingType: (type: "image" | "@mention" | null) => void
|
setDraggingType: (type: "image" | "@mention" | null) => void
|
||||||
focusEditor: () => void
|
focusEditor: () => void
|
||||||
|
|
@ -35,58 +38,73 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const addImageAttachment = async (file: File) => {
|
const warn = () => {
|
||||||
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
|
showToast({
|
||||||
|
title: language.t("prompt.toast.pasteUnsupported.title"),
|
||||||
const reader = new FileReader()
|
description: language.t("prompt.toast.pasteUnsupported.description"),
|
||||||
reader.onload = () => {
|
})
|
||||||
const editor = input.editor()
|
|
||||||
if (!editor) return
|
|
||||||
const dataUrl = reader.result as string
|
|
||||||
const attachment: ImageAttachmentPart = {
|
|
||||||
type: "image",
|
|
||||||
id: uuid(),
|
|
||||||
filename: file.name,
|
|
||||||
mime: file.type,
|
|
||||||
dataUrl,
|
|
||||||
}
|
|
||||||
const cursorPosition = prompt.cursor() ?? getCursorPosition(editor)
|
|
||||||
prompt.set([...prompt.current(), attachment], cursorPosition)
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeImageAttachment = (id: string) => {
|
const add = async (file: File, toast = true) => {
|
||||||
|
const mime = await attachmentMime(file)
|
||||||
|
if (!mime) {
|
||||||
|
if (toast) warn()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = input.editor()
|
||||||
|
if (!editor) return false
|
||||||
|
|
||||||
|
const url = await dataUrl(file, mime)
|
||||||
|
if (!url) return false
|
||||||
|
|
||||||
|
const attachment: ImageAttachmentPart = {
|
||||||
|
type: "image",
|
||||||
|
id: uuid(),
|
||||||
|
filename: file.name,
|
||||||
|
mime,
|
||||||
|
dataUrl: url,
|
||||||
|
}
|
||||||
|
const cursor = prompt.cursor() ?? getCursorPosition(editor)
|
||||||
|
prompt.set([...prompt.current(), attachment], cursor)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const addAttachment = (file: File) => add(file)
|
||||||
|
|
||||||
|
const addAttachments = async (files: File[], toast = true) => {
|
||||||
|
let found = false
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const ok = await add(file, false)
|
||||||
|
if (ok) found = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found && files.length > 0 && toast) warn()
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeAttachment = (id: string) => {
|
||||||
const current = prompt.current()
|
const current = prompt.current()
|
||||||
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
||||||
prompt.set(next, prompt.cursor())
|
prompt.set(next, prompt.cursor())
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePaste = async (event: ClipboardEvent) => {
|
const handlePaste = async (event: ClipboardEvent) => {
|
||||||
if (!input.isFocused()) return
|
|
||||||
const clipboardData = event.clipboardData
|
const clipboardData = event.clipboardData
|
||||||
if (!clipboardData) return
|
if (!clipboardData) return
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
||||||
const items = Array.from(clipboardData.items)
|
const files = Array.from(clipboardData.items).flatMap((item) => {
|
||||||
const fileItems = items.filter((item) => item.kind === "file")
|
if (item.kind !== "file") return []
|
||||||
const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
|
const file = item.getAsFile()
|
||||||
|
return file ? [file] : []
|
||||||
|
})
|
||||||
|
|
||||||
if (imageItems.length > 0) {
|
if (files.length > 0) {
|
||||||
for (const item of imageItems) {
|
await addAttachments(files)
|
||||||
const file = item.getAsFile()
|
|
||||||
if (file) await addImageAttachment(file)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileItems.length > 0) {
|
|
||||||
showToast({
|
|
||||||
title: language.t("prompt.toast.pasteUnsupported.title"),
|
|
||||||
description: language.t("prompt.toast.pasteUnsupported.description"),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,23 +114,30 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||||
if (input.readClipboardImage && !plainText) {
|
if (input.readClipboardImage && !plainText) {
|
||||||
const file = await input.readClipboardImage()
|
const file = await input.readClipboardImage()
|
||||||
if (file) {
|
if (file) {
|
||||||
await addImageAttachment(file)
|
await addAttachment(file)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!plainText) return
|
if (!plainText) return
|
||||||
|
|
||||||
if (largePaste(plainText)) {
|
const text = normalizePaste(plainText)
|
||||||
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
|
|
||||||
|
const put = () => {
|
||||||
|
if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
|
||||||
input.focusEditor()
|
input.focusEditor()
|
||||||
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
|
return input.addPart({ type: "text", content: text, start: 0, end: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
|
if (pasteMode(text) === "manual") {
|
||||||
|
put()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text)
|
||||||
if (inserted) return
|
if (inserted) return
|
||||||
|
|
||||||
input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
put()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGlobalDragOver = (event: DragEvent) => {
|
const handleGlobalDragOver = (event: DragEvent) => {
|
||||||
|
|
@ -153,28 +178,19 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
||||||
const dropped = event.dataTransfer?.files
|
const dropped = event.dataTransfer?.files
|
||||||
if (!dropped) return
|
if (!dropped) return
|
||||||
|
|
||||||
for (const file of Array.from(dropped)) {
|
await addAttachments(Array.from(dropped))
|
||||||
if (ACCEPTED_FILE_TYPES.includes(file.type)) {
|
|
||||||
await addImageAttachment(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
document.addEventListener("dragover", handleGlobalDragOver)
|
makeEventListener(document, "dragover", handleGlobalDragOver)
|
||||||
document.addEventListener("dragleave", handleGlobalDragLeave)
|
makeEventListener(document, "dragleave", handleGlobalDragLeave)
|
||||||
document.addEventListener("drop", handleGlobalDrop)
|
makeEventListener(document, "drop", handleGlobalDrop)
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
document.removeEventListener("dragover", handleGlobalDragOver)
|
|
||||||
document.removeEventListener("dragleave", handleGlobalDragLeave)
|
|
||||||
document.removeEventListener("drop", handleGlobalDrop)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
addImageAttachment,
|
addAttachment,
|
||||||
removeImageAttachment,
|
addAttachments,
|
||||||
|
removeAttachment,
|
||||||
handlePaste,
|
handlePaste,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,32 @@ describe("buildRequestParts", () => {
|
||||||
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps multiple uploaded attachments in order", () => {
|
||||||
|
const result = buildRequestParts({
|
||||||
|
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
|
||||||
|
context: [],
|
||||||
|
images: [
|
||||||
|
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
id: "img_2",
|
||||||
|
filename: "b.pdf",
|
||||||
|
mime: "application/pdf",
|
||||||
|
dataUrl: "data:application/pdf;base64,BBB",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
text: "check these",
|
||||||
|
messageID: "msg_multi",
|
||||||
|
sessionID: "ses_multi",
|
||||||
|
sessionDirectory: "/repo",
|
||||||
|
})
|
||||||
|
|
||||||
|
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
|
||||||
|
|
||||||
|
expect(files).toHaveLength(2)
|
||||||
|
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
|
||||||
|
})
|
||||||
|
|
||||||
test("deduplicates context files when prompt already includes same path", () => {
|
test("deduplicates context files when prompt already includes same path", () => {
|
||||||
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
||||||
|
|
||||||
|
|
@ -74,6 +100,30 @@ describe("buildRequestParts", () => {
|
||||||
expect(synthetic).toHaveLength(1)
|
expect(synthetic).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("adds file parts for @mentions inside comment text", () => {
|
||||||
|
const result = buildRequestParts({
|
||||||
|
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
|
||||||
|
context: [
|
||||||
|
{
|
||||||
|
key: "ctx:comment-mention",
|
||||||
|
type: "file",
|
||||||
|
path: "src/review.ts",
|
||||||
|
comment: "Compare with @src/shared.ts and @src/review.ts.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
images: [],
|
||||||
|
text: "look",
|
||||||
|
messageID: "msg_comment_mentions",
|
||||||
|
sessionID: "ses_comment_mentions",
|
||||||
|
sessionDirectory: "/repo",
|
||||||
|
})
|
||||||
|
|
||||||
|
const files = result.requestParts.filter((part) => part.type === "file")
|
||||||
|
expect(files).toHaveLength(2)
|
||||||
|
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
|
||||||
|
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
test("handles Windows paths correctly (simulated on macOS)", () => {
|
test("handles Windows paths correctly (simulated on macOS)", () => {
|
||||||
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,16 @@ const absolute = (directory: string, path: string) => {
|
||||||
const fileQuery = (selection: FileSelection | undefined) =>
|
const fileQuery = (selection: FileSelection | undefined) =>
|
||||||
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
||||||
|
|
||||||
|
const mention = /(^|[\s([{"'])@(\S+)/g
|
||||||
|
|
||||||
|
const parseCommentMentions = (comment: string) => {
|
||||||
|
return Array.from(comment.matchAll(mention)).flatMap((match) => {
|
||||||
|
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
|
||||||
|
if (!path) return []
|
||||||
|
return [path]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||||
|
|
||||||
|
|
@ -138,6 +148,21 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||||
|
|
||||||
if (!comment) return [filePart]
|
if (!comment) return [filePart]
|
||||||
|
|
||||||
|
const mentions = parseCommentMentions(comment).flatMap((path) => {
|
||||||
|
const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
|
||||||
|
if (used.has(url)) return []
|
||||||
|
used.add(url)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
type: "file",
|
||||||
|
mime: "text/plain",
|
||||||
|
url,
|
||||||
|
filename: getFilename(path),
|
||||||
|
} satisfies PromptRequestPart,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: Identifier.ascending("part"),
|
id: Identifier.ascending("part"),
|
||||||
|
|
@ -153,6 +178,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||||
}),
|
}),
|
||||||
} satisfies PromptRequestPart,
|
} satisfies PromptRequestPart,
|
||||||
filePart,
|
filePart,
|
||||||
|
...mentions,
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker"
|
||||||
|
|
||||||
|
export { ACCEPTED_FILE_TYPES }
|
||||||
|
|
||||||
|
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
|
||||||
|
const IMAGE_EXTS = new Map([
|
||||||
|
["gif", "image/gif"],
|
||||||
|
["jpeg", "image/jpeg"],
|
||||||
|
["jpg", "image/jpeg"],
|
||||||
|
["png", "image/png"],
|
||||||
|
["webp", "image/webp"],
|
||||||
|
])
|
||||||
|
const TEXT_MIMES = new Set([
|
||||||
|
"application/json",
|
||||||
|
"application/ld+json",
|
||||||
|
"application/toml",
|
||||||
|
"application/x-toml",
|
||||||
|
"application/x-yaml",
|
||||||
|
"application/xml",
|
||||||
|
"application/yaml",
|
||||||
|
])
|
||||||
|
|
||||||
|
const SAMPLE = 4096
|
||||||
|
|
||||||
|
function kind(type: string) {
|
||||||
|
return type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function ext(name: string) {
|
||||||
|
const idx = name.lastIndexOf(".")
|
||||||
|
if (idx === -1) return ""
|
||||||
|
return name.slice(idx + 1).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function textMime(type: string) {
|
||||||
|
if (!type) return false
|
||||||
|
if (type.startsWith("text/")) return true
|
||||||
|
if (TEXT_MIMES.has(type)) return true
|
||||||
|
if (type.endsWith("+json")) return true
|
||||||
|
return type.endsWith("+xml")
|
||||||
|
}
|
||||||
|
|
||||||
|
function textBytes(bytes: Uint8Array) {
|
||||||
|
if (bytes.length === 0) return true
|
||||||
|
let count = 0
|
||||||
|
for (const byte of bytes) {
|
||||||
|
if (byte === 0) return false
|
||||||
|
if (byte < 9 || (byte > 13 && byte < 32)) count += 1
|
||||||
|
}
|
||||||
|
return count / bytes.length <= 0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function attachmentMime(file: File) {
|
||||||
|
const type = kind(file.type)
|
||||||
|
if (IMAGE_MIMES.has(type)) return type
|
||||||
|
if (type === "application/pdf") return type
|
||||||
|
|
||||||
|
const suffix = ext(file.name)
|
||||||
|
const fallback = IMAGE_EXTS.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined)
|
||||||
|
if ((!type || type === "application/octet-stream") && fallback) return fallback
|
||||||
|
|
||||||
|
if (textMime(type)) return "text/plain"
|
||||||
|
const bytes = new Uint8Array(await file.slice(0, SAMPLE).arrayBuffer())
|
||||||
|
if (!textBytes(bytes)) return
|
||||||
|
return "text/plain"
|
||||||
|
}
|
||||||
|
|
@ -126,7 +126,7 @@ describe("prompt-input history", () => {
|
||||||
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
|
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
|
||||||
const value = "a\nb\nc"
|
const value = "a\nb\nc"
|
||||||
|
|
||||||
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
|
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false)
|
||||||
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
|
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
|
||||||
|
|
||||||
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
|
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
|
||||||
|
|
@ -135,11 +135,14 @@ describe("prompt-input history", () => {
|
||||||
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
|
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
|
||||||
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
|
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
|
||||||
|
|
||||||
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true)
|
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false)
|
||||||
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
|
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
|
||||||
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
|
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
|
||||||
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
|
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
|
||||||
|
|
||||||
|
expect(canNavigateHistoryAtCursor("up", "", 0)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", "", 0)).toBe(true)
|
||||||
|
|
||||||
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
|
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
|
||||||
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
|
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
|
||||||
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
|
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue