Compare commits
519 commits
provider-c
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89130db6b0 | ||
|
|
9465a6c97d | ||
|
|
53fd02970d | ||
|
|
6ff0adef22 |
||
|
|
3307f99f1f |
||
|
|
8de7a3c739 | ||
|
|
28d0637406 | ||
|
|
ef7f712867 | ||
|
|
3016830e25 |
||
|
|
e9e7472456 |
||
|
|
9f9fbf815c |
||
|
|
c88facb2cc | ||
|
|
d4b85f8d83 |
||
|
|
1882c33827 | ||
|
|
01624c8c87 | ||
|
|
0891ecd8e0 |
||
|
|
32f278b48f | ||
|
|
bb6bec9070 | ||
|
|
0eac0964af | ||
|
|
f67e80c275 |
||
|
|
19231fce4b | ||
|
|
d0a7ca6d1b | ||
|
|
d80e86be9d | ||
|
|
e4bd9757a3 | ||
|
|
4087cf1a97 |
||
|
|
14f0bf64a1 | ||
|
|
2df47ee394 | ||
|
|
ad82616445 | ||
|
|
d4ad650f73 | ||
|
|
2039c90c06 |
||
|
|
da59457ca4 |
||
|
|
db4dbaa289 |
||
|
|
d3f30df180 |
||
|
|
7ea343cabd |
||
|
|
e024e2ef92 |
||
|
|
ceb4890ca3 |
||
|
|
3f239b3834 | ||
|
|
c1ee3c6e34 |
||
|
|
a1ab489e61 |
||
|
|
8c38d260eb | ||
|
|
a4f25a94b4 |
||
|
|
ff0382e971 | ||
|
|
ac5380614f |
||
|
|
c08350b83e | ||
|
|
c5cf416633 |
||
|
|
3dbce3dade |
||
|
|
a277557a5f | ||
|
|
005bf088c9 |
||
|
|
eca5e68a5e |
||
|
|
5b11635d97 |
||
|
|
a9eda2e99e |
||
|
|
73009f0b09 |
||
|
|
f720490219 | ||
|
|
341c64cc97 |
||
|
|
1e17856ba4 | ||
|
|
a6f7fe739c |
||
|
|
02f50a6e80 |
||
|
|
7565e03536 | ||
|
|
035a5a1a67 |
||
|
|
e8b0992788 |
||
|
|
8cbea4fbb7 | ||
|
|
f256a4c538 |
||
|
|
a45c2b917e | ||
|
|
f28d72d15e | ||
|
|
d8a9d8a76d | ||
|
|
982a9044c5 |
||
|
|
7edefb3347 |
||
|
|
017a5977d2 |
||
|
|
7336cc5a7b | ||
|
|
e1587588c4 | ||
|
|
be93cec9ba |
||
|
|
7c898249a2 |
||
|
|
9e432a6785 |
||
|
|
1ead8d84a2 |
||
|
|
8c81f9a40b |
||
|
|
95636bd3ca |
||
|
|
7dae9a1083 | ||
|
|
d46be565aa |
||
|
|
8021dbd80f | ||
|
|
c8487bac54 | ||
|
|
484f00ebf4 |
||
|
|
c3be6c4965 |
||
|
|
172d08cb98 | ||
|
|
5e39051183 |
||
|
|
d50b9e8f9a | ||
|
|
921b1c6a34 |
||
|
|
9c8060d96d |
||
|
|
87db7a7f0a |
||
|
|
237e694df0 |
||
|
|
3f9dad3fd0 | ||
|
|
3cc70160de | ||
|
|
b72d7b6fa2 |
||
|
|
029f5aa3a0 |
||
|
|
284e97068c |
||
|
|
970c38257c | ||
|
|
c39ad38424 |
||
|
|
91508f6c88 | ||
|
|
1274408b89 |
||
|
|
7ae1be5bf6 |
||
|
|
40e4d730ca |
||
|
|
8dc539b4ae |
||
|
|
8e8d38c3cd |
||
|
|
a9bebd3653 |
||
|
|
78f5764319 |
||
|
|
55b0211690 |
||
|
|
4f62295e44 |
||
|
|
e1c41941a9 |
||
|
|
0b4edfc64c | ||
|
|
4469cecc4e |
||
|
|
b06768aa8e |
||
|
|
bc2d3df05f | ||
|
|
35075bb466 |
||
|
|
a9afed0515 |
||
|
|
09afffeead |
||
|
|
de78da8a23 | ||
|
|
9ca2b44235 |
||
|
|
7ffc22c0ef | ||
|
|
759f6afa80 |
||
|
|
1ad63cfc6c | ||
|
|
7d8195e8fe |
||
|
|
7534d23551 | ||
|
|
0a6637e17a |
||
|
|
2b2b69d668 |
||
|
|
9e8b2171a5 |
||
|
|
5e2a6257b2 | ||
|
|
065dc274ec |
||
|
|
a85d8d23aa | ||
|
|
2b2aacc939 |
||
|
|
7840562d1b |
||
|
|
53669cab2b | ||
|
|
4b19ea2a71 |
||
|
|
553b42fcc7 | ||
|
|
e62b09e6fc | ||
|
|
f51665191a |
||
|
|
e63996919b |
||
|
|
909db63265 |
||
|
|
66495a2a22 | ||
|
|
9ba82a1b8c |
||
|
|
b62806683e |
||
|
|
ae4be983cb | ||
|
|
ad78ef5a4c |
||
|
|
67a04787bf |
||
|
|
aaa42fe3bf |
||
|
|
3337495427 | ||
|
|
80a4fe8f39 |
||
|
|
91ed2567ef |
||
|
|
c4545ab12f | ||
|
|
57ddfeb756 |
||
|
|
ce7f54d5e7 |
||
|
|
bce2992729 | ||
|
|
3819848cf2 |
||
|
|
55f4a2691a |
||
|
|
a48912cbb1 |
||
|
|
2ea4bb793e | ||
|
|
d07323ef59 |
||
|
|
5ce89dc2ad | ||
|
|
589ef16128 |
||
|
|
386afb77e0 | ||
|
|
29af2e39ff |
||
|
|
090a26a301 | ||
|
|
ce9a875181 |
||
|
|
db88c42335 |
||
|
|
adba484df4 | ||
|
|
37c263e153 |
||
|
|
204f48de8b |
||
|
|
743f6410f2 | ||
|
|
20589d66d5 |
||
|
|
62e4641235 | ||
|
|
e59ba24b80 |
||
|
|
347510a73b | ||
|
|
d03e0c5e54 |
||
|
|
84c79c1399 | ||
|
|
e45210c6d2 |
||
|
|
92cede0541 | ||
|
|
fada1a538f |
||
|
|
542ba88602 |
||
|
|
411eff73f0 |
||
|
|
50eee1f5a4 |
||
|
|
0a601cf334 |
||
|
|
c9db6e9a1f |
||
|
|
130038eb63 |
||
|
|
4438f69aac | ||
|
|
76ced5418f | ||
|
|
c40e3e74a7 | ||
|
|
0317531906 | ||
|
|
5f241f1cc1 | ||
|
|
b513fafe83 |
||
|
|
21c4c93770 |
||
|
|
abc4b83f58 | ||
|
|
cb562b2c62 | ||
|
|
2d2339d37d |
||
|
|
849c2598ab |
||
|
|
3033afba51 |
||
|
|
5a8ee27254 |
||
|
|
4cc022481c |
||
|
|
d36a2d8981 | ||
|
|
96ff82a11f |
||
|
|
4872c48c23 |
||
|
|
47fc6f2991 |
||
|
|
da312b009c |
||
|
|
70535a1bad | ||
|
|
43e472bba7 |
||
|
|
b67fda133a | ||
|
|
e2531dff0f |
||
|
|
cc3615b535 |
||
|
|
5542415b64 |
||
|
|
8bec40a816 |
||
|
|
4a81e8392b |
||
|
|
a19b52e85b |
||
|
|
9105f35966 | ||
|
|
3ba3954aa0 |
||
|
|
3fc5af6dd1 |
||
|
|
5586f9675e |
||
|
|
3b719aba48 | ||
|
|
20a3a2138e |
||
|
|
cd46f22d51 | ||
|
|
cfa134f042 | ||
|
|
61653a20a5 | ||
|
|
2a097f3af7 |
||
|
|
ba4b8e21f4 |
||
|
|
7985c2066a |
||
|
|
67caf894e0 |
||
|
|
78587c141b |
||
|
|
f5573281ca | ||
|
|
4817228f96 |
||
|
|
5a5117b36c | ||
|
|
6a288426b8 | ||
|
|
573ac85983 |
||
|
|
1221bbbe14 | ||
|
|
6bea0f4fa4 | ||
|
|
cc34084dfe |
||
|
|
b8142c7aa8 | ||
|
|
ba3f966ed0 |
||
|
|
b95fe7b2d7 |
||
|
|
9da9ed9de6 |
||
|
|
fab2133129 |
||
|
|
901c9e7329 |
||
|
|
3476e6baa5 | ||
|
|
86e04d4174 |
||
|
|
45cd8d7692 | ||
|
|
a46374e90e | ||
|
|
41405c9eb0 | ||
|
|
49d2dd8a38 |
||
|
|
0df2f6245a | ||
|
|
38989c57a0 |
||
|
|
69a80663a2 |
||
|
|
518b0bf9e3 | ||
|
|
be08207a88 |
||
|
|
efb6cc2d4b |
||
|
|
4bffbb655f | ||
|
|
ba6cf38607 |
||
|
|
86978e7a8c |
||
|
|
82a3270cf2 | ||
|
|
c0a258b22a |
||
|
|
ed926be253 |
||
|
|
b527f605d9 |
||
|
|
aca52ba198 | ||
|
|
f7d5a1cbc7 |
||
|
|
3238daa851 | ||
|
|
90291fc6df | ||
|
|
a414e06d0c | ||
|
|
52f65f3a0b |
||
|
|
3a1c6df9e2 |
||
|
|
4436678f7d |
||
|
|
08fb473735 | ||
|
|
51213520f5 | ||
|
|
0bc9a28b5e | ||
|
|
1d2a7b4c86 |
||
|
|
453b61e27b |
||
|
|
95ebf50ace |
||
|
|
d8a1b932eb |
||
|
|
0c91e6a620 |
||
|
|
ef3b673084 | ||
|
|
c69abee0c7 | ||
|
|
dc3ff4b7aa |
||
|
|
958e08e109 |
||
|
|
590a11ed98 |
||
|
|
17544802c3 |
||
|
|
4a760b5743 |
||
|
|
888c4cb504 |
||
|
|
4394b324c9 | ||
|
|
a27ffb20f0 |
||
|
|
81141dc562 | ||
|
|
285d315b4e |
||
|
|
656f299017 |
||
|
|
04bdf7732b |
||
|
|
ab7dfb1edc |
||
|
|
05c3e40a4e |
||
|
|
b6478dcebf |
||
|
|
571e7b852f |
||
|
|
888dd26e1d | ||
|
|
279c8bdd7f |
||
|
|
865e4d5c86 | ||
|
|
64950cd358 |
||
|
|
36157938a8 |
||
|
|
c0dea334fb |
||
|
|
9b49daf3a9 |
||
|
|
d6f1b08ce8 | ||
|
|
75cf4cc8a8 | ||
|
|
4a181c3574 |
||
|
|
bfddf05782 |
||
|
|
cdee625a13 |
||
|
|
5f7091ab4e |
||
|
|
e7e7a97648 |
||
|
|
f58b8cb67b |
||
|
|
775f687ca9 | ||
|
|
265a93927d |
||
|
|
3a938bb6d8 |
||
|
|
6f9a4e70da | ||
|
|
dedb4133dc |
||
|
|
21f8184c58 |
||
|
|
59fe350972 |
||
|
|
5bdfcaac3c |
||
|
|
323d687173 |
||
|
|
cdca203be2 |
||
|
|
77a31d2b6e | ||
|
|
a1ed99278a |
||
|
|
729bd438e4 |
||
|
|
7304bb2751 |
||
|
|
456521f689 |
||
|
|
ee69079bb4 |
||
|
|
de14bb2e0f |
||
|
|
0539d56fc4 |
||
|
|
729adde022 |
||
|
|
b7111d5c62 | ||
|
|
1669713fbc |
||
|
|
cb8be9ba12 | ||
|
|
15046658ba |
||
|
|
7255ce9a57 | ||
|
|
2b9cf5d388 | ||
|
|
a625d35f7b |
||
|
|
449c64928b |
||
|
|
35c88c3fc8 |
||
|
|
b5e09024d5 |
||
|
|
9a51765bd2 |
||
|
|
bb31c9b92c | ||
|
|
5117679148 |
||
|
|
0d5798c0bf | ||
|
|
808677a612 |
||
|
|
fe41e9d482 | ||
|
|
0768c4655e | ||
|
|
1586a26594 |
||
|
|
f026c1422c |
||
|
|
b7fdb595df | ||
|
|
049ee1ce5e |
||
|
|
e71fbb6d48 | ||
|
|
c9976d69c0 | ||
|
|
27734409e4 | ||
|
|
e434ce01d3 |
||
|
|
c2f93ae2ad | ||
|
|
92cc8f553a |
||
|
|
49d997aec3 |
||
|
|
67aa9cee23 |
||
|
|
7ec3d67829 | ||
|
|
5401ebaede |
||
|
|
99668cfdce |
||
|
|
b3a012cbdd |
||
|
|
51b9c726cf |
||
|
|
b4e49d5b32 |
||
|
|
2b29854589 | ||
|
|
8ce5038bd8 | ||
|
|
446510a6a9 | ||
|
|
49028b6245 | ||
|
|
d595c7e265 |
||
|
|
17cd4a8c32 |
||
|
|
9bbf7918de | ||
|
|
97f502e79a |
||
|
|
ac5e249083 | ||
|
|
140e796b21 | ||
|
|
dd02cea9e7 |
||
|
|
a8062ea314 |
||
|
|
f47684787a |
||
|
|
8168f0f0f6 |
||
|
|
8036440693 |
||
|
|
cf7503687a | ||
|
|
6f8e1dda15 |
||
|
|
4dcfd9182c | ||
|
|
d7c0db8cee |
||
|
|
184da0e42e |
||
|
|
a244d82aba |
||
|
|
34e5809059 |
||
|
|
2db96c9b7e |
||
|
|
9976269ab1 | ||
|
|
5df822be11 |
||
|
|
233f069070 |
||
|
|
4a1982f5c9 |
||
|
|
1c7f65f105 |
||
|
|
a84151e482 |
||
|
|
0340a4ff77 |
||
|
|
f4faacbe87 |
||
|
|
7dbfd0b934 | ||
|
|
08a2a623dc |
||
|
|
60c88336b9 |
||
|
|
8a03fc265b |
||
|
|
d0ba538924 | ||
|
|
772e976d80 |
||
|
|
c769380776 |
||
|
|
01131c75d8 |
||
|
|
ea982c3832 |
||
|
|
d09d5612f3 | ||
|
|
85ef35dd64 |
||
|
|
251177d56c |
||
|
|
13b9e55c72 |
||
|
|
b94e55c7c0 |
||
|
|
9c6bb4ff72 |
||
|
|
c2e019ba7d | ||
|
|
b8374b5a7c |
||
|
|
b4665a8bf8 |
||
|
|
08096b5e61 | ||
|
|
2e43d41af0 |
||
|
|
518772c2ba | ||
|
|
fc9021ccec | ||
|
|
3bebfe6b1b | ||
|
|
3b18c64782 | ||
|
|
c6c599b872 |
||
|
|
6b41ae910c | ||
|
|
5c860d4142 |
||
|
|
4f9207daac |
||
|
|
ae7d63272c |
||
|
|
5cc3a51357 |
||
|
|
e357c90212 | ||
|
|
fb1edc4918 | ||
|
|
353e0eef8a | ||
|
|
a4c17f08b1 |
||
|
|
3cd9ee5a73 |
||
|
|
7c56319aa4 | ||
|
|
13fa4937b2 |
||
|
|
1db5c2402c |
||
|
|
0abbcddac2 |
||
|
|
dd25d143c5 |
||
|
|
d8a57ee15c |
||
|
|
aa52d30d7f |
||
|
|
83d633fd13 |
||
|
|
824455e2f1 |
||
|
|
62de2b3695 |
||
|
|
7550aa4b89 | ||
|
|
c33a2011c4 |
||
|
|
fff6764ed4 |
||
|
|
9644a008a4 | ||
|
|
ccb6b7c3ea |
||
|
|
f95f877e5f |
||
|
|
fe96d4aebd | ||
|
|
3a95d56144 | ||
|
|
ecd8be7593 | ||
|
|
a72c3880bb | ||
|
|
ccd8676bee |
||
|
|
5a533e366f |
||
|
|
77429f5982 |
||
|
|
6cfe4bc34f |
||
|
|
95013d2936 |
||
|
|
14a5529793 |
||
|
|
0b2e8707e3 | ||
|
|
adf178a6b9 |
||
|
|
78f85b1cd6 |
||
|
|
888dc67cc3 | ||
|
|
fd9ee435ec |
||
|
|
1c25b2f298 | ||
|
|
ea9fffce2e |
||
|
|
516f0266b8 |
||
|
|
39397deb8d | ||
|
|
65fd2e5c91 |
||
|
|
c5fe32fbb1 |
||
|
|
c48b77d6f9 | ||
|
|
64fd7a2027 |
||
|
|
ae259d87f0 |
||
|
|
50374abab8 |
||
|
|
0497e8badf | ||
|
|
2a08f8f5f4 |
||
|
|
9353559088 | ||
|
|
6aff2ee169 |
||
|
|
254a481e5d |
||
|
|
26885e7118 |
||
|
|
dba08013d1 | ||
|
|
fc09646973 |
||
|
|
c3b9c9d888 |
||
|
|
203885d241 | ||
|
|
728e86b287 | ||
|
|
d5aa79c73a |
||
|
|
eb6ff0c1e0 | ||
|
|
0df44c4e91 | ||
|
|
561070fbc2 |
||
|
|
b0e41ff2c4 |
||
|
|
977a40af68 | ||
|
|
dffecb6478 |
||
|
|
7f57d2a9ac |
||
|
|
377d5d2287 |
||
|
|
38bb38ecb2 |
||
|
|
3a149ba71c |
||
|
|
14df88eab5 |
||
|
|
e12cb7fb6b | ||
|
|
d4f7039932 |
||
|
|
e0ec9be238 | ||
|
|
2b34df94fa |
||
|
|
68f225a11d |
||
|
|
e9f5d34096 |
||
|
|
d3459eb740 |
||
|
|
be73f465df |
||
|
|
f14eafe9db |
||
|
|
b7e4f1ef74 |
||
|
|
efd5f0a80f |
||
|
|
7135bc4fb5 | ||
|
|
7cf8d1ca6d |
||
|
|
78bca5c707 | ||
|
|
82db86e2a1 |
||
|
|
476e3fc382 | ||
|
|
a12d50e15a |
||
|
|
1b9b260458 | ||
|
|
a8983bd2c7 |
||
|
|
709af58612 |
||
|
|
bcbbf32569 |
||
|
|
7a8e7c88f4 |
||
|
|
a22676720d |
||
|
|
f63a451b6f |
||
|
|
8b6a2b173b |
||
|
|
933dbfdb36 |
||
|
|
b44bc0ad07 |
||
|
|
436cd39d14 | ||
|
|
ed6dc879be |
||
|
|
96d53c6716 |
797 changed files with 214295 additions and 86419 deletions
6
.github/TEAM_MEMBERS
vendored
6
.github/TEAM_MEMBERS
vendored
|
|
@ -1,4 +1,5 @@
|
||||||
adamdotdevin
|
adamdotdevin
|
||||||
|
arvsrn
|
||||||
Brendonovich
|
Brendonovich
|
||||||
fwang
|
fwang
|
||||||
Hona
|
Hona
|
||||||
|
|
@ -7,11 +8,14 @@ jayair
|
||||||
jlongster
|
jlongster
|
||||||
kitlangton
|
kitlangton
|
||||||
kommander
|
kommander
|
||||||
|
ludvigrask
|
||||||
MrMushrooooom
|
MrMushrooooom
|
||||||
nexxeln
|
nexxeln
|
||||||
R44VC0RP
|
R44VC0RP
|
||||||
rekram1-node
|
rekram1-node
|
||||||
thdxr
|
thdxr
|
||||||
simonklee
|
simonklee
|
||||||
|
Slickstef11
|
||||||
|
usrnk1
|
||||||
vimtor
|
vimtor
|
||||||
starptech
|
StarpTech
|
||||||
|
|
|
||||||
7
.github/workflows/publish.yml
vendored
7
.github/workflows/publish.yml
vendored
|
|
@ -219,7 +219,6 @@ jobs:
|
||||||
|
|
||||||
build-electron:
|
build-electron:
|
||||||
needs:
|
needs:
|
||||||
- build-cli
|
|
||||||
- version
|
- version
|
||||||
if: github.repository == 'anomalyco/opencode'
|
if: github.repository == 'anomalyco/opencode'
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
|
|
@ -316,10 +315,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 }}
|
|
||||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: bun run build
|
run: bun run build
|
||||||
|
|
@ -343,8 +339,7 @@ jobs:
|
||||||
env:
|
env:
|
||||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
|
CSC_KEYCHAIN: build.keychain
|
||||||
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
|
||||||
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
|
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
|
||||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
|
||||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
description: translate English to other languages
|
description: translate English to other languages
|
||||||
model: opencode/claude-opus-4-8
|
model: opencode/gpt-5.6-sol
|
||||||
---
|
---
|
||||||
|
|
||||||
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.
|
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { tool } from "@opencode-ai/plugin"
|
||||||
const TEAM = {
|
const TEAM = {
|
||||||
tui: ["kommander", "simonklee"],
|
tui: ["kommander", "simonklee"],
|
||||||
desktop_web: ["Hona", "Brendonovich"],
|
desktop_web: ["Hona", "Brendonovich"],
|
||||||
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton", "starptech"],
|
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"],
|
||||||
inference: ["fwang", "MrMushrooooom", "starptech"],
|
inference: ["fwang", "MrMushrooooom", "starptech"],
|
||||||
windows: ["Hona"],
|
windows: ["Hona"],
|
||||||
} as const
|
} as const
|
||||||
|
|
|
||||||
|
|
@ -125,4 +125,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**加入我们的社区** [飞书](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)
|
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode)
|
||||||
|
|
|
||||||
|
|
@ -125,4 +125,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**加入我們的社群** [飞书](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)
|
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode)
|
||||||
|
|
|
||||||
10
artifacts/glm52-rise-video/sst-env.d.ts
vendored
Normal file
10
artifacts/glm52-rise-video/sst-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
/* This file is auto-generated by SST. Do not edit. */
|
||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
/* deno-fmt-ignore-file */
|
||||||
|
/* biome-ignore-all lint: auto-generated */
|
||||||
|
|
||||||
|
/// <reference path="../../sst-env.d.ts" />
|
||||||
|
|
||||||
|
import "sst"
|
||||||
|
export {}
|
||||||
251
bun.lock
251
bun.lock
|
|
@ -29,13 +29,15 @@
|
||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@corvu/drawer": "catalog:",
|
||||||
"@dnd-kit/abstract": "0.5.0",
|
"@dnd-kit/abstract": "0.5.0",
|
||||||
"@dnd-kit/dom": "0.5.0",
|
"@dnd-kit/dom": "0.5.0",
|
||||||
"@dnd-kit/helpers": "0.5.0",
|
"@dnd-kit/helpers": "0.5.0",
|
||||||
"@dnd-kit/solid": "0.5.0",
|
"@dnd-kit/solid": "0.5.0",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
|
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
|
@ -64,7 +66,7 @@
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
|
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
"marked": "catalog:",
|
"marked": "catalog:",
|
||||||
"marked-shiki": "catalog:",
|
"marked-shiki": "catalog:",
|
||||||
|
|
@ -72,6 +74,7 @@
|
||||||
"shiki": "catalog:",
|
"shiki": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"solid-list": "catalog:",
|
"solid-list": "catalog:",
|
||||||
|
"solid-presence": "0.2.0",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -93,7 +96,7 @@
|
||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@opencode-ai/cli",
|
"name": "@opencode-ai/cli",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"bin": {
|
"bin": {
|
||||||
"lildax": "./bin/lildax.cjs",
|
"lildax": "./bin/lildax.cjs",
|
||||||
},
|
},
|
||||||
|
|
@ -141,7 +144,7 @@
|
||||||
},
|
},
|
||||||
"packages/codemode": {
|
"packages/codemode": {
|
||||||
"name": "@opencode-ai/codemode",
|
"name": "@opencode-ai/codemode",
|
||||||
"version": "0.0.1",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"acorn": "8.15.0",
|
"acorn": "8.15.0",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
|
|
@ -155,7 +158,7 @@
|
||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
|
|
@ -191,7 +194,7 @@
|
||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
|
|
@ -218,7 +221,7 @@
|
||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.82",
|
"@ai-sdk/anthropic": "3.0.82",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
|
|
@ -240,7 +243,7 @@
|
||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|
@ -264,7 +267,7 @@
|
||||||
},
|
},
|
||||||
"packages/console/support": {
|
"packages/console/support": {
|
||||||
"name": "@opencode-ai/console-support",
|
"name": "@opencode-ai/console-support",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@opencode-ai/console-core": "workspace:*",
|
"@opencode-ai/console-core": "workspace:*",
|
||||||
|
|
@ -284,7 +287,7 @@
|
||||||
},
|
},
|
||||||
"packages/core": {
|
"packages/core": {
|
||||||
"name": "@opencode-ai/core",
|
"name": "@opencode-ai/core",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
|
|
@ -292,7 +295,7 @@
|
||||||
"@ai-sdk/alibaba": "1.0.17",
|
"@ai-sdk/alibaba": "1.0.17",
|
||||||
"@ai-sdk/amazon-bedrock": "4.0.112",
|
"@ai-sdk/amazon-bedrock": "4.0.112",
|
||||||
"@ai-sdk/anthropic": "3.0.82",
|
"@ai-sdk/anthropic": "3.0.82",
|
||||||
"@ai-sdk/azure": "3.0.49",
|
"@ai-sdk/azure": "3.0.88",
|
||||||
"@ai-sdk/cerebras": "2.0.41",
|
"@ai-sdk/cerebras": "2.0.41",
|
||||||
"@ai-sdk/cohere": "3.0.27",
|
"@ai-sdk/cohere": "3.0.27",
|
||||||
"@ai-sdk/deepinfra": "2.0.41",
|
"@ai-sdk/deepinfra": "2.0.41",
|
||||||
|
|
@ -300,15 +303,15 @@
|
||||||
"@ai-sdk/google": "3.0.73",
|
"@ai-sdk/google": "3.0.73",
|
||||||
"@ai-sdk/google-vertex": "4.0.128",
|
"@ai-sdk/google-vertex": "4.0.128",
|
||||||
"@ai-sdk/groq": "3.0.31",
|
"@ai-sdk/groq": "3.0.31",
|
||||||
"@ai-sdk/mistral": "3.0.27",
|
"@ai-sdk/mistral": "3.0.51",
|
||||||
"@ai-sdk/openai": "3.0.53",
|
"@ai-sdk/openai": "3.0.84",
|
||||||
"@ai-sdk/openai-compatible": "2.0.41",
|
"@ai-sdk/openai-compatible": "2.0.41",
|
||||||
"@ai-sdk/perplexity": "3.0.26",
|
"@ai-sdk/perplexity": "3.0.26",
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@ai-sdk/provider-utils": "4.0.23",
|
"@ai-sdk/provider-utils": "4.0.23",
|
||||||
"@ai-sdk/togetherai": "2.0.41",
|
"@ai-sdk/togetherai": "2.0.41",
|
||||||
"@ai-sdk/vercel": "2.0.39",
|
"@ai-sdk/vercel": "2.0.39",
|
||||||
"@ai-sdk/xai": "3.0.82",
|
"@ai-sdk/xai": "3.0.102",
|
||||||
"@aws-sdk/credential-providers": "3.1057.0",
|
"@aws-sdk/credential-providers": "3.1057.0",
|
||||||
"@effect/opentelemetry": "catalog:",
|
"@effect/opentelemetry": "catalog:",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
|
|
@ -336,7 +339,7 @@
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"gitlab-ai-provider": "6.10.0",
|
"gitlab-ai-provider": "6.12.1",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
|
|
@ -378,16 +381,16 @@
|
||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zip.js/zip.js": "2.7.62",
|
"@zip.js/zip.js": "2.7.62",
|
||||||
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"electron-context-menu": "4.1.2",
|
"electron-context-menu": "4.1.2",
|
||||||
"electron-log": "^5",
|
"electron-log": "^5",
|
||||||
"electron-store": "11.0.2",
|
"electron-store": "11.0.2",
|
||||||
"electron-updater": "6.8.9",
|
"electron-updater": "6.8.9",
|
||||||
"electron-window-state": "^5.0.3",
|
"electron-window-state": "^5.0.3",
|
||||||
"marked": "^15",
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@actions/artifact": "4.0.0",
|
"@actions/artifact": "4.0.0",
|
||||||
|
|
@ -432,7 +435,7 @@
|
||||||
},
|
},
|
||||||
"packages/effect-drizzle-sqlite": {
|
"packages/effect-drizzle-sqlite": {
|
||||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
|
|
@ -446,7 +449,7 @@
|
||||||
},
|
},
|
||||||
"packages/effect-sqlite-node": {
|
"packages/effect-sqlite-node": {
|
||||||
"name": "@opencode-ai/effect-sqlite-node",
|
"name": "@opencode-ai/effect-sqlite-node",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
},
|
},
|
||||||
|
|
@ -458,7 +461,7 @@
|
||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/standard-validator": "catalog:",
|
"@hono/standard-validator": "catalog:",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
|
|
@ -490,7 +493,7 @@
|
||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
|
|
@ -506,7 +509,7 @@
|
||||||
},
|
},
|
||||||
"packages/http-recorder": {
|
"packages/http-recorder": {
|
||||||
"name": "@opencode-ai/http-recorder",
|
"name": "@opencode-ai/http-recorder",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@effect/platform-node": "4.0.0-beta.83",
|
"@effect/platform-node": "4.0.0-beta.83",
|
||||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||||
|
|
@ -537,7 +540,7 @@
|
||||||
},
|
},
|
||||||
"packages/llm": {
|
"packages/llm": {
|
||||||
"name": "@opencode-ai/llm",
|
"name": "@opencode-ai/llm",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@smithy/eventstream-codec": "4.2.14",
|
"@smithy/eventstream-codec": "4.2.14",
|
||||||
|
|
@ -556,7 +559,7 @@
|
||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
|
|
@ -567,7 +570,7 @@
|
||||||
"@ai-sdk/alibaba": "1.0.17",
|
"@ai-sdk/alibaba": "1.0.17",
|
||||||
"@ai-sdk/amazon-bedrock": "4.0.112",
|
"@ai-sdk/amazon-bedrock": "4.0.112",
|
||||||
"@ai-sdk/anthropic": "3.0.82",
|
"@ai-sdk/anthropic": "3.0.82",
|
||||||
"@ai-sdk/azure": "3.0.49",
|
"@ai-sdk/azure": "3.0.88",
|
||||||
"@ai-sdk/cerebras": "2.0.60",
|
"@ai-sdk/cerebras": "2.0.60",
|
||||||
"@ai-sdk/cohere": "3.0.27",
|
"@ai-sdk/cohere": "3.0.27",
|
||||||
"@ai-sdk/deepinfra": "2.0.41",
|
"@ai-sdk/deepinfra": "2.0.41",
|
||||||
|
|
@ -575,14 +578,14 @@
|
||||||
"@ai-sdk/google": "3.0.73",
|
"@ai-sdk/google": "3.0.73",
|
||||||
"@ai-sdk/google-vertex": "4.0.128",
|
"@ai-sdk/google-vertex": "4.0.128",
|
||||||
"@ai-sdk/groq": "3.0.31",
|
"@ai-sdk/groq": "3.0.31",
|
||||||
"@ai-sdk/mistral": "3.0.27",
|
"@ai-sdk/mistral": "3.0.51",
|
||||||
"@ai-sdk/openai": "3.0.53",
|
"@ai-sdk/openai": "3.0.84",
|
||||||
"@ai-sdk/openai-compatible": "2.0.41",
|
"@ai-sdk/openai-compatible": "2.0.41",
|
||||||
"@ai-sdk/perplexity": "3.0.26",
|
"@ai-sdk/perplexity": "3.0.26",
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@ai-sdk/togetherai": "2.0.41",
|
"@ai-sdk/togetherai": "2.0.41",
|
||||||
"@ai-sdk/vercel": "2.0.39",
|
"@ai-sdk/vercel": "2.0.39",
|
||||||
"@ai-sdk/xai": "3.0.82",
|
"@ai-sdk/xai": "3.0.102",
|
||||||
"@aws-sdk/credential-providers": "3.1057.0",
|
"@aws-sdk/credential-providers": "3.1057.0",
|
||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
"@effect/opentelemetry": "catalog:",
|
"@effect/opentelemetry": "catalog:",
|
||||||
|
|
@ -629,7 +632,7 @@
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
"gitlab-ai-provider": "6.10.0",
|
"gitlab-ai-provider": "6.12.1",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"google-auth-library": "10.5.0",
|
"google-auth-library": "10.5.0",
|
||||||
"gray-matter": "4.0.3",
|
"gray-matter": "4.0.3",
|
||||||
|
|
@ -687,7 +690,7 @@
|
||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
|
@ -704,9 +707,9 @@
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentui/core": ">=0.3.4",
|
"@opentui/core": ">=0.4.5",
|
||||||
"@opentui/keymap": ">=0.3.4",
|
"@opentui/keymap": ">=0.4.5",
|
||||||
"@opentui/solid": ">=0.3.4",
|
"@opentui/solid": ">=0.4.5",
|
||||||
},
|
},
|
||||||
"optionalPeers": [
|
"optionalPeers": [
|
||||||
"@opentui/core",
|
"@opentui/core",
|
||||||
|
|
@ -763,7 +766,7 @@
|
||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
|
|
@ -778,7 +781,7 @@
|
||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@opencode-ai/server",
|
"name": "@opencode-ai/server",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/protocol": "workspace:*",
|
"@opencode-ai/protocol": "workspace:*",
|
||||||
|
|
@ -793,9 +796,10 @@
|
||||||
},
|
},
|
||||||
"packages/session-ui": {
|
"packages/session-ui": {
|
||||||
"name": "@opencode-ai/session-ui",
|
"name": "@opencode-ai/session-ui",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
|
"@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
|
|
@ -807,7 +811,6 @@
|
||||||
"@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.3",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
|
|
@ -837,7 +840,7 @@
|
||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
|
|
@ -850,9 +853,10 @@
|
||||||
},
|
},
|
||||||
"packages/stats/app": {
|
"packages/stats/app": {
|
||||||
"name": "@opencode-ai/stats-app",
|
"name": "@opencode-ai/stats-app",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/stats-core": "workspace:*",
|
"@opencode-ai/stats-core": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
|
|
@ -883,7 +887,7 @@
|
||||||
},
|
},
|
||||||
"packages/stats/core": {
|
"packages/stats/core": {
|
||||||
"name": "@opencode-ai/stats-core",
|
"name": "@opencode-ai/stats-core",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-athena": "3.933.0",
|
"@aws-sdk/client-athena": "3.933.0",
|
||||||
"@planetscale/database": "1.19.0",
|
"@planetscale/database": "1.19.0",
|
||||||
|
|
@ -902,7 +906,7 @@
|
||||||
},
|
},
|
||||||
"packages/stats/server": {
|
"packages/stats/server": {
|
||||||
"name": "@opencode-ai/stats-server",
|
"name": "@opencode-ai/stats-server",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-firehose": "3.933.0",
|
"@aws-sdk/client-firehose": "3.933.0",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
|
|
@ -934,6 +938,7 @@
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/react": "18.0.25",
|
"@types/react": "18.0.25",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"storybook": "^10.2.13",
|
"storybook": "^10.2.13",
|
||||||
"storybook-solidjs-vite": "^10.0.9",
|
"storybook-solidjs-vite": "^10.0.9",
|
||||||
|
|
@ -943,7 +948,7 @@
|
||||||
},
|
},
|
||||||
"packages/tui": {
|
"packages/tui": {
|
||||||
"name": "@opencode-ai/tui",
|
"name": "@opencode-ai/tui",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
|
|
@ -970,7 +975,7 @@
|
||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
|
|
@ -996,6 +1001,7 @@
|
||||||
"remend": "catalog:",
|
"remend": "catalog:",
|
||||||
"shiki": "catalog:",
|
"shiki": "catalog:",
|
||||||
"solid-list": "catalog:",
|
"solid-list": "catalog:",
|
||||||
|
"solid-sonner": "catalog:",
|
||||||
"strip-ansi": "7.1.2",
|
"strip-ansi": "7.1.2",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -1021,7 +1027,7 @@
|
||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
|
|
@ -1064,18 +1070,19 @@
|
||||||
],
|
],
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||||
|
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||||
|
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||||
"@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",
|
||||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
|
|
||||||
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
|
|
||||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||||
|
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@opentui/core": "catalog:",
|
"@opentui/core": "catalog:",
|
||||||
|
|
@ -1086,6 +1093,7 @@
|
||||||
},
|
},
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@cloudflare/workers-types": "4.20251008.0",
|
"@cloudflare/workers-types": "4.20251008.0",
|
||||||
|
"@corvu/drawer": "0.2.4",
|
||||||
"@effect/opentelemetry": "4.0.0-beta.83",
|
"@effect/opentelemetry": "4.0.0-beta.83",
|
||||||
"@effect/platform-node": "4.0.0-beta.83",
|
"@effect/platform-node": "4.0.0-beta.83",
|
||||||
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
|
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
|
||||||
|
|
@ -1096,9 +1104,9 @@
|
||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@opentui/core": "0.3.4",
|
"@opentui/core": "0.4.5",
|
||||||
"@opentui/keymap": "0.3.4",
|
"@opentui/keymap": "0.4.5",
|
||||||
"@opentui/solid": "0.3.4",
|
"@opentui/solid": "0.4.5",
|
||||||
"@pierre/diffs": "1.2.10",
|
"@pierre/diffs": "1.2.10",
|
||||||
"@playwright/test": "1.59.1",
|
"@playwright/test": "1.59.1",
|
||||||
"@sentry/solid": "10.36.0",
|
"@sentry/solid": "10.36.0",
|
||||||
|
|
@ -1109,7 +1117,7 @@
|
||||||
"@solidjs/router": "0.15.4",
|
"@solidjs/router": "0.15.4",
|
||||||
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"@tanstack/solid-virtual": "3.13.28",
|
"@tanstack/solid-virtual": "3.13.32",
|
||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@tsconfig/node22": "22.0.2",
|
"@tsconfig/node22": "22.0.2",
|
||||||
"@types/bun": "1.3.13",
|
"@types/bun": "1.3.13",
|
||||||
|
|
@ -1129,7 +1137,7 @@
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
"luxon": "3.6.1",
|
"luxon": "3.6.1",
|
||||||
"marked": "17.0.1",
|
"marked": "17.0.6",
|
||||||
"marked-shiki": "1.2.1",
|
"marked-shiki": "1.2.1",
|
||||||
"opentui-spinner": "0.0.7",
|
"opentui-spinner": "0.0.7",
|
||||||
"remeda": "2.26.0",
|
"remeda": "2.26.0",
|
||||||
|
|
@ -1138,6 +1146,7 @@
|
||||||
"shiki": "4.2.0",
|
"shiki": "4.2.0",
|
||||||
"solid-js": "1.9.10",
|
"solid-js": "1.9.10",
|
||||||
"solid-list": "0.3.0",
|
"solid-list": "0.3.0",
|
||||||
|
"solid-sonner": "0.3.1",
|
||||||
"sst": "4.13.1",
|
"sst": "4.13.1",
|
||||||
"tailwindcss": "4.1.11",
|
"tailwindcss": "4.1.11",
|
||||||
"typescript": "5.8.2",
|
"typescript": "5.8.2",
|
||||||
|
|
@ -1171,7 +1180,7 @@
|
||||||
|
|
||||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="],
|
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="],
|
||||||
|
|
||||||
"@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
|
"@ai-sdk/azure": ["@ai-sdk/azure@3.0.88", "", { "dependencies": { "@ai-sdk/deepseek": "2.0.47", "@ai-sdk/openai": "3.0.84", "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RRjZkB1lYplh8dpBarnvkl1j7sYLHsyXua7erL3oNcMK7fHcl4bPO5C7iQhD1O/DqD/zCceDifnege1s+8yEvw=="],
|
||||||
|
|
||||||
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="],
|
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kDMEpjaRdRXIUi1EH8WHwLRahyDTYv9SAJnP6VCCeq8X+tVqZbMLCqqxSG5dRknrI65ucjvzQt+FiDKTAa7AHg=="],
|
||||||
|
|
||||||
|
|
@ -1181,7 +1190,7 @@
|
||||||
|
|
||||||
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="],
|
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-y6RoOP7DGWmDSiSxrUSt5p18sbz+Ixe5lMVPmdE7x+Tr5rlrzvftyHhjWHfqlAtoYERZTGFbP6tPW1OfQcrb4A=="],
|
||||||
|
|
||||||
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="],
|
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MzcQ321JO8OY+TVLFI81A7cIIuoeLLxrLCDD+8C1E3Ro6UFyfMtRXo9bw9OhTMRSDMo6hgSDOo4Fekz8aJtQYQ=="],
|
||||||
|
|
||||||
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EtvsWfGrqx3OhzJdoi82qH+4yzEPPKZr2utyQ+w8cHKoFeg0+8Lou9Z3uixy73WEwz8Z1+AR8QT9fZ64AWGYPA=="],
|
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EtvsWfGrqx3OhzJdoi82qH+4yzEPPKZr2utyQ+w8cHKoFeg0+8Lou9Z3uixy73WEwz8Z1+AR8QT9fZ64AWGYPA=="],
|
||||||
|
|
||||||
|
|
@ -1195,7 +1204,7 @@
|
||||||
|
|
||||||
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
|
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
|
||||||
|
|
||||||
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
|
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="],
|
||||||
|
|
||||||
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
|
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
|
||||||
|
|
||||||
|
|
@ -1211,7 +1220,7 @@
|
||||||
|
|
||||||
"@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="],
|
"@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="],
|
||||||
|
|
||||||
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
|
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.102", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.56", "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NeQyOR7OCqDMgaLS4uNX/ep/HrwUzzFYLzXQSRoqLy2jsnqxAJhsgltRwAwf+ADjyPBIAKEOestWnIQA+LrLrQ=="],
|
||||||
|
|
||||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||||
|
|
||||||
|
|
@ -1467,6 +1476,10 @@
|
||||||
|
|
||||||
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="],
|
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="],
|
||||||
|
|
||||||
|
"@corvu/dialog": ["@corvu/dialog@0.2.4", "", { "dependencies": { "@corvu/utils": "~0.4.2", "solid-dismissible": "~0.1.1", "solid-focus-trap": "~0.1.8", "solid-presence": "~0.2.0", "solid-prevent-scroll": "~0.1.10" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-n54vJq+fOy8GVrnYBdJpD6JXNuyx7LOeMrRxwzAvZnYGpW8+AA12tnb/P/2emJj/HjOO5otheGKb0breshdFlA=="],
|
||||||
|
|
||||||
|
"@corvu/drawer": ["@corvu/drawer@0.2.4", "", { "dependencies": { "@corvu/dialog": "~0.2.4", "@corvu/utils": "~0.4.2", "@solid-primitives/memo": "^1.4.1", "solid-transition-size": "~0.1.4" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-7jQoGZ8ROB9CmXam2nMY2wEskU3IoFwZQywkF/7vrBc/edGsPv7mOVQ1GN6G+4nd7nrMZ3UtqHAhmRh9V0azlw=="],
|
||||||
|
|
||||||
"@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="],
|
"@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="],
|
||||||
|
|
||||||
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||||
|
|
@ -2031,27 +2044,27 @@
|
||||||
|
|
||||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
|
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
|
||||||
|
|
||||||
"@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="],
|
"@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="],
|
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="],
|
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
|
||||||
|
|
||||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="],
|
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
|
||||||
|
|
||||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="],
|
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="],
|
||||||
|
|
||||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="],
|
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
|
||||||
|
|
||||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="],
|
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
|
||||||
|
|
||||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="],
|
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
|
||||||
|
|
||||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="],
|
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
|
||||||
|
|
||||||
"@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="],
|
"@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="],
|
||||||
|
|
||||||
"@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="],
|
"@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="],
|
||||||
|
|
||||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||||
|
|
||||||
|
|
@ -2667,6 +2680,8 @@
|
||||||
|
|
||||||
"@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="],
|
"@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="],
|
||||||
|
|
||||||
|
"@solid-primitives/memo": ["@solid-primitives/memo@1.5.0", "", { "dependencies": { "@solid-primitives/scheduled": "^1.5.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nSMHpFdnOMP88t7lqtktUXJlhQdJk0BQs2v3jUqcn+OtFwUm27Oa/coKl1BHDxL/65jR5drjqxxvuhaIpiUi0w=="],
|
||||||
|
|
||||||
"@solid-primitives/props": ["@solid-primitives/props@3.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-XzG6en9gSFwmvbKcATm2BxL63HegZ+BAG5fmHi8jyBppQHcaths7ffz+6vYvwYy3nlgLa20ufJLj7tst+PcHFA=="],
|
"@solid-primitives/props": ["@solid-primitives/props@3.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-XzG6en9gSFwmvbKcATm2BxL63HegZ+BAG5fmHi8jyBppQHcaths7ffz+6vYvwYy3nlgLa20ufJLj7tst+PcHFA=="],
|
||||||
|
|
||||||
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA=="],
|
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA=="],
|
||||||
|
|
@ -2771,9 +2786,9 @@
|
||||||
|
|
||||||
"@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
|
"@tanstack/solid-query": ["@tanstack/solid-query@5.91.4", "", { "dependencies": { "@tanstack/query-core": "5.91.2" }, "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-oCEgn8iT7WnF/7ISd7usBpUK1C9EdvQfg8ZUpKNKZ4edVClICZrCX6f3/Bp8ZlwQnL21KLc2rp+CejEuehlRxg=="],
|
||||||
|
|
||||||
"@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.28", "", { "dependencies": { "@tanstack/virtual-core": "3.17.0" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-kRuOEL5orH/rzGgxNgfgOttsgV6cgrUeupVtrHMITb5p0rZ3hnxhbu/lhKcR9+7x+EJdfUtJIb2CVC85mlw15g=="],
|
"@tanstack/solid-virtual": ["@tanstack/solid-virtual@3.13.32", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "solid-js": "^1.3.0" } }, "sha512-yhX4A4Kgn+wyTg6Mmu8+zwoMTwjz4K1ucvLfRJ8f0rPGDDAIqSaf0v6oU0yT9+SvrjmUaZQ0VX7g4byexbhNng=="],
|
||||||
|
|
||||||
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.0", "", {}, "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ=="],
|
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="],
|
||||||
|
|
||||||
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
|
||||||
|
|
||||||
|
|
@ -3243,7 +3258,7 @@
|
||||||
|
|
||||||
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
|
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
|
||||||
|
|
||||||
"bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
|
"bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
|
||||||
|
|
||||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||||
|
|
||||||
|
|
@ -3835,13 +3850,13 @@
|
||||||
|
|
||||||
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
||||||
|
|
||||||
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#513463a", {}, "anomalyco-ghostty-web-513463a", "sha512-GZR8LSmgGzViWnBJrqRI8MpAZRCJxhcr1Hi9Tyeh7YRooHZQjK9J97FQRD3tbBaM2wjq05gzGY2UEsG+JtZeBw=="],
|
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#83c0a07", {}, "anomalyco-ghostty-web-83c0a07", "sha512-Lf2v1agHkVUpMpHBWWuCZrhOEmcwwin5/Hboc9rZwQ7/CKkIh5rU1r1CvfLlhkMoFv+ed8z52RZ8hkzGZZj3MQ=="],
|
||||||
|
|
||||||
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
|
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
|
||||||
|
|
||||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||||
|
|
||||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.10.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-oWEZ06rDO6JjB7INHO882wyBAQqCZVHiDHwCs5M+VPmdDj8TzhGXcYesA2CcV5RoI5lfHLKwGp5uKFB62VWpqw=="],
|
"gitlab-ai-provider": ["gitlab-ai-provider@6.12.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Qn5iHqvjG8yktI5MWaUgdRR94l7O4WtYW0CAbhsCh1Tj0Fei/DeprOYPVyf4Nht1Ix6U2PXSYM32QOHI6Z2TDw=="],
|
||||||
|
|
||||||
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
||||||
|
|
||||||
|
|
@ -4303,7 +4318,7 @@
|
||||||
|
|
||||||
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||||
|
|
||||||
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
"marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="],
|
||||||
|
|
||||||
"marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
|
"marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
|
||||||
|
|
||||||
|
|
@ -5099,18 +5114,26 @@
|
||||||
|
|
||||||
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
|
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
|
||||||
|
|
||||||
|
"solid-dismissible": ["solid-dismissible@0.1.1", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-9kcKBJIMdS+586cA1g63HYWxKh3h89leeNHbPZ1csYjuni+NvPBtNr11l0iEX2AKKEt6FHk6qNhc/gjoYAW1pA=="],
|
||||||
|
|
||||||
|
"solid-focus-trap": ["solid-focus-trap@0.1.9", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-LTyNki6GUJPRLXV5uMWPkYClB07SUMubbr2EkAddiR0CJCF/I283txilMU9RURSr/P8EewMfXWu2o3aWrK7A5A=="],
|
||||||
|
|
||||||
"solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="],
|
"solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="],
|
||||||
|
|
||||||
"solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="],
|
"solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="],
|
||||||
|
|
||||||
"solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="],
|
"solid-presence": ["solid-presence@0.2.0", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-YM92o+jvpzX3XGaD4rLYmq/Kc2ZVh47GSCLEufHBFQQIurvZTs8SoGJxO8BJGNDxBKdcS8F3dYhW1SDXp4BNjA=="],
|
||||||
|
|
||||||
"solid-prevent-scroll": ["solid-prevent-scroll@0.1.10", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-KplGPX2GHiWJLZ6AXYRql4M127PdYzfwvLJJXMkO+CMb8Np4VxqDAg5S8jLdwlEuBis/ia9DKw2M8dFx5u8Mhw=="],
|
"solid-prevent-scroll": ["solid-prevent-scroll@0.1.10", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-KplGPX2GHiWJLZ6AXYRql4M127PdYzfwvLJJXMkO+CMb8Np4VxqDAg5S8jLdwlEuBis/ia9DKw2M8dFx5u8Mhw=="],
|
||||||
|
|
||||||
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
|
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
|
||||||
|
|
||||||
|
"solid-sonner": ["solid-sonner@0.3.1", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-F/+zi9yKJTHh5hX1UGJfkDvyC+F34Vi3jgy44NJwOKCgic1QtAon0b1iT9OsDO77RTgR+PCil+3Y5B8T2Owy1Q=="],
|
||||||
|
|
||||||
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
|
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
|
||||||
|
|
||||||
|
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
|
||||||
|
|
||||||
"solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
|
"solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
|
||||||
|
|
||||||
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
|
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
|
||||||
|
|
@ -5591,8 +5614,6 @@
|
||||||
|
|
||||||
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||||
|
|
||||||
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
|
|
||||||
|
|
||||||
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||||
|
|
||||||
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
||||||
|
|
@ -5641,7 +5662,11 @@
|
||||||
|
|
||||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||||
|
|
||||||
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||||
|
|
||||||
|
"@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||||
|
|
||||||
|
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||||
|
|
||||||
"@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
|
|
@ -5653,9 +5678,9 @@
|
||||||
|
|
||||||
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
"@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
"@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||||
|
|
||||||
"@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
"@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||||
|
|
||||||
"@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
"@ai-sdk/elevenlabs/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||||
|
|
||||||
|
|
@ -5681,7 +5706,9 @@
|
||||||
|
|
||||||
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||||
|
|
||||||
|
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
|
||||||
|
|
||||||
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
|
|
@ -5695,7 +5722,11 @@
|
||||||
|
|
||||||
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cQrN6OUn/jvsY3OdsU6Wn+ss7vp1iwIcakZKSlSRMnYqShBfyT7Qht+eqmgxs7w9ttrw6FAG6o11AiBs+iEsTA=="],
|
||||||
|
|
||||||
|
"@ai-sdk/xai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.13", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-ZPtVYt5QIJzOta1kdUiDuCx4HhFkvNPv/rvmZ2b1iXwybYjJsCnNYR4PAw4kW7rgVfDARvHXcU64efWuqNp6bw=="],
|
||||||
|
|
||||||
|
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bjYld/2KGPLt78kpqbya+fD4LYS7BqVQJyUjE3qAHrYB0FR2Q90BaWEVIBZaguTWXf/A8L6uG1zO1v9TxVlGWg=="],
|
||||||
|
|
||||||
"@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
"@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||||
|
|
||||||
|
|
@ -5919,6 +5950,8 @@
|
||||||
|
|
||||||
"@jsx-email/doiuse-email/htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
|
"@jsx-email/doiuse-email/htmlparser2": ["htmlparser2@9.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.1.0", "entities": "^4.5.0" } }, "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ=="],
|
||||||
|
|
||||||
|
"@kobalte/core/solid-presence": ["solid-presence@0.1.8", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-pWGtXUFWYYUZNbg5YpG5vkQJyOtzn2KXhxYaMx/4I+lylTLYkITOLevaCwMRN+liCVk0pqB6EayLWojNqBFECA=="],
|
||||||
|
|
||||||
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
|
"@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
|
||||||
|
|
||||||
"@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
"@mdx-js/mdx/acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||||
|
|
@ -6001,7 +6034,9 @@
|
||||||
|
|
||||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||||
|
|
||||||
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
"@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="],
|
||||||
|
|
||||||
|
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||||
|
|
||||||
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||||
|
|
||||||
|
|
@ -6009,8 +6044,6 @@
|
||||||
|
|
||||||
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
|
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
|
||||||
|
|
||||||
"@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
|
||||||
|
|
||||||
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
|
||||||
|
|
||||||
"@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
|
"@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
|
||||||
|
|
@ -6019,6 +6052,8 @@
|
||||||
|
|
||||||
"@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
"@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||||
|
|
||||||
|
"@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="],
|
||||||
|
|
||||||
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
"@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
||||||
|
|
||||||
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
|
||||||
|
|
@ -6027,6 +6062,8 @@
|
||||||
|
|
||||||
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
"@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||||
|
|
||||||
|
"@opentui/core/marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||||
|
|
||||||
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
||||||
|
|
||||||
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
||||||
|
|
@ -6143,8 +6180,16 @@
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
|
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ=="],
|
"ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ=="],
|
||||||
|
|
||||||
"ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
"ajv-keywords/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||||
|
|
@ -6367,7 +6412,7 @@
|
||||||
|
|
||||||
"opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="],
|
"opencode/@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.60", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.54", "@ai-sdk/provider": "3.0.12", "@ai-sdk/provider-utils": "4.0.33" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rnok3cThg6awBwaDSyiZpgRpbV7pqxGYrA89LODCo5cuEHeP2h0AM0lLHP7zIkclAdXfOm4wldKi/S2T/DGCOw=="],
|
||||||
|
|
||||||
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="],
|
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
|
||||||
|
|
||||||
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||||
|
|
||||||
|
|
@ -6443,6 +6488,8 @@
|
||||||
|
|
||||||
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
|
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
|
||||||
|
|
||||||
|
"solid-transition-size/@corvu/utils": ["@corvu/utils@0.3.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.7" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ZWlyWEE8qV9+CB9OAyo2bTrZGXQN9ZeM+JfYv89zoR+lRACKTDuoOZEdiyL8Uc7U5dUSH1uTqKhTTnaHWb+wZA=="],
|
||||||
|
|
||||||
"sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="],
|
"sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="],
|
||||||
|
|
||||||
"sst/aws4fetch": ["aws4fetch@1.0.18", "", {}, "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ=="],
|
"sst/aws4fetch": ["aws4fetch@1.0.18", "", {}, "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ=="],
|
||||||
|
|
@ -6579,6 +6626,8 @@
|
||||||
|
|
||||||
"@ai-sdk/vercel/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@ai-sdk/vercel/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"@ai-sdk/xai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
"@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||||
|
|
||||||
"@astrojs/check/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
"@astrojs/check/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
@ -6871,6 +6920,10 @@
|
||||||
|
|
||||||
"@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
"@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
||||||
|
|
||||||
|
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||||
|
|
||||||
|
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||||
|
|
||||||
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
|
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
|
||||||
|
|
||||||
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
|
||||||
|
|
@ -6941,6 +6994,18 @@
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||||
|
|
||||||
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||||
|
|
||||||
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
|
|
@ -7091,6 +7156,10 @@
|
||||||
|
|
||||||
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="],
|
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.33", "", { "dependencies": { "@ai-sdk/provider": "3.0.12", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nJ0bAfegMAIJtrzMJtbzer1cS3nb7c7DsyU1S4nrPm7ZU0Mn6SBBZv5IGZZGTbpWTJwqKTSPeZJTXalbAxt1BA=="],
|
||||||
|
|
||||||
|
"opencode/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
|
||||||
|
|
||||||
|
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
|
||||||
|
|
||||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||||
|
|
||||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||||
|
|
@ -7339,6 +7408,8 @@
|
||||||
|
|
||||||
"@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
"@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
|
||||||
|
|
||||||
|
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
"@opencode-ai/desktop/@actions/artifact/@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||||
|
|
||||||
"@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
|
"@sentry/bundler-plugin-core/glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
|
||||||
|
|
@ -7355,6 +7426,12 @@
|
||||||
|
|
||||||
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"ai-gateway-provider/@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||||
|
|
@ -7425,6 +7502,8 @@
|
||||||
|
|
||||||
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"opencode/@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
"pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||||
|
|
||||||
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
|
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
|
||||||
|
|
|
||||||
|
|
@ -288,8 +288,12 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||||
server: {
|
server: {
|
||||||
placement: { region: "aws:us-east-2" },
|
placement: { region: "aws:us-east-2" },
|
||||||
transform: {
|
transform: {
|
||||||
worker: {
|
worker: (args) => {
|
||||||
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
|
args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [
|
||||||
|
...(flags ?? []),
|
||||||
|
"global_fetch_strictly_public",
|
||||||
|
])
|
||||||
|
args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }]
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,10 @@ const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", {
|
||||||
configuration: {
|
configuration: {
|
||||||
enforceWorkgroupConfiguration: true,
|
enforceWorkgroupConfiguration: true,
|
||||||
publishCloudwatchMetricsEnabled: true,
|
publishCloudwatchMetricsEnabled: true,
|
||||||
|
// Athena bills $5/TB scanned; kill any query that would scan more than 2 TB
|
||||||
|
// so a regression cannot silently burn money. Stats sync full passes scan
|
||||||
|
// ~250 GB as of 2026-07.
|
||||||
|
bytesScannedCutoffPerQuery: 2 * 1024 ** 4,
|
||||||
resultConfiguration: {
|
resultConfiguration: {
|
||||||
outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
|
outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,25 @@ export const zoneID = "430ba34c138cfb5360826c4909f99be8"
|
||||||
export const awsStage = $app.stage === "production" ? "production" : "dev"
|
export const awsStage = $app.stage === "production" ? "production" : "dev"
|
||||||
export const deployAws = $app.stage === awsStage
|
export const deployAws = $app.stage === awsStage
|
||||||
|
|
||||||
|
if ($app.stage === "production") {
|
||||||
|
new cloudflare.DnsRecord("TrustCenter", {
|
||||||
|
zoneId: zoneID,
|
||||||
|
name: "trust.opencode.ai",
|
||||||
|
type: "CNAME",
|
||||||
|
content: "3a69a5bb27875189.vercel-dns-016.com",
|
||||||
|
proxied: false,
|
||||||
|
ttl: 60,
|
||||||
|
})
|
||||||
|
|
||||||
|
new cloudflare.DnsRecord("TrustCenterVerification", {
|
||||||
|
zoneId: zoneID,
|
||||||
|
name: "opencode.ai",
|
||||||
|
type: "TXT",
|
||||||
|
content: "compai-domain-verification=org_6993a99c6200a2d642bb115d",
|
||||||
|
ttl: 60,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
new cloudflare.RegionalHostname("RegionalHostname", {
|
new cloudflare.RegionalHostname("RegionalHostname", {
|
||||||
hostname: domain,
|
hostname: domain,
|
||||||
regionKey: "us",
|
regionKey: "us",
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,9 @@ export const statSync = new sst.aws.Service("StatsSyncService", {
|
||||||
cluster: lakeCluster,
|
cluster: lakeCluster,
|
||||||
architecture: "arm64",
|
architecture: "arm64",
|
||||||
cpu: "0.25 vCPU",
|
cpu: "0.25 vCPU",
|
||||||
memory: "0.5 GB",
|
// 0.5 GB caused an OOM crash loop: every restart immediately re-ran the 4 Athena
|
||||||
|
// stats queries (~$5/pass) every ~5 minutes instead of hourly.
|
||||||
|
memory: "2 GB",
|
||||||
image: {
|
image: {
|
||||||
context: ".",
|
context: ".",
|
||||||
dockerfile: "packages/stats/server/Dockerfile",
|
dockerfile: "packages/stats/server/Dockerfile",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
makeWrapper,
|
makeWrapper,
|
||||||
writableTmpDirAsHomeHook,
|
writableTmpDirAsHomeHook,
|
||||||
autoPatchelfHook,
|
autoPatchelfHook,
|
||||||
|
copyDesktopItems,
|
||||||
|
makeDesktopItem,
|
||||||
opencode,
|
opencode,
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
|
|
@ -27,9 +29,12 @@ stdenv.mkDerivation (finalAttrs: {
|
||||||
nodejs
|
nodejs
|
||||||
makeWrapper
|
makeWrapper
|
||||||
writableTmpDirAsHomeHook
|
writableTmpDirAsHomeHook
|
||||||
] ++ lib.optionals stdenv.hostPlatform.isLinux [
|
]
|
||||||
|
++ lib.optionals stdenv.hostPlatform.isLinux [
|
||||||
autoPatchelfHook
|
autoPatchelfHook
|
||||||
] ++ lib.optionals stdenv.hostPlatform.isDarwin [
|
copyDesktopItems
|
||||||
|
]
|
||||||
|
++ lib.optionals stdenv.hostPlatform.isDarwin [
|
||||||
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
|
# Ad-hoc sign the .app: --config.mac.identity=null below skips signing.
|
||||||
darwin.autoSignDarwinBinariesHook
|
darwin.autoSignDarwinBinariesHook
|
||||||
];
|
];
|
||||||
|
|
@ -38,20 +43,37 @@ stdenv.mkDerivation (finalAttrs: {
|
||||||
(lib.getLib stdenv.cc.cc)
|
(lib.getLib stdenv.cc.cc)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
desktopItems = lib.optional stdenv.hostPlatform.isLinux (makeDesktopItem {
|
||||||
|
name = "ai.opencode.desktop";
|
||||||
|
desktopName = "OpenCode";
|
||||||
|
exec = "opencode-desktop %U";
|
||||||
|
icon = "ai.opencode.desktop";
|
||||||
|
# Electron 41 derives X11 WM_CLASS from app.name.
|
||||||
|
startupWMClass = "OpenCode";
|
||||||
|
categories = [ "Development" ];
|
||||||
|
});
|
||||||
|
|
||||||
env = opencode.env // {
|
env = opencode.env // {
|
||||||
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
ELECTRON_SKIP_BINARY_DOWNLOAD = "1";
|
||||||
};
|
};
|
||||||
|
|
||||||
# https://github.com/electron/electron/issues/31121
|
postPatch =
|
||||||
# mac builds use a .app bundle which doesnt have this issue
|
# NOTE: Relax Bun version check to be a warning instead of an error
|
||||||
postPatch = lib.optionalString stdenv.isLinux ''
|
''
|
||||||
BASE_PATH=packages/desktop
|
substituteInPlace packages/script/src/index.ts \
|
||||||
FILES=(src/main/windows.ts)
|
--replace-fail 'throw new Error(`This script requires bun@''${expectedBunVersionRange}' \
|
||||||
for file in "''${FILES[@]}"; do
|
'console.warn(`Warning: This script requires bun@''${expectedBunVersionRange}'
|
||||||
substituteInPlace $BASE_PATH/$file \
|
''
|
||||||
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
|
# https://github.com/electron/electron/issues/31121
|
||||||
done
|
# mac builds use a .app bundle which doesnt have this issue
|
||||||
'';
|
+ lib.optionalString stdenv.isLinux ''
|
||||||
|
BASE_PATH=packages/desktop
|
||||||
|
FILES=(src/main/windows.ts)
|
||||||
|
for file in "''${FILES[@]}"; do
|
||||||
|
substituteInPlace $BASE_PATH/$file \
|
||||||
|
--replace-fail "process.resourcesPath" "'$out/opt/opencode-desktop/resources'"
|
||||||
|
done
|
||||||
|
'';
|
||||||
|
|
||||||
preBuild = ''
|
preBuild = ''
|
||||||
cp -r "${electron.dist}" $HOME/.electron-dist
|
cp -r "${electron.dist}" $HOME/.electron-dist
|
||||||
|
|
@ -76,27 +98,38 @@ stdenv.mkDerivation (finalAttrs: {
|
||||||
runHook postBuild
|
runHook postBuild
|
||||||
'';
|
'';
|
||||||
|
|
||||||
installPhase =
|
installPhase = ''
|
||||||
''
|
runHook preInstall
|
||||||
runHook preInstall
|
''
|
||||||
''
|
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
|
||||||
+ lib.optionalString stdenv.hostPlatform.isDarwin ''
|
mkdir -p $out/Applications
|
||||||
mkdir -p $out/Applications
|
mv dist/mac*/*.app $out/Applications
|
||||||
mv dist/mac*/*.app $out/Applications
|
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
|
||||||
makeWrapper "$out/Applications/OpenCode.app/Contents/MacOS/OpenCode" $out/bin/opencode-desktop
|
''
|
||||||
''
|
+ lib.optionalString stdenv.hostPlatform.isLinux ''
|
||||||
+ lib.optionalString stdenv.hostPlatform.isLinux ''
|
mkdir -p $out/opt/opencode-desktop
|
||||||
mkdir -p $out/opt/opencode-desktop
|
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
|
||||||
cp -r dist/linux*-unpacked/{resources,LICENSE*} $out/opt/opencode-desktop
|
install -Dm644 resources/icons/32x32.png \
|
||||||
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
|
"$out/share/icons/hicolor/32x32/apps/ai.opencode.desktop.png"
|
||||||
--inherit-argv0 \
|
install -Dm644 resources/icons/64x64.png \
|
||||||
--set ELECTRON_FORCE_IS_PACKAGED 1 \
|
"$out/share/icons/hicolor/64x64/apps/ai.opencode.desktop.png"
|
||||||
--add-flags $out/opt/opencode-desktop/resources/app.asar \
|
install -Dm644 resources/icons/128x128.png \
|
||||||
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
|
"$out/share/icons/hicolor/128x128/apps/ai.opencode.desktop.png"
|
||||||
''
|
install -Dm644 resources/icons/128x128@2x.png \
|
||||||
+ ''
|
"$out/share/icons/hicolor/256x256/apps/ai.opencode.desktop.png"
|
||||||
runHook postInstall
|
install -Dm644 resources/icons/icon.png \
|
||||||
'';
|
"$out/share/icons/hicolor/512x512/apps/ai.opencode.desktop.png"
|
||||||
|
install -Dm644 resources/ai.opencode.desktop.metainfo.xml \
|
||||||
|
"$out/share/metainfo/ai.opencode.desktop.metainfo.xml"
|
||||||
|
makeWrapper ${lib.getExe electron} $out/bin/opencode-desktop \
|
||||||
|
--inherit-argv0 \
|
||||||
|
--set ELECTRON_FORCE_IS_PACKAGED 1 \
|
||||||
|
--add-flags $out/opt/opencode-desktop/resources/app.asar \
|
||||||
|
--add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}"
|
||||||
|
''
|
||||||
|
+ ''
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
autoPatchelfIgnoreMissingDeps = [
|
autoPatchelfIgnoreMissingDeps = [
|
||||||
"libc.musl-x86_64.so.1"
|
"libc.musl-x86_64.so.1"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-ovrz0pALxRek5VEqSYkVpzb9YeiiVQWxfAHZQ3kX0/0=",
|
"x86_64-linux": "sha256-a7NyYa9vRUEqDfZNDPXXmFO58RDEgioyuGSl5CPBvxo=",
|
||||||
"aarch64-linux": "sha256-+sqBt11Nl1fDrL1JvFAcN8JMWahspfbBPuMeM7YdagU=",
|
"aarch64-linux": "sha256-l4OJtSEllHvRhktjcaJYwkXBSaJvsIoyoLusbZfYMcM=",
|
||||||
"aarch64-darwin": "sha256-PsZfcmMpz/Xzudjv4CQZu+wPfnyijrlVYeP2x9tRffk=",
|
"aarch64-darwin": "sha256-IIl0BQGs1/HLFh0auiQjiwfSQ2nfHcK2G2BAphYW59c=",
|
||||||
"x86_64-darwin": "sha256-4TpIc3Gh9nDaA5Y1zkCq0KraMdWu+PWtYVY3p7/CsJ0="
|
"x86_64-darwin": "sha256-vVeuPyd4ZIRYrHouphTuEb4rkRZLKKTAHe840jNh9rU="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
package.json
22
package.json
|
|
@ -19,6 +19,7 @@
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"random": "echo 'Random script'",
|
"random": "echo 'Random script'",
|
||||||
"sso": "aws sso login --sso-session=opencode --no-browser",
|
"sso": "aws sso login --sso-session=opencode --no-browser",
|
||||||
|
"translate:app": "bun run script/translate-app.ts",
|
||||||
"test": "echo 'do not run tests from root' && exit 1"
|
"test": "echo 'do not run tests from root' && exit 1"
|
||||||
},
|
},
|
||||||
"workspaces": {
|
"workspaces": {
|
||||||
|
|
@ -39,13 +40,14 @@
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/standard-validator": "0.2.0",
|
"@hono/standard-validator": "0.2.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@opentui/core": "0.3.4",
|
"@opentui/core": "0.4.5",
|
||||||
"@opentui/keymap": "0.3.4",
|
"@opentui/keymap": "0.4.5",
|
||||||
"@opentui/solid": "0.3.4",
|
"@opentui/solid": "0.4.5",
|
||||||
"@tanstack/solid-virtual": "3.13.28",
|
"@tanstack/solid-virtual": "3.13.32",
|
||||||
"@shikijs/stream": "4.2.0",
|
"@shikijs/stream": "4.2.0",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
|
"@corvu/drawer": "0.2.4",
|
||||||
"@types/luxon": "3.7.1",
|
"@types/luxon": "3.7.1",
|
||||||
"@types/node": "24.12.2",
|
"@types/node": "24.12.2",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
|
|
@ -68,7 +70,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.6",
|
||||||
"marked-shiki": "1.2.1",
|
"marked-shiki": "1.2.1",
|
||||||
"remend": "1.3.0",
|
"remend": "1.3.0",
|
||||||
"@playwright/test": "1.59.1",
|
"@playwright/test": "1.59.1",
|
||||||
|
|
@ -88,6 +90,7 @@
|
||||||
"@sentry/solid": "10.36.0",
|
"@sentry/solid": "10.36.0",
|
||||||
"@sentry/vite-plugin": "4.6.0",
|
"@sentry/vite-plugin": "4.6.0",
|
||||||
"solid-js": "1.9.10",
|
"solid-js": "1.9.10",
|
||||||
|
"solid-sonner": "0.3.1",
|
||||||
"vite-plugin-solid": "2.11.10",
|
"vite-plugin-solid": "2.11.10",
|
||||||
"@lydell/node-pty": "1.2.0-beta.12"
|
"@lydell/node-pty": "1.2.0-beta.12"
|
||||||
}
|
}
|
||||||
|
|
@ -141,19 +144,20 @@
|
||||||
"@types/node": "catalog:"
|
"@types/node": "catalog:"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
|
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
|
||||||
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
|
"@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch",
|
||||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||||
"@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",
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||||
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
|
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
|
||||||
|
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||||
"@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch",
|
|
||||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||||
"@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch",
|
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
|
||||||
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch"
|
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
220
packages/app/V1_API_MIGRATION.md
Normal file
220
packages/app/V1_API_MIGRATION.md
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
# V1 API Migration Checklist
|
||||||
|
|
||||||
|
The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name.
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
- [x] Replace `GET /global/event` with `GET /api/event`.
|
||||||
|
- `src/context/server-sdk.tsx`
|
||||||
|
- [x] Reduce current granular session and message events into the existing app projections.
|
||||||
|
- `src/context/server-session-v2-reducer.ts`
|
||||||
|
- `src/context/server-session.ts`
|
||||||
|
- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`.
|
||||||
|
- `src/context/global-sync/event-reducer.ts`
|
||||||
|
- `src/context/server-session.ts`
|
||||||
|
- `src/context/notification.tsx`
|
||||||
|
- `src/pages/session/usage-exceeded-dialogs.tsx`
|
||||||
|
- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`.
|
||||||
|
- `src/context/global-sync/event-reducer.ts`
|
||||||
|
- `src/context/server-session.ts`
|
||||||
|
- [x] Adapt current permission and question events to the existing request model.
|
||||||
|
- `src/context/global-sync/event-reducer.ts`
|
||||||
|
- `src/context/permission.tsx`
|
||||||
|
- [x] Consume current file watcher events.
|
||||||
|
- `src/context/file.tsx`
|
||||||
|
- [x] Consume current VCS events.
|
||||||
|
- `src/context/global-sync/event-reducer.ts`
|
||||||
|
- `src/pages/session.tsx`
|
||||||
|
- [x] Consume current `pty.exited` events.
|
||||||
|
- `src/context/terminal.tsx`
|
||||||
|
- [ ] Migrate LSP and reference events.
|
||||||
|
- `src/context/global-sync/event-reducer.ts`
|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
|
||||||
|
- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events.
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- [x] Migrate session listing from `GET /session`.
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- `src/context/directory-sync.ts`
|
||||||
|
- `src/pages/layout.tsx`
|
||||||
|
- [x] Migrate the remaining direct session read from `GET /session/:sessionID`.
|
||||||
|
- `src/components/titlebar.tsx`
|
||||||
|
- [x] Migrate session updates from `PATCH /session/:sessionID`.
|
||||||
|
- `src/context/directory-sync.ts`
|
||||||
|
- `src/context/layout.tsx`
|
||||||
|
- `src/pages/home.tsx`
|
||||||
|
- `src/pages/layout.tsx`
|
||||||
|
- `src/pages/session/timeline/message-timeline.tsx`
|
||||||
|
- `src/components/titlebar-tab-nav.tsx`
|
||||||
|
- Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`.
|
||||||
|
- [x] Migrate session deletion from `DELETE /session/:sessionID`.
|
||||||
|
- `src/pages/session/timeline/message-timeline.tsx`
|
||||||
|
- [x] Remove session diff loading from `GET /session/:sessionID/diff`.
|
||||||
|
- Historical Session diffs remain unavailable until the current API defines their snapshot semantics.
|
||||||
|
- [x] Migrate abort from `POST /session/:sessionID/abort`.
|
||||||
|
- `src/components/prompt-input/submit.ts`
|
||||||
|
- `src/pages/session/use-session-commands.tsx`
|
||||||
|
- `src/pages/session.tsx`
|
||||||
|
- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`.
|
||||||
|
- `src/pages/session/use-session-commands.tsx`
|
||||||
|
- `src/pages/session.tsx`
|
||||||
|
- [x] Replace `POST /session/:sessionID/summarize` with the current compact API.
|
||||||
|
- `src/pages/session/use-session-commands.tsx`
|
||||||
|
- [x] Migrate slash commands from `POST /session/:sessionID/command`.
|
||||||
|
- `src/components/prompt-input/submit.ts`
|
||||||
|
- [x] Migrate shell execution from `POST /session/:sessionID/shell`.
|
||||||
|
- `src/components/prompt-input/submit.ts`
|
||||||
|
- [x] Migrate session fork from `POST /session/:sessionID/fork`.
|
||||||
|
- `src/components/dialog-fork.tsx`
|
||||||
|
- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`.
|
||||||
|
- `src/pages/session/use-session-commands.tsx`
|
||||||
|
- `src/pages/session/timeline/message-timeline.tsx`
|
||||||
|
- Blocked: the current API has no sharing contract or implementation.
|
||||||
|
|
||||||
|
## Session Compatibility Fallbacks
|
||||||
|
|
||||||
|
These calls are retained as fallback adapters. The current production path supplies the current session and message APIs.
|
||||||
|
|
||||||
|
- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary.
|
||||||
|
- `src/context/server-session.ts`
|
||||||
|
- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary.
|
||||||
|
- `src/context/server-session.ts`
|
||||||
|
- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary.
|
||||||
|
- `src/context/server-session.ts`
|
||||||
|
|
||||||
|
## Filesystem
|
||||||
|
|
||||||
|
- [ ] Migrate file listing from `GET /file`.
|
||||||
|
- `src/context/file.tsx`
|
||||||
|
- [ ] Migrate file reads from `GET /file/content`.
|
||||||
|
- `src/context/file.tsx`
|
||||||
|
- `src/pages/session/review-tab.tsx`
|
||||||
|
- `src/pages/session/v2/review-panel-v2.tsx`
|
||||||
|
- [x] Migrate path discovery from `GET /path` to `GET /api/path`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- `src/components/dialog-select-directory.tsx`
|
||||||
|
- `src/components/dialog-select-directory-v2.tsx`
|
||||||
|
|
||||||
|
## Projects And Worktrees
|
||||||
|
|
||||||
|
- [x] Migrate project listing from `GET /project` to `GET /api/project`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- [ ] Migrate Git initialization from `POST /project/git/init`.
|
||||||
|
- `src/pages/session.tsx`
|
||||||
|
- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`.
|
||||||
|
- `src/context/layout.tsx`
|
||||||
|
- `src/components/edit-project.ts`
|
||||||
|
- `src/pages/layout.tsx`
|
||||||
|
- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`.
|
||||||
|
- `src/pages/layout.tsx`
|
||||||
|
- `src/components/prompt-input/submit.ts`
|
||||||
|
- Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain.
|
||||||
|
- [ ] Migrate instance disposal from `POST /instance/dispose`.
|
||||||
|
- `src/pages/layout.tsx`
|
||||||
|
|
||||||
|
## VCS
|
||||||
|
|
||||||
|
- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`.
|
||||||
|
- `src/pages/session.tsx`
|
||||||
|
- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`.
|
||||||
|
- `src/pages/layout.tsx`
|
||||||
|
|
||||||
|
## Configuration And Authentication
|
||||||
|
|
||||||
|
- [ ] Migrate global configuration reads from `GET /global/config`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- [ ] Migrate directory configuration reads from `GET /config`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- [ ] Migrate global configuration updates from `PATCH /global/config`.
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`.
|
||||||
|
- `src/components/dialog-connect-provider.tsx`
|
||||||
|
- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`.
|
||||||
|
- `src/components/dialog-connect-provider.tsx`
|
||||||
|
- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`.
|
||||||
|
- Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`.
|
||||||
|
- `src/components/dialog-connect-provider.tsx`
|
||||||
|
- `src/components/dialog-custom-provider.tsx`
|
||||||
|
- `src/components/settings-providers.tsx`
|
||||||
|
- `src/components/settings-v2/providers.tsx`
|
||||||
|
- [ ] Migrate global disposal from `POST /global/dispose`.
|
||||||
|
- `src/components/dialog-connect-provider.tsx`
|
||||||
|
- `src/components/settings-providers.tsx`
|
||||||
|
- `src/components/settings-v2/providers.tsx`
|
||||||
|
|
||||||
|
## Permissions And Questions
|
||||||
|
|
||||||
|
- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- `src/context/permission.tsx`
|
||||||
|
- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`.
|
||||||
|
- `src/context/permission.tsx`
|
||||||
|
- `src/pages/session/composer/session-composer-state.ts`
|
||||||
|
- [x] Migrate question listing from `GET /question` to `GET /api/question/request`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`.
|
||||||
|
- `src/pages/session/composer/session-question-dock.tsx`
|
||||||
|
|
||||||
|
## Commands, MCP, LSP, And References
|
||||||
|
|
||||||
|
- [x] Migrate command listing from `GET /command` to `GET /api/command`.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`.
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- [ ] Replace legacy MCP authentication with the Integration OAuth workflow.
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`.
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- [ ] Migrate LSP status from `GET /lsp`.
|
||||||
|
- `src/context/server-sync.tsx`
|
||||||
|
- [x] Move `GET /api/reference` off the legacy generated SDK transport.
|
||||||
|
- `src/context/global-sync/bootstrap.ts`
|
||||||
|
|
||||||
|
## Search
|
||||||
|
|
||||||
|
- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`.
|
||||||
|
- `src/components/command-palette.ts`
|
||||||
|
- `src/components/dialog-command-palette-v2.tsx`
|
||||||
|
|
||||||
|
## PTY And Terminal
|
||||||
|
|
||||||
|
- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`.
|
||||||
|
- `src/context/terminal.tsx`
|
||||||
|
- `src/components/terminal.tsx`
|
||||||
|
- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`.
|
||||||
|
- `src/components/settings-general.tsx`
|
||||||
|
- `src/components/settings-v2/general.tsx`
|
||||||
|
- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`.
|
||||||
|
- `src/components/terminal.tsx`
|
||||||
|
- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`.
|
||||||
|
- `src/components/terminal.tsx`
|
||||||
|
|
||||||
|
## Legacy Types And Adapters
|
||||||
|
|
||||||
|
These are not V1 network requests, but they keep the UI coupled to V1 data contracts.
|
||||||
|
|
||||||
|
- [ ] Replace the current-session-to-legacy-session adapter.
|
||||||
|
- `src/utils/session.ts`
|
||||||
|
- [ ] Replace the current-message-to-legacy-message-and-part adapter.
|
||||||
|
- `src/utils/session-message.ts`
|
||||||
|
- [ ] Replace current agent, provider, and model adapters to legacy SDK structures.
|
||||||
|
- `src/context/global-sync/utils.ts`
|
||||||
|
- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering.
|
||||||
|
- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone.
|
||||||
|
- `package.json`
|
||||||
|
|
||||||
|
## Test Infrastructure
|
||||||
|
|
||||||
|
- [ ] Replace V1 endpoint mocks with current API mocks.
|
||||||
|
- `e2e/utils/mock-server.ts`
|
||||||
|
- [x] Replace `/global/event` and `/event` interception with current event transport handling.
|
||||||
|
- `e2e/utils/sse-transport.ts`
|
||||||
|
- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests.
|
||||||
|
- `e2e/performance/timeline-stability/fixture.ts`
|
||||||
|
- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests.
|
||||||
|
|
@ -97,6 +97,7 @@ export async function setupTimeline(
|
||||||
locale?: string
|
locale?: string
|
||||||
deviceScaleFactor?: number
|
deviceScaleFactor?: number
|
||||||
seedHistory?: boolean
|
seedHistory?: boolean
|
||||||
|
protocol?: "v1" | "v2"
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const sessions = input.sessions ?? [session()]
|
const sessions = input.sessions ?? [session()]
|
||||||
|
|
@ -114,6 +115,7 @@ export async function setupTimeline(
|
||||||
retry: input.eventRetry ?? 20,
|
retry: input.eventRetry ?? 20,
|
||||||
})
|
})
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: input.protocol,
|
||||||
directory,
|
directory,
|
||||||
project: project(),
|
project: project(),
|
||||||
provider: provider(),
|
provider: provider(),
|
||||||
|
|
@ -136,6 +138,9 @@ export async function setupTimeline(
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
if (settings.newLayoutDesigns === false) {
|
||||||
|
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
||||||
|
}
|
||||||
}, input.settings ?? {})
|
}, input.settings ?? {})
|
||||||
if (input.locale) {
|
if (input.locale) {
|
||||||
await page.addInitScript((locale) => {
|
await page.addInitScript((locale) => {
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,40 @@ test("does not reverse visible rows when the user wheels during shell remeasurem
|
||||||
await reportVisualStability(testInfo, "wheel-during-resize", trace, rowPairPlan(regions, 1))
|
await reportVisualStability(testInfo, "wheel-during-resize", trace, rowPairPlan(regions, 1))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps moving upward while drag-selecting above the timeline", async ({ page }) => {
|
||||||
|
await setupTimeline(page, {
|
||||||
|
messages: history(80),
|
||||||
|
viewport: { width: 1400, height: 700 },
|
||||||
|
reducedMotion: true,
|
||||||
|
})
|
||||||
|
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||||
|
const text = page.getByText("History 79.", { exact: false })
|
||||||
|
await expect(text).toBeVisible()
|
||||||
|
await scroller.evaluate((element) => {
|
||||||
|
element.dataset.selectionLength = "0"
|
||||||
|
document.addEventListener("selectionchange", () => {
|
||||||
|
element.dataset.selectionLength = String(
|
||||||
|
Math.max(Number(element.dataset.selectionLength), window.getSelection()?.toString().length ?? 0),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const textBox = await text.boundingBox()
|
||||||
|
const scrollBox = await scroller.boundingBox()
|
||||||
|
expect(textBox).not.toBeNull()
|
||||||
|
expect(scrollBox).not.toBeNull()
|
||||||
|
if (!textBox || !scrollBox) return
|
||||||
|
|
||||||
|
await page.mouse.move(textBox.x + textBox.width - 10, textBox.y + textBox.height / 2)
|
||||||
|
await page.mouse.down()
|
||||||
|
await page.mouse.move(textBox.x + 20, scrollBox.y - 120, { steps: 30 })
|
||||||
|
|
||||||
|
await expect.poll(() => scroller.evaluate((element) => Number(element.dataset.selectionLength))).toBeGreaterThan(0)
|
||||||
|
await expect
|
||||||
|
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||||
|
.toBeGreaterThan(500)
|
||||||
|
await page.mouse.up()
|
||||||
|
})
|
||||||
|
|
||||||
test("does not pull a keyboard-scrolled user during shell remeasurement", async ({ page }, testInfo) => {
|
test("does not pull a keyboard-scrolled user during shell remeasurement", async ({ page }, testInfo) => {
|
||||||
const shellID = "prt_keyboard_01_shell"
|
const shellID = "prt_keyboard_01_shell"
|
||||||
const followingID = "prt_keyboard_02_following"
|
const followingID = "prt_keyboard_02_following"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,312 @@
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { benchmark, expect } from "../benchmark"
|
||||||
|
import { setupTimelineBenchmark } from "./session-timeline-benchmark.fixture"
|
||||||
|
|
||||||
|
const changedLinesPerFile = 100
|
||||||
|
const linesPerSide = changedLinesPerFile / 2
|
||||||
|
const fileCounts = [1, 10, 100, 1_000, 10_000]
|
||||||
|
const filesPerDirectory = 100
|
||||||
|
const readyFrames = 3
|
||||||
|
const completionTimeoutMs = Number(process.env.REVIEW_PANE_COMPLETION_TIMEOUT_MS ?? 900_000)
|
||||||
|
|
||||||
|
type ReviewPaneScalingSample = {
|
||||||
|
observedAtMs: number
|
||||||
|
logicalRows: number
|
||||||
|
treeRows: number
|
||||||
|
fileRows: number
|
||||||
|
diffLines: number
|
||||||
|
header: string
|
||||||
|
ready: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReviewPaneScalingProbe = {
|
||||||
|
startedAt?: number
|
||||||
|
firstTreeRowMs?: number
|
||||||
|
logicalTreeReadyMs?: number
|
||||||
|
firstDiffRenderMs?: number
|
||||||
|
stableReadyMs?: number
|
||||||
|
samples: ReviewPaneScalingSample[]
|
||||||
|
frameTimesMs: number[]
|
||||||
|
longTasks: { startTime: number; duration: number }[]
|
||||||
|
stop: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
benchmark.describe("performance: review pane scaling", () => {
|
||||||
|
for (const fileCount of fileCounts) {
|
||||||
|
const changedLines = fileCount * changedLinesPerFile
|
||||||
|
|
||||||
|
benchmark(
|
||||||
|
`${changedLines} changed lines across ${fileCount} ${fileCount === 1 ? "file" : "files"}`,
|
||||||
|
async ({ page, report }) => {
|
||||||
|
benchmark.setTimeout(1_200_000)
|
||||||
|
await page.emulateMedia({ reducedMotion: "reduce" })
|
||||||
|
|
||||||
|
const patchByteLimit = Number(process.env.REVIEW_PANE_PATCH_BYTE_LIMIT ?? Number.POSITIVE_INFINITY)
|
||||||
|
if (Number.isNaN(patchByteLimit) || patchByteLimit < 0)
|
||||||
|
throw new Error(`Invalid REVIEW_PANE_PATCH_BYTE_LIMIT: ${process.env.REVIEW_PANE_PATCH_BYTE_LIMIT}`)
|
||||||
|
const responseBody = JSON.stringify(createScalingDiffs(fileCount, patchByteLimit))
|
||||||
|
await setupTimelineBenchmark(page, {
|
||||||
|
historyTurns: 0,
|
||||||
|
eventBatch: 1,
|
||||||
|
newLayoutDesigns: true,
|
||||||
|
})
|
||||||
|
await page.route("**/vcs/diff**", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: responseBody,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const expectedRows = fileCount + 2 + Math.ceil(fileCount / filesPerDirectory)
|
||||||
|
const metrics = await measureReviewPaneLoad(page, {
|
||||||
|
expectedFile: reviewFile(0),
|
||||||
|
expectedRows,
|
||||||
|
})
|
||||||
|
const search = await measureBroadReviewSearch(page, fileCount)
|
||||||
|
|
||||||
|
expect(metrics.logicalRows).toBe(expectedRows)
|
||||||
|
expect(metrics.fileRows).toBeGreaterThan(0)
|
||||||
|
expect(metrics.treeRows).toBeGreaterThan(0)
|
||||||
|
expect(metrics.diffLines).toBeGreaterThan(0)
|
||||||
|
expect(search.logicalRows).toBe(fileCount)
|
||||||
|
expect(search.renderedRows).toBeGreaterThan(0)
|
||||||
|
report(
|
||||||
|
{ ...metrics, search },
|
||||||
|
{
|
||||||
|
fileCount,
|
||||||
|
changedLinesPerFile,
|
||||||
|
changedLines,
|
||||||
|
additions: changedLines / 2,
|
||||||
|
deletions: changedLines / 2,
|
||||||
|
patchLines: changedLines,
|
||||||
|
patchByteLimit: Number.isFinite(patchByteLimit) ? patchByteLimit : null,
|
||||||
|
payloadBytes: new TextEncoder().encode(responseBody).byteLength,
|
||||||
|
expectedRows,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function measureBroadReviewSearch(page: Page, expectedRows: number) {
|
||||||
|
const filter = page.getByRole("searchbox", { name: "Filter files" })
|
||||||
|
await filter.evaluate((element) => {
|
||||||
|
element.addEventListener(
|
||||||
|
"input",
|
||||||
|
() => {
|
||||||
|
;(window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt = performance.now()
|
||||||
|
},
|
||||||
|
{ once: true, capture: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
await filter.fill("file-")
|
||||||
|
|
||||||
|
return page.evaluate((expectedRows) => {
|
||||||
|
const startedAt = (window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt!
|
||||||
|
return new Promise<{ stableMs: number; logicalRows: number; renderedRows: number }>((resolve) => {
|
||||||
|
let previous = -1
|
||||||
|
let streak = 0
|
||||||
|
const sample = () => {
|
||||||
|
const tree = document.querySelector<HTMLElement>('#review-panel [data-component="file-tree-v2"]')
|
||||||
|
const rows = [...document.querySelectorAll<HTMLElement>('#review-panel [data-slot="file-tree-v2-row"]')]
|
||||||
|
const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
|
||||||
|
const ready =
|
||||||
|
logicalRows === expectedRows && rows.length > 0 && rows.every((row) => row.textContent?.includes("file-"))
|
||||||
|
streak = ready && rows.length === previous ? streak + 1 : ready ? 1 : 0
|
||||||
|
previous = rows.length
|
||||||
|
if (streak >= 3) {
|
||||||
|
resolve({ stableMs: performance.now() - startedAt, logicalRows, renderedRows: rows.length })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestAnimationFrame(sample)
|
||||||
|
}
|
||||||
|
requestAnimationFrame(sample)
|
||||||
|
})
|
||||||
|
}, expectedRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createScalingDiffs(fileCount: number, patchByteLimit: number) {
|
||||||
|
const changes = Array.from({ length: linesPerSide }, (_, index) => {
|
||||||
|
const line = String(index).padStart(3, "0")
|
||||||
|
return `-export const value_${line} = "before"\n+export const value_${line} = "after"`
|
||||||
|
}).join("\n")
|
||||||
|
let patchBytes = 0
|
||||||
|
let capped = false
|
||||||
|
|
||||||
|
return Array.from({ length: fileCount }, (_, index) => {
|
||||||
|
const file = reviewFile(index)
|
||||||
|
const fullPatch = [
|
||||||
|
`diff --git a/${file} b/${file}`,
|
||||||
|
`--- a/${file}`,
|
||||||
|
`+++ b/${file}`,
|
||||||
|
`@@ -1,${linesPerSide} +1,${linesPerSide} @@`,
|
||||||
|
changes,
|
||||||
|
].join("\n")
|
||||||
|
if (index === 0 && fullPatch.length > patchByteLimit)
|
||||||
|
throw new Error(`REVIEW_PANE_PATCH_BYTE_LIMIT must include the active patch (${fullPatch.length} bytes)`)
|
||||||
|
const patch = !capped && patchBytes + fullPatch.length <= patchByteLimit ? fullPatch : emptyReviewPatch(file)
|
||||||
|
if (patch === fullPatch) patchBytes += fullPatch.length
|
||||||
|
else capped = true
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
patch,
|
||||||
|
additions: linesPerSide,
|
||||||
|
deletions: linesPerSide,
|
||||||
|
status: "modified" as const,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyReviewPatch(file: string) {
|
||||||
|
return [`diff --git a/${file} b/${file}`, `--- a/${file}`, `+++ b/${file}`].join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewFile(index: number) {
|
||||||
|
return `src/review/d${String(Math.floor(index / filesPerDirectory)).padStart(5, "0")}/file-${String(index).padStart(5, "0")}.ts`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measureReviewPaneLoad(page: Page, input: { expectedFile: string; expectedRows: number }) {
|
||||||
|
const toggle = page.getByRole("button", { name: "Toggle review" })
|
||||||
|
await expect(toggle).toBeVisible()
|
||||||
|
await toggle.evaluate((element) => element.setAttribute("data-review-pane-scaling-toggle", ""))
|
||||||
|
await installReviewPaneScalingProbe(page, input)
|
||||||
|
await toggle.click()
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe
|
||||||
|
?.stableReadyMs !== undefined,
|
||||||
|
undefined,
|
||||||
|
{ timeout: completionTimeoutMs },
|
||||||
|
)
|
||||||
|
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const probe = (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe!
|
||||||
|
probe.stop()
|
||||||
|
const startedAt = probe.startedAt!
|
||||||
|
const final = probe.samples.at(-1)!
|
||||||
|
const resources = performance
|
||||||
|
.getEntriesByType("resource")
|
||||||
|
.filter((entry) => entry.name.includes("/vcs/diff")) as PerformanceResourceTiming[]
|
||||||
|
const resource = resources.at(-1)
|
||||||
|
const longTasks = probe.longTasks.filter(
|
||||||
|
(entry) => entry.startTime >= startedAt && entry.startTime <= startedAt + probe.stableReadyMs!,
|
||||||
|
)
|
||||||
|
const frameGaps = probe.frameTimesMs.map((time, index) => time - (probe.frameTimesMs[index - 1] ?? 0))
|
||||||
|
|
||||||
|
return {
|
||||||
|
firstTreeRowMs: probe.firstTreeRowMs ?? null,
|
||||||
|
logicalTreeReadyMs: probe.logicalTreeReadyMs ?? null,
|
||||||
|
firstDiffRenderMs: probe.firstDiffRenderMs ?? null,
|
||||||
|
stableReadyMs: probe.stableReadyMs ?? null,
|
||||||
|
responseStartMs: resource ? resource.responseStart - startedAt : null,
|
||||||
|
responseEndMs: resource ? resource.responseEnd - startedAt : null,
|
||||||
|
responseToStableMs: resource ? probe.stableReadyMs! - (resource.responseEnd - startedAt) : null,
|
||||||
|
treeRows: final.treeRows,
|
||||||
|
logicalRows: final.logicalRows,
|
||||||
|
fileRows: final.fileRows,
|
||||||
|
diffLines: final.diffLines,
|
||||||
|
samples: probe.samples.length,
|
||||||
|
maxFrameGapMs: Math.max(0, ...frameGaps),
|
||||||
|
longTaskCount: longTasks.length,
|
||||||
|
longTaskTotalMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0),
|
||||||
|
maxLongTaskMs: Math.max(0, ...longTasks.map((entry) => entry.duration)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installReviewPaneScalingProbe(page: Page, input: { expectedFile: string; expectedRows: number }) {
|
||||||
|
await page.evaluate(
|
||||||
|
({ expectedFile, expectedRows, stableFrames }) => {
|
||||||
|
let running = true
|
||||||
|
let readyStreak = 0
|
||||||
|
const basename = expectedFile.split("/").at(-1)!
|
||||||
|
const longTaskObserver = PerformanceObserver.supportedEntryTypes.includes("longtask")
|
||||||
|
? new PerformanceObserver((list) => {
|
||||||
|
probe.longTasks.push(
|
||||||
|
...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
const probe: ReviewPaneScalingProbe = {
|
||||||
|
samples: [],
|
||||||
|
frameTimesMs: [],
|
||||||
|
longTasks: [],
|
||||||
|
stop: () => {
|
||||||
|
running = false
|
||||||
|
longTaskObserver?.disconnect()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const sample = (time: number) => {
|
||||||
|
if (!running || probe.startedAt === undefined) return
|
||||||
|
const panel = document.querySelector<HTMLElement>("#review-panel")
|
||||||
|
const tree = panel?.querySelector<HTMLElement>('[data-component="file-tree-v2"]')
|
||||||
|
const rows = panel?.querySelectorAll('[data-slot="file-tree-v2-row"]') ?? []
|
||||||
|
const fileRows = panel?.querySelectorAll('button[data-slot="file-tree-v2-row"]') ?? []
|
||||||
|
const header =
|
||||||
|
panel?.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')?.textContent?.trim() ?? ""
|
||||||
|
const viewers = panel
|
||||||
|
? [...panel.querySelectorAll<HTMLElement>('[data-component="file"][data-mode="diff"]')]
|
||||||
|
: []
|
||||||
|
const diffLines = viewers.reduce(
|
||||||
|
(sum, viewer) =>
|
||||||
|
sum + (viewer.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
const observedAtMs = time - probe.startedAt
|
||||||
|
const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
|
||||||
|
const ready =
|
||||||
|
logicalRows === expectedRows &&
|
||||||
|
fileRows.length > 0 &&
|
||||||
|
header.includes(basename) &&
|
||||||
|
viewers.length === 1 &&
|
||||||
|
diffLines > 0
|
||||||
|
const previous = probe.samples.at(-1)
|
||||||
|
const stable =
|
||||||
|
ready &&
|
||||||
|
previous?.ready === true &&
|
||||||
|
previous.logicalRows === logicalRows &&
|
||||||
|
previous.treeRows === rows.length &&
|
||||||
|
previous.fileRows === fileRows.length &&
|
||||||
|
previous.diffLines === diffLines &&
|
||||||
|
previous.header === header
|
||||||
|
|
||||||
|
probe.frameTimesMs.push(observedAtMs)
|
||||||
|
probe.samples.push({
|
||||||
|
observedAtMs,
|
||||||
|
logicalRows,
|
||||||
|
treeRows: rows.length,
|
||||||
|
fileRows: fileRows.length,
|
||||||
|
diffLines,
|
||||||
|
header,
|
||||||
|
ready,
|
||||||
|
})
|
||||||
|
if (probe.firstTreeRowMs === undefined && rows.length > 0) probe.firstTreeRowMs = observedAtMs
|
||||||
|
if (probe.logicalTreeReadyMs === undefined && logicalRows === expectedRows)
|
||||||
|
probe.logicalTreeReadyMs = observedAtMs
|
||||||
|
if (probe.firstDiffRenderMs === undefined && diffLines > 0) probe.firstDiffRenderMs = observedAtMs
|
||||||
|
readyStreak = !ready ? 0 : stable ? readyStreak + 1 : 1
|
||||||
|
if (readyStreak === stableFrames) probe.stableReadyMs = observedAtMs
|
||||||
|
if (probe.stableReadyMs === undefined) requestAnimationFrame(sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
longTaskObserver?.observe({ type: "longtask", buffered: true })
|
||||||
|
document.addEventListener(
|
||||||
|
"click",
|
||||||
|
(event) => {
|
||||||
|
const toggle = event.target instanceof Element ? event.target.closest("button") : undefined
|
||||||
|
if (!toggle?.hasAttribute("data-review-pane-scaling-toggle")) return
|
||||||
|
probe.startedAt = performance.now()
|
||||||
|
performance.mark("opencode.review-pane-scaling.click")
|
||||||
|
requestAnimationFrame(sample)
|
||||||
|
},
|
||||||
|
{ capture: true, once: true },
|
||||||
|
)
|
||||||
|
;(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe = probe
|
||||||
|
},
|
||||||
|
{ ...input, stableFrames: readyFrames },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { expectSessionTitle } from "../../utils/waits"
|
||||||
|
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||||
|
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
|
||||||
|
import { fixture } from "./session-timeline-stress.fixture"
|
||||||
|
import { installStressSessionTabs, stressSessionHref } from "./timeline-test-helpers"
|
||||||
|
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
||||||
|
|
||||||
|
type ParentHydrationBenchmarkMode = "natural" | "candidate"
|
||||||
|
|
||||||
|
const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
|
||||||
|
if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
|
||||||
|
const userID = "msg_parent_hydration_user"
|
||||||
|
const user = {
|
||||||
|
...fixture.messages[fixture.targetID][0]!,
|
||||||
|
info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } },
|
||||||
|
parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({
|
||||||
|
...part,
|
||||||
|
id: `prt_parent_hydration_user_${index}`,
|
||||||
|
messageID: userID,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
const assistantSeed = fixture.messages[fixture.targetID][3]!
|
||||||
|
const assistants = Array.from({ length: 14 }, (_, index) => {
|
||||||
|
const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
|
||||||
|
return {
|
||||||
|
...assistantSeed,
|
||||||
|
info: {
|
||||||
|
...assistantSeed.info,
|
||||||
|
id: messageID,
|
||||||
|
parentID: userID,
|
||||||
|
time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
|
||||||
|
},
|
||||||
|
parts: assistantSeed.parts.map((part, partIndex) => ({
|
||||||
|
...part,
|
||||||
|
id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`,
|
||||||
|
messageID,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const messages = [user, ...assistants]
|
||||||
|
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
||||||
|
const lastID = userID
|
||||||
|
const lastAssistant = assistants.at(-1)!
|
||||||
|
const lastPart = lastAssistant.parts.at(-1)!
|
||||||
|
const lastPartID =
|
||||||
|
lastPart.type === "tool"
|
||||||
|
? lastPart.id
|
||||||
|
: `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
|
||||||
|
|
||||||
|
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
|
||||||
|
benchmark.setTimeout(180_000)
|
||||||
|
const results = [] as Awaited<ReturnType<typeof trial>>[]
|
||||||
|
for (let run = 0; run < 5; run++) {
|
||||||
|
results.push(
|
||||||
|
await withBenchmarkPage(
|
||||||
|
browser,
|
||||||
|
`session-parent-hydration-${mode}-${run}`,
|
||||||
|
(page) => trial(page, mode),
|
||||||
|
testInfo,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const timing = results.map((result) => result.metrics.firstCorrectObservedMs!).sort((a, b) => a - b)
|
||||||
|
report(
|
||||||
|
{
|
||||||
|
results: results.map((result) => ({ ...result.metrics, historyGateCount: result.historyGateCount })),
|
||||||
|
summary: {
|
||||||
|
firstCorrectObservedMs: { min: timing[0], median: timing[2], max: timing.at(-1) },
|
||||||
|
blankSamples: results.map((result) => result.metrics.blankSamples),
|
||||||
|
requestCounts: {
|
||||||
|
list: results.map((result) => result.requestCounts.list),
|
||||||
|
parent: results.map((result) => result.requestCounts.parent),
|
||||||
|
},
|
||||||
|
historyGateCount: results.map((result) => result.historyGateCount),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ mode },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
||||||
|
const requests: { type: "list" | "parent"; before?: string }[] = []
|
||||||
|
const history = mode === "candidate" ? Promise.withResolvers<void>() : undefined
|
||||||
|
let historyGates = 0
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
sessions: fixture.sessions.filter((session) => session.id === fixture.sourceID),
|
||||||
|
provider: fixture.provider,
|
||||||
|
directory: fixture.directory,
|
||||||
|
project: fixture.project,
|
||||||
|
messageDelay: 50,
|
||||||
|
onMessages: (request) => {
|
||||||
|
if (request.sessionID === fixture.targetID && request.phase === "start")
|
||||||
|
requests.push({ type: "list", before: request.before })
|
||||||
|
},
|
||||||
|
beforeMessagesResponse: (request) => {
|
||||||
|
if (mode !== "candidate" || request.sessionID !== fixture.targetID || !request.before) return Promise.resolve()
|
||||||
|
historyGates++
|
||||||
|
return history!.promise
|
||||||
|
},
|
||||||
|
onMessage: (request) => {
|
||||||
|
if (request.sessionID === fixture.targetID && request.messageID === userID) requests.push({ type: "parent" })
|
||||||
|
},
|
||||||
|
message: (sessionID, messageID) => {
|
||||||
|
if (sessionID !== fixture.targetID || messageID !== userID) return
|
||||||
|
return user
|
||||||
|
},
|
||||||
|
pageMessages: (sessionID, limit, before) => {
|
||||||
|
const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID]
|
||||||
|
const end = before ? items.findIndex((message) => message.info.id === before) : items.length
|
||||||
|
const start = Math.max(0, end - limit)
|
||||||
|
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await page.route(`**/session/${fixture.targetID}`, (route) => {
|
||||||
|
const current = new URL(route.request().url()).pathname.startsWith("/api/")
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
data: {
|
||||||
|
...target,
|
||||||
|
cost: 0,
|
||||||
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
location: { directory: target.directory },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: target,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
|
||||||
|
await page.goto(stressSessionHref(fixture.sourceID))
|
||||||
|
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||||
|
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||||
|
|
||||||
|
const href = stressSessionHref(fixture.targetID)
|
||||||
|
await page.evaluate(
|
||||||
|
({ href, title }) => {
|
||||||
|
const link = document.createElement("a")
|
||||||
|
link.id = "parent-hydration-target"
|
||||||
|
link.href = href
|
||||||
|
link.textContent = title
|
||||||
|
document.body.append(link)
|
||||||
|
},
|
||||||
|
{ href, title: target.title },
|
||||||
|
)
|
||||||
|
const metrics = await measureSessionSwitch(page, {
|
||||||
|
destinationIDs: messages.map((message) => message.info.id),
|
||||||
|
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id),
|
||||||
|
lastID,
|
||||||
|
requiredPartID: lastPartID,
|
||||||
|
requireBottomAnchor: false,
|
||||||
|
href,
|
||||||
|
switch: async () => {
|
||||||
|
await page.locator("#parent-hydration-target").click()
|
||||||
|
await expectSessionTitle(page, target.title)
|
||||||
|
},
|
||||||
|
}).finally(() => history?.resolve())
|
||||||
|
expect(metrics.firstCorrectObservedMs).not.toBeNull()
|
||||||
|
const requestCounts = {
|
||||||
|
list: requests.filter((request) => request.type === "list").length,
|
||||||
|
parent: requests.filter((request) => request.type === "parent").length,
|
||||||
|
}
|
||||||
|
if (mode === "candidate") {
|
||||||
|
expect(requestCounts.parent).toBe(0)
|
||||||
|
expect(historyGates).toBe(0)
|
||||||
|
}
|
||||||
|
return { metrics, requestCounts, historyGateCount: historyGates }
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@ export type SessionSwitchSample = {
|
||||||
source: string[]
|
source: string[]
|
||||||
hasVisibleRows: boolean
|
hasVisibleRows: boolean
|
||||||
last: boolean
|
last: boolean
|
||||||
|
requiredPartVisible?: boolean
|
||||||
|
bottomAnchorRequired?: boolean
|
||||||
bottomErrorPx?: number
|
bottomErrorPx?: number
|
||||||
review?: {
|
review?: {
|
||||||
fileHost: boolean
|
fileHost: boolean
|
||||||
|
|
@ -41,7 +43,8 @@ export function isCorrectDestination(sample: SessionSwitchSample) {
|
||||||
sample.destination.length > 0 &&
|
sample.destination.length > 0 &&
|
||||||
sample.source.length === 0 &&
|
sample.source.length === 0 &&
|
||||||
sample.last &&
|
sample.last &&
|
||||||
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1
|
sample.requiredPartVisible !== false &&
|
||||||
|
(sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,16 @@ type SessionSwitchProbe = {
|
||||||
|
|
||||||
async function installSessionSwitchProbe(
|
async function installSessionSwitchProbe(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string },
|
input: {
|
||||||
|
destinationIDs: string[]
|
||||||
|
sourceIDs: string[]
|
||||||
|
lastID: string
|
||||||
|
requiredPartID?: string
|
||||||
|
requireBottomAnchor?: boolean
|
||||||
|
href: string
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
await page.evaluate(({ destinationIDs, sourceIDs, lastID, href }) => {
|
await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => {
|
||||||
const destination = new Set(destinationIDs)
|
const destination = new Set(destinationIDs)
|
||||||
const source = new Set(sourceIDs)
|
const source = new Set(sourceIDs)
|
||||||
const samples: SessionSwitchSample[] = []
|
const samples: SessionSwitchSample[] = []
|
||||||
|
|
@ -66,6 +73,13 @@ async function installSessionSwitchProbe(
|
||||||
const rect = element.getBoundingClientRect()
|
const rect = element.getBoundingClientRect()
|
||||||
return rect.bottom > view.top && rect.top < view.bottom
|
return rect.bottom > view.top && rect.top < view.bottom
|
||||||
})
|
})
|
||||||
|
const requiredPartVisible = requiredPartID
|
||||||
|
? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
|
||||||
|
if (element.dataset.timelinePartId !== requiredPartID) return false
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
|
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
|
||||||
samples.push({
|
samples.push({
|
||||||
observedAtMs,
|
observedAtMs,
|
||||||
|
|
@ -73,11 +87,22 @@ async function installSessionSwitchProbe(
|
||||||
source: visible.filter((id) => source.has(id)),
|
source: visible.filter((id) => source.has(id)),
|
||||||
hasVisibleRows,
|
hasVisibleRows,
|
||||||
last: visible.includes(lastID),
|
last: visible.includes(lastID),
|
||||||
|
requiredPartVisible,
|
||||||
|
bottomAnchorRequired: requireBottomAnchor !== false,
|
||||||
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
|
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
|
||||||
review,
|
review,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false, review })
|
samples.push({
|
||||||
|
observedAtMs,
|
||||||
|
destination: [],
|
||||||
|
source: [],
|
||||||
|
hasVisibleRows: false,
|
||||||
|
last: false,
|
||||||
|
requiredPartVisible: requiredPartID ? false : undefined,
|
||||||
|
bottomAnchorRequired: requireBottomAnchor !== false,
|
||||||
|
review,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
requestAnimationFrame(sample)
|
requestAnimationFrame(sample)
|
||||||
}, 0)
|
}, 0)
|
||||||
|
|
@ -117,7 +142,8 @@ async function waitForStableSessionSwitch(page: Page) {
|
||||||
sample.destination.length > 0 &&
|
sample.destination.length > 0 &&
|
||||||
sample.source.length === 0 &&
|
sample.source.length === 0 &&
|
||||||
sample.last &&
|
sample.last &&
|
||||||
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1,
|
sample.requiredPartVisible !== false &&
|
||||||
|
(sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
@ -135,13 +161,27 @@ async function collectSessionSwitchResult(page: Page) {
|
||||||
|
|
||||||
export async function measureSessionSwitch(
|
export async function measureSessionSwitch(
|
||||||
page: Page,
|
page: Page,
|
||||||
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise<void> },
|
input: {
|
||||||
|
destinationIDs: string[]
|
||||||
|
sourceIDs: string[]
|
||||||
|
lastID: string
|
||||||
|
requiredPartID?: string
|
||||||
|
requireBottomAnchor?: boolean
|
||||||
|
href: string
|
||||||
|
switch: () => Promise<void>
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
const { switch: run, ...probe } = input
|
const { switch: run, ...probe } = input
|
||||||
await installSessionSwitchProbe(page, probe)
|
await installSessionSwitchProbe(page, probe)
|
||||||
await run()
|
try {
|
||||||
await waitForStableSessionSwitch(page)
|
await run()
|
||||||
return collectSessionSwitchResult(page)
|
await waitForStableSessionSwitch(page)
|
||||||
|
return await collectSessionSwitchResult(page)
|
||||||
|
} finally {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop()
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForStableTimeline(page: Page, lastID: string) {
|
export async function waitForStableTimeline(page: Page, lastID: string) {
|
||||||
|
|
|
||||||
46
packages/app/e2e/performance/unit/mock-server.test.ts
Normal file
46
packages/app/e2e/performance/unit/mock-server.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import type { Page, Route } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||||
|
|
||||||
|
test("applies message latency after a list response gate is released", async () => {
|
||||||
|
const events: string[] = []
|
||||||
|
const gate = Promise.withResolvers<void>()
|
||||||
|
let handler: ((route: Route) => Promise<void>) | undefined
|
||||||
|
const page = {
|
||||||
|
route: (_url: string, callback: (route: Route) => Promise<void>) => {
|
||||||
|
handler = callback
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
} as unknown as Page
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
provider: {},
|
||||||
|
directory: "C:/OpenCode",
|
||||||
|
project: {},
|
||||||
|
sessions: [{ id: "session" }],
|
||||||
|
messageDelay: 25,
|
||||||
|
beforeMessagesResponse: () => {
|
||||||
|
events.push("before")
|
||||||
|
return gate.promise
|
||||||
|
},
|
||||||
|
onMessages: (request) => events.push(request.phase),
|
||||||
|
pageMessages: () => {
|
||||||
|
events.push("page")
|
||||||
|
return { items: [] }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = handler!({
|
||||||
|
request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }),
|
||||||
|
fulfill: () => {
|
||||||
|
events.push("fulfill")
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
} as unknown as Route)
|
||||||
|
expect(events).toEqual(["start", "before"])
|
||||||
|
|
||||||
|
const released = performance.now()
|
||||||
|
gate.resolve()
|
||||||
|
await response
|
||||||
|
expect(performance.now() - released).toBeGreaterThanOrEqual(20)
|
||||||
|
expect(events).toEqual(["start", "before", "page", "end", "fulfill"])
|
||||||
|
})
|
||||||
|
|
@ -52,3 +52,35 @@ test("reports missing correctness without throwing", () => {
|
||||||
expect(result.firstCorrectObservedMs).toBeNull()
|
expect(result.firstCorrectObservedMs).toBeNull()
|
||||||
expect(result.stableObservedMs).toBeNull()
|
expect(result.stableObservedMs).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("requires an explicitly tracked part to be visible", () => {
|
||||||
|
const result = classifySessionSwitch([
|
||||||
|
{
|
||||||
|
observedAtMs: 16,
|
||||||
|
destination: ["destination"],
|
||||||
|
source: [],
|
||||||
|
hasVisibleRows: true,
|
||||||
|
last: true,
|
||||||
|
requiredPartVisible: false,
|
||||||
|
bottomErrorPx: 0,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result.firstCorrectObservedMs).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can measure content correctness without requiring a bottom anchor", () => {
|
||||||
|
const result = classifySessionSwitch([
|
||||||
|
{
|
||||||
|
observedAtMs: 16,
|
||||||
|
destination: ["destination"],
|
||||||
|
source: [],
|
||||||
|
hasVisibleRows: true,
|
||||||
|
last: true,
|
||||||
|
requiredPartVisible: true,
|
||||||
|
bottomAnchorRequired: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result.firstCorrectObservedMs).toBe(16)
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { measureSessionSwitch } from "../timeline/session-tab-switch-probe"
|
||||||
|
|
||||||
|
function testPage(waitFailure?: Error) {
|
||||||
|
const stops: unknown[] = []
|
||||||
|
const page = {
|
||||||
|
evaluate: async (_callback: unknown, input?: unknown) => {
|
||||||
|
if (input) return
|
||||||
|
stops.push(undefined)
|
||||||
|
},
|
||||||
|
waitForFunction: async () => {
|
||||||
|
if (waitFailure) throw waitFailure
|
||||||
|
},
|
||||||
|
} as unknown as Page
|
||||||
|
return { page, stops }
|
||||||
|
}
|
||||||
|
|
||||||
|
function input(run: () => Promise<void>) {
|
||||||
|
return {
|
||||||
|
destinationIDs: ["destination"],
|
||||||
|
sourceIDs: ["source"],
|
||||||
|
lastID: "destination",
|
||||||
|
href: "/session/destination",
|
||||||
|
switch: run,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("stops sampling when the session switch fails", async () => {
|
||||||
|
const failure = new Error("switch failed")
|
||||||
|
const context = testPage()
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
measureSessionSwitch(
|
||||||
|
context.page,
|
||||||
|
input(async () => Promise.reject(failure)),
|
||||||
|
),
|
||||||
|
).rejects.toBe(failure)
|
||||||
|
|
||||||
|
expect(context.stops).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stops sampling when the stable wait fails", async () => {
|
||||||
|
const failure = new Error("stable wait failed")
|
||||||
|
const context = testPage(failure)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
measureSessionSwitch(
|
||||||
|
context.page,
|
||||||
|
input(async () => {}),
|
||||||
|
),
|
||||||
|
).rejects.toBe(failure)
|
||||||
|
|
||||||
|
expect(context.stops).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { currentSession } from "../utils/mock-server"
|
||||||
|
|
||||||
const serverA = "http://127.0.0.1:4096"
|
const serverA = "http://127.0.0.1:4096"
|
||||||
const serverB = "http://127.0.0.1:4097"
|
const serverB = "http://127.0.0.1:4097"
|
||||||
|
|
@ -33,7 +34,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn
|
||||||
await tabA.locator('[data-slot="tab-close"] button').click()
|
await tabA.locator('[data-slot="tab-close"] button').click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||||
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true)
|
await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true)
|
||||||
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||||
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`))
|
||||||
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
|
expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true)
|
||||||
|
|
@ -84,17 +85,21 @@ async function mockServers(page: Page, requests: string[]) {
|
||||||
const current = url.origin === serverA ? sessionA : sessionB
|
const current = url.origin === serverA ? sessionA : sessionB
|
||||||
const directory = url.searchParams.get("directory")
|
const directory = url.searchParams.get("directory")
|
||||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
return sse(route)
|
||||||
if (url.pathname === "/session") return json(route, [current])
|
if (url.pathname === "/global/health") return json(route, {}, 404)
|
||||||
|
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||||
|
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||||
|
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||||
|
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||||
|
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||||
return json(route, [])
|
return json(route, [])
|
||||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||||
return json(route, {})
|
|
||||||
if (url.pathname === "/provider")
|
if (url.pathname === "/provider")
|
||||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||||
|
|
@ -116,7 +121,20 @@ async function mockServers(page: Page, requests: string[]) {
|
||||||
directory: current.directory,
|
directory: current.directory,
|
||||||
home: current.directory,
|
home: current.directory,
|
||||||
})
|
})
|
||||||
|
if (url.pathname === "/api/path")
|
||||||
|
return json(route, {
|
||||||
|
state: current.directory,
|
||||||
|
config: current.directory,
|
||||||
|
worktree: current.directory,
|
||||||
|
directory: current.directory,
|
||||||
|
home: current.directory,
|
||||||
|
})
|
||||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||||
|
if (url.pathname === "/api/vcs")
|
||||||
|
return json(route, {
|
||||||
|
location: { directory: current.directory },
|
||||||
|
data: { branch: "main", defaultBranch: "main" },
|
||||||
|
})
|
||||||
return json(route, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/FileBrowserSidebar"
|
||||||
|
const projectID = "proj_file_browser_sidebar"
|
||||||
|
const sessionID = "ses_file_browser_sidebar"
|
||||||
|
const title = "File browser sidebar"
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
const files = Array.from({ length: 80 }, (_, index) => `file-${String(index).padStart(2, "0")}.ts`)
|
||||||
|
// Marks the file-browser sidebar DOM node so a remount (fresh node) is detectable.
|
||||||
|
const PROBE = "original"
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 1440, height: 900 } })
|
||||||
|
|
||||||
|
// The file-browser sidebar must stay mounted across preview/pinned file-tab
|
||||||
|
// switches. Remounting resets scroll and filter state.
|
||||||
|
test("keeps the file-browser sidebar mounted when switching file tabs", async ({ page }) => {
|
||||||
|
await setup(page)
|
||||||
|
|
||||||
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
|
const panel = page.locator("#review-panel")
|
||||||
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
|
|
||||||
|
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
|
||||||
|
await expect(sidebar).toBeVisible()
|
||||||
|
await expect(panel.getByRole("button", { name: "file-00.ts" })).toBeVisible()
|
||||||
|
|
||||||
|
await panel.getByRole("button", { name: "file-00.ts" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "file-00.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(panel.getByText("contents:file-00.ts", { exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
const viewport = panel.locator('[data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
||||||
|
await viewport.hover()
|
||||||
|
await page.mouse.wheel(0, 100_000)
|
||||||
|
await expect
|
||||||
|
.poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||||
|
.toBeLessThanOrEqual(1)
|
||||||
|
const scrolled = await viewport.evaluate((element) => element.scrollTop)
|
||||||
|
expect(scrolled).toBeGreaterThan(0)
|
||||||
|
await writeProbe(page)
|
||||||
|
|
||||||
|
await panel.getByRole("button", { name: "file-79.ts" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(panel.getByText("contents:file-79.ts", { exact: true })).toBeVisible()
|
||||||
|
expect(await readProbe(page)).toBe(PROBE)
|
||||||
|
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
|
||||||
|
|
||||||
|
await panel.getByRole("button", { name: "file-78.ts" }).dblclick()
|
||||||
|
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
await panel.getByRole("button", { name: "file-79.ts" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "file-79.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
await panel.getByRole("tab", { name: "file-78.ts" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "file-78.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
expect(await readProbe(page)).toBe(PROBE)
|
||||||
|
await expect.poll(() => viewport.evaluate((element) => element.scrollTop)).toBe(scrolled)
|
||||||
|
})
|
||||||
|
|
||||||
|
type Probed = HTMLElement & { __e2eProbe?: string }
|
||||||
|
|
||||||
|
async function writeProbe(page: Page) {
|
||||||
|
await page.locator('#review-panel [data-component="session-review-v2-sidebar-root"]').evaluate((el, probe) => {
|
||||||
|
;(el as Probed).__e2eProbe = probe
|
||||||
|
}, PROBE)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readProbe(page: Page) {
|
||||||
|
return page
|
||||||
|
.locator('#review-panel [data-component="session-review-v2-sidebar-root"]')
|
||||||
|
.evaluate((el) => (el as Probed).__e2eProbe)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(page: Page) {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "file-browser-sidebar",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "test" },
|
||||||
|
},
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: sessionID,
|
||||||
|
slug: sessionID,
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vcsDiff: [],
|
||||||
|
fileList: (path) => {
|
||||||
|
if (path) return []
|
||||||
|
return files.map((name) => ({
|
||||||
|
name,
|
||||||
|
path: name,
|
||||||
|
absolute: `${directory}/${name}`,
|
||||||
|
type: "file" as const,
|
||||||
|
ignored: false,
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.addInitScript(
|
||||||
|
({ directory, server, sessionID }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({
|
||||||
|
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||||
|
lastProject: { local: directory },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:layout",
|
||||||
|
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:review-panel-v2",
|
||||||
|
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.window.browser.dat:tabs",
|
||||||
|
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory, server, sessionID },
|
||||||
|
)
|
||||||
|
}
|
||||||
41
packages/app/e2e/regression/legacy-new-session.spec.ts
Normal file
41
packages/app/e2e/regression/legacy-new-session.spec.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
|
||||||
|
const draftID = "draft_legacy_new_session"
|
||||||
|
const directory = "C:/OpenCode/LegacyNewSession"
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
|
test("redirects a draft to the legacy new-session route", async ({ page }) => {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: "proj_legacy_new_session",
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "legacy-new-session",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: { all: [], connected: [], default: {} },
|
||||||
|
sessions: [],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
await page.addInitScript(
|
||||||
|
({ directory, draftID, server }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: false } }))
|
||||||
|
localStorage.setItem("app-version.v1", JSON.stringify({ version: "1.17.20" }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.window.browser.dat:tabs",
|
||||||
|
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory, draftID, server },
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/new-session?draftId=${draftID}`)
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(`/${base64Encode(directory)}/session`)
|
||||||
|
await expect(page.locator("header[data-tauri-drag-region]")).toBeVisible()
|
||||||
|
await expect(page.locator('[data-component="prompt-input"]')).toBeVisible()
|
||||||
|
})
|
||||||
83
packages/app/e2e/regression/new-session-panel-corner.spec.ts
Normal file
83
packages/app/e2e/regression/new-session-panel-corner.spec.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectAppVisible } from "../utils/waits"
|
||||||
|
|
||||||
|
const draftID = "draft_new_session_panel_corner"
|
||||||
|
const directory = "C:/OpenCode/NewSessionPanelCorner"
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
|
test.use({
|
||||||
|
viewport: { width: 935, height: 522 },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matches the rounded panel corners to the dark new-session background", async ({ page }, testInfo) => {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: "proj_new_session_panel_corner",
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "new-session-panel-corner",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: { all: [], connected: [], default: {} },
|
||||||
|
sessions: [],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
await page.addInitScript(
|
||||||
|
({ directory, draftID, server }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem("opencode-theme-id", "oc-2")
|
||||||
|
localStorage.setItem("opencode-color-scheme", "dark")
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({
|
||||||
|
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||||
|
lastProject: { local: directory },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.window.browser.dat:tabs",
|
||||||
|
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory, draftID, server },
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/new-session?draftId=${draftID}`)
|
||||||
|
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
|
||||||
|
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
||||||
|
const panel = page.locator('main div[class*="rounded-[10px]"][class*="overflow-hidden"]')
|
||||||
|
await expect(panel).toHaveCount(1)
|
||||||
|
const box = await panel.boundingBox()
|
||||||
|
if (!box) throw new Error("New-session panel bounds are unavailable")
|
||||||
|
|
||||||
|
const screenshot = await page.screenshot({ path: testInfo.outputPath("new-session-dark.png") })
|
||||||
|
const corners = await page.evaluate(
|
||||||
|
async ({ source, points }) => {
|
||||||
|
const image = new Image()
|
||||||
|
image.src = source
|
||||||
|
await image.decode()
|
||||||
|
const canvas = document.createElement("canvas")
|
||||||
|
canvas.width = image.naturalWidth
|
||||||
|
canvas.height = image.naturalHeight
|
||||||
|
const context = canvas.getContext("2d")
|
||||||
|
if (!context) throw new Error("2D canvas is unavailable")
|
||||||
|
context.drawImage(image, 0, 0)
|
||||||
|
return points.map((point) => Array.from(context.getImageData(point.x, point.y, 1, 1).data))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: `data:image/png;base64,${screenshot.toString("base64")}`,
|
||||||
|
points: [
|
||||||
|
{ x: Math.floor(box.x), y: Math.floor(box.y) },
|
||||||
|
{ x: Math.ceil(box.x + box.width) - 1, y: Math.floor(box.y) },
|
||||||
|
{ x: Math.floor(box.x), y: Math.ceil(box.y + box.height) - 1 },
|
||||||
|
{ x: Math.ceil(box.x + box.width) - 1, y: Math.ceil(box.y + box.height) - 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(corners.every(([red, green, blue, alpha]) => red <= 8 && green <= 8 && blue <= 8 && alpha === 255)).toBe(true)
|
||||||
|
})
|
||||||
132
packages/app/e2e/regression/open-file-expand-folder.spec.ts
Normal file
132
packages/app/e2e/regression/open-file-expand-folder.spec.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/OpenFileExpand"
|
||||||
|
const projectID = "proj_open_file_expand"
|
||||||
|
const sessionID = "ses_open_file_expand"
|
||||||
|
const title = "Open file expand"
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 1440, height: 900 } })
|
||||||
|
|
||||||
|
test("expands a folder whose path has a trailing Windows separator", async ({ page }) => {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "open-file-expand",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "test" },
|
||||||
|
},
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: sessionID,
|
||||||
|
slug: sessionID,
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vcsDiff: [],
|
||||||
|
fileList: (path) => {
|
||||||
|
if (path === "frontend\\" || path === "frontend") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "app.ts",
|
||||||
|
path: "frontend\\app.ts",
|
||||||
|
absolute: `${directory}/frontend/app.ts`,
|
||||||
|
type: "file" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (path) return []
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: "frontend",
|
||||||
|
path: "frontend\\",
|
||||||
|
absolute: `${directory}/frontend`,
|
||||||
|
type: "directory" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "README.md",
|
||||||
|
path: "README.md",
|
||||||
|
absolute: `${directory}/README.md`,
|
||||||
|
type: "file" as const,
|
||||||
|
ignored: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.addInitScript(
|
||||||
|
({ directory, server, sessionID }) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"settings.v3",
|
||||||
|
JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({
|
||||||
|
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||||
|
lastProject: { local: directory },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:layout",
|
||||||
|
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:review-panel-v2",
|
||||||
|
JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.window.browser.dat:tabs",
|
||||||
|
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory, server, sessionID },
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
|
const panel = page.locator("#review-panel")
|
||||||
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
|
|
||||||
|
const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]')
|
||||||
|
await expect(sidebar).toBeVisible()
|
||||||
|
|
||||||
|
const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]')
|
||||||
|
await expect(frontendRow).toBeVisible()
|
||||||
|
await expect(frontendRow).toHaveAttribute("aria-expanded", "false")
|
||||||
|
await frontendRow.click()
|
||||||
|
await expect(frontendRow).toHaveAttribute("aria-expanded", "true")
|
||||||
|
|
||||||
|
const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]')
|
||||||
|
await expect(appRow).toBeVisible()
|
||||||
|
await appRow.click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectAppVisible } from "../utils/waits"
|
||||||
|
|
||||||
|
const NAMES = ["alpha-service", "bravo-web", "charlie-api", "delta-tools", "echo-infra", "foxtrot-docs"]
|
||||||
|
const worktrees = NAMES.map((name) => `/opencode-demo/${name}`)
|
||||||
|
|
||||||
|
// The sixth project sits outside the five-item recent cap, so it is only reachable if the
|
||||||
|
// dialog hands every recent project to the list filter instead of a pre-truncated slice.
|
||||||
|
const OUTSIDE_CAP = "foxtrot-docs"
|
||||||
|
|
||||||
|
// Dialog rows carry data-directory-path; the sidebar project list does not, so this
|
||||||
|
// scopes assertions to the picker instead of matching the sidebar entry of the same name.
|
||||||
|
const rows = (page: Page) => page.locator("[data-directory-path]")
|
||||||
|
const row = (page: Page, name: string) => page.locator(`[data-directory-path*="${name}"]`)
|
||||||
|
|
||||||
|
async function openProjectDialog(page: Page) {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
sessions: fixture.sessions,
|
||||||
|
provider: fixture.provider,
|
||||||
|
directory: fixture.directory,
|
||||||
|
project: fixture.project,
|
||||||
|
pageMessages,
|
||||||
|
fileList: () => [],
|
||||||
|
findFiles: () => [],
|
||||||
|
})
|
||||||
|
await page.addInitScript((dirs) => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({
|
||||||
|
projects: { local: dirs.map((worktree: string) => ({ worktree, expanded: false })) },
|
||||||
|
lastProject: {},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}, worktrees)
|
||||||
|
await page.goto("/")
|
||||||
|
const add = page.getByRole("button", { name: "Add project" }).first()
|
||||||
|
await expectAppVisible(add)
|
||||||
|
await add.click()
|
||||||
|
await expect(rows(page)).toHaveCount(5)
|
||||||
|
return page.getByRole("textbox").last()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("searches every recent project, not just the five most recent", async ({ page }) => {
|
||||||
|
const search = await openProjectDialog(page)
|
||||||
|
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
|
||||||
|
|
||||||
|
await search.fill("foxtrot")
|
||||||
|
|
||||||
|
await expect(row(page, OUTSIDE_CAP)).toHaveCount(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("still caps the idle recent list at five projects", async ({ page }) => {
|
||||||
|
await openProjectDialog(page)
|
||||||
|
|
||||||
|
await expect(row(page, NAMES[4])).toHaveCount(1)
|
||||||
|
await expect(row(page, OUTSIDE_CAP)).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectAppVisible } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/PromptInputV2Editing"
|
||||||
|
const projectID = "proj_prompt_input_v2_editing"
|
||||||
|
const sessionID = "ses_prompt_input_v2_editing"
|
||||||
|
|
||||||
|
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "prompt-input-v2-editing",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: { all: [], connected: [], default: {} },
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: sessionID,
|
||||||
|
slug: "prompt-input-v2-editing",
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title: "Prompt input V2 editing",
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
const composer = page.locator('[data-component="prompt-input-v2"]')
|
||||||
|
const input = composer.locator('[data-component="prompt-input"]')
|
||||||
|
await expectAppVisible(composer)
|
||||||
|
|
||||||
|
await input.fill("keep me")
|
||||||
|
await composer.getByRole("button", { name: "Add images and files" }).click()
|
||||||
|
await page.getByRole("menuitem", { name: "Commands" }).click()
|
||||||
|
await page.locator('[data-suggestion-id="model.choose"]').click()
|
||||||
|
|
||||||
|
await expect(input).toHaveText("keep me")
|
||||||
|
})
|
||||||
|
|
@ -54,18 +54,15 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
|
||||||
})
|
})
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
const composer = page.locator('[data-component="session-composer"]')
|
const composer = page.locator('[data-component="prompt-input-v2"]')
|
||||||
const input = composer.locator('[data-component="prompt-input"]')
|
const input = composer.locator('[data-component="prompt-input"]')
|
||||||
const control = composer.locator('[data-component="prompt-variant-control"]')
|
const control = composer.getByRole("button", { name: "Choose model variant" })
|
||||||
await expectAppVisible(composer)
|
await expectAppVisible(composer)
|
||||||
|
|
||||||
await idleComposer(page)
|
await idleComposer(page)
|
||||||
await expect(control).toBeHidden()
|
|
||||||
|
|
||||||
await composer.hover()
|
|
||||||
await expect(control).toBeVisible()
|
await expect(control).toBeVisible()
|
||||||
|
|
||||||
await control.locator('[data-action="prompt-model-variant"]').click()
|
await control.click()
|
||||||
const high = page.getByRole("menuitemradio", { name: "high" })
|
const high = page.getByRole("menuitemradio", { name: "high" })
|
||||||
await expect(high).toBeVisible()
|
await expect(high).toBeVisible()
|
||||||
await page.mouse.move(0, 0)
|
await page.mouse.move(0, 0)
|
||||||
|
|
|
||||||
302
packages/app/e2e/regression/remote-session-settings.spec.ts
Normal file
302
packages/app/e2e/regression/remote-session-settings.spec.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
|
import { installSseTransport } from "../utils/sse-transport"
|
||||||
|
import { currentSession } from "../utils/mock-server"
|
||||||
|
|
||||||
|
const serverA = "http://127.0.0.1:4096"
|
||||||
|
const serverB = "http://127.0.0.1:4097"
|
||||||
|
const directoryA = "C:/server-a"
|
||||||
|
const directoryB = "/home/server-b"
|
||||||
|
const sessionA = session("ses_server_a", directoryA, "Server A session")
|
||||||
|
const childSessionA = { ...session("ses_server_a_child", directoryA, "Server A child session"), parentID: sessionA.id }
|
||||||
|
const sessionB = session("ses_server_b", directoryB, "Server B session")
|
||||||
|
|
||||||
|
test("session settings use the remote server context", async ({ page }) => {
|
||||||
|
const permissionRequests: string[] = []
|
||||||
|
await mockServers(page, permissionRequests)
|
||||||
|
await configureServers(page)
|
||||||
|
|
||||||
|
await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
||||||
|
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||||
|
await page.keyboard.press("Control+,")
|
||||||
|
|
||||||
|
const dialog = page.locator(".settings-v2-dialog")
|
||||||
|
const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]')
|
||||||
|
const input = autoAccept.getByRole("switch")
|
||||||
|
await expect(autoAccept).toBeVisible()
|
||||||
|
await expect(input).toBeEnabled()
|
||||||
|
permissionRequests.length = 0
|
||||||
|
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||||
|
await expect(input).toBeChecked()
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
permissionRequests.some((request) => {
|
||||||
|
const url = new URL(request)
|
||||||
|
return url.origin === serverB && url.searchParams.get("directory") === directoryB
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
expect(permissionRequests.every((request) => new URL(request).origin === serverB)).toBe(true)
|
||||||
|
|
||||||
|
await dialog.getByRole("tab", { name: "Models" }).click()
|
||||||
|
await expect(dialog.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
|
||||||
|
await expect(dialog.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("auto-accept responds for an unfocused server session", async ({ page }) => {
|
||||||
|
const permissionRequests: string[] = []
|
||||||
|
const permissionResponses: PermissionResponse[] = []
|
||||||
|
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
||||||
|
server: serverA,
|
||||||
|
retry: 20,
|
||||||
|
})
|
||||||
|
await mockServers(page, permissionRequests, permissionResponses)
|
||||||
|
await configureServers(page, [
|
||||||
|
{ type: "session", server: serverA, sessionId: sessionA.id },
|
||||||
|
{ type: "session", server: serverB, sessionId: sessionB.id },
|
||||||
|
])
|
||||||
|
|
||||||
|
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||||
|
await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`)
|
||||||
|
await expect(page.getByText(sessionA.title).first()).toBeVisible()
|
||||||
|
await page.keyboard.press("Control+,")
|
||||||
|
const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]')
|
||||||
|
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||||
|
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
permissionRequests.some((request) => {
|
||||||
|
const url = new URL(request)
|
||||||
|
return url.origin === serverA && url.searchParams.get("directory") === directoryA
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
|
||||||
|
await page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`).click()
|
||||||
|
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||||
|
await expect(page.getByText(sessionB.title).first()).toBeVisible()
|
||||||
|
await transport.waitForConnection()
|
||||||
|
|
||||||
|
await transport.send({
|
||||||
|
directory: directoryA,
|
||||||
|
payload: {
|
||||||
|
id: "event-permission-background-a",
|
||||||
|
type: "permission.asked",
|
||||||
|
properties: {
|
||||||
|
id: "permission-background-a",
|
||||||
|
sessionID: sessionA.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["git status"],
|
||||||
|
metadata: {},
|
||||||
|
always: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => permissionResponses)
|
||||||
|
.toEqual([
|
||||||
|
{
|
||||||
|
origin: serverA,
|
||||||
|
directory: directoryA,
|
||||||
|
sessionID: sessionA.id,
|
||||||
|
permissionID: "permission-background-a",
|
||||||
|
body: { response: "once" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
await transport.send({
|
||||||
|
directory: directoryA,
|
||||||
|
payload: {
|
||||||
|
id: "event-permission-background-a-child",
|
||||||
|
type: "permission.asked",
|
||||||
|
properties: {
|
||||||
|
id: "permission-background-a-child",
|
||||||
|
sessionID: childSessionA.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["git diff"],
|
||||||
|
metadata: {},
|
||||||
|
always: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(() => permissionResponses)
|
||||||
|
.toEqual([
|
||||||
|
{
|
||||||
|
origin: serverA,
|
||||||
|
directory: directoryA,
|
||||||
|
sessionID: sessionA.id,
|
||||||
|
permissionID: "permission-background-a",
|
||||||
|
body: { response: "once" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
origin: serverA,
|
||||||
|
directory: directoryA,
|
||||||
|
sessionID: childSessionA.id,
|
||||||
|
permissionID: "permission-background-a-child",
|
||||||
|
body: { response: "once" },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
type PermissionResponse = {
|
||||||
|
origin: string
|
||||||
|
directory?: string
|
||||||
|
sessionID: string
|
||||||
|
permissionID: string
|
||||||
|
body: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configureServers(page: Page, tabs: { type: "session"; server: string; sessionId: string }[] = []) {
|
||||||
|
await page.addInitScript(
|
||||||
|
({ serverB, tabs }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||||
|
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify(tabs))
|
||||||
|
},
|
||||||
|
{ serverB, tabs },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockServers(page: Page, permissionRequests: string[], permissionResponses: PermissionResponse[] = []) {
|
||||||
|
await page.route("**/*", async (route) => {
|
||||||
|
const url = new URL(route.request().url())
|
||||||
|
if (url.origin !== serverA && url.origin !== serverB) return route.fallback()
|
||||||
|
const remote = url.origin === serverB
|
||||||
|
const directory = remote ? directoryB : directoryA
|
||||||
|
const sessions = remote ? [sessionB] : [sessionA, childSessionA]
|
||||||
|
const requestDirectory = url.searchParams.get("directory")
|
||||||
|
const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/)
|
||||||
|
if (route.request().method() === "POST" && response) {
|
||||||
|
permissionResponses.push({
|
||||||
|
origin: url.origin,
|
||||||
|
directory: requestDirectory ?? undefined,
|
||||||
|
sessionID: response[1]!,
|
||||||
|
permissionID: response[2]!,
|
||||||
|
body: route.request().postDataJSON(),
|
||||||
|
})
|
||||||
|
return json(route, true)
|
||||||
|
}
|
||||||
|
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
|
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||||
|
return sse(route)
|
||||||
|
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||||
|
if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent")
|
||||||
|
return json(route, { data: [] })
|
||||||
|
if (url.pathname === "/api/model/default") return json(route, { data: null })
|
||||||
|
if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname))
|
||||||
|
return json(route, { location: { directory }, data: [] })
|
||||||
|
if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] })
|
||||||
|
if (url.pathname === "/api/mcp/resource")
|
||||||
|
return json(route, { location: { directory }, data: { resources: [], templates: [] } })
|
||||||
|
if (url.pathname === "/api/project") {
|
||||||
|
return json(route, [
|
||||||
|
{
|
||||||
|
id: remote ? sessionB.projectID : "project-server-a",
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/project/current")
|
||||||
|
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory })
|
||||||
|
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
||||||
|
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||||
|
const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`)
|
||||||
|
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||||
|
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
|
||||||
|
return json(route, { data: [], cursor: {} })
|
||||||
|
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
|
||||||
|
if (current) return json(route, current)
|
||||||
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
|
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||||
|
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||||
|
if (url.pathname === "/permission") {
|
||||||
|
permissionRequests.push(url.toString())
|
||||||
|
return json(route, [])
|
||||||
|
}
|
||||||
|
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
|
||||||
|
return json(route, [])
|
||||||
|
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||||
|
if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
|
||||||
|
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||||
|
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
||||||
|
const project = {
|
||||||
|
id: remote ? sessionB.projectID : "project-server-a",
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
sandboxes: [],
|
||||||
|
}
|
||||||
|
return json(route, url.pathname === "/project" ? [project] : project)
|
||||||
|
}
|
||||||
|
if (url.pathname === "/path")
|
||||||
|
return json(route, {
|
||||||
|
state: directory,
|
||||||
|
config: directory,
|
||||||
|
worktree: directory,
|
||||||
|
directory,
|
||||||
|
home: directory,
|
||||||
|
})
|
||||||
|
if (url.pathname === "/api/path")
|
||||||
|
return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory })
|
||||||
|
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||||
|
if (url.pathname === "/api/vcs")
|
||||||
|
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
|
||||||
|
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
|
||||||
|
return json(route, {})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function session(id: string, directory: string, title: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
slug: id,
|
||||||
|
projectID: `project-${id}`,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function provider(id: string) {
|
||||||
|
const name = id === "server-b" ? "Server B" : "Server A"
|
||||||
|
return {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
name: `${name} Provider`,
|
||||||
|
models: {
|
||||||
|
[id]: {
|
||||||
|
id,
|
||||||
|
name: `${name} Model`,
|
||||||
|
family: id,
|
||||||
|
release_date: "2026-01-01",
|
||||||
|
limit: { context: 200_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: [id],
|
||||||
|
default: { providerID: id, modelID: id },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(route: Route, body: unknown, status = 200) {
|
||||||
|
return route.fulfill({
|
||||||
|
status,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sse(route: Route) {
|
||||||
|
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { currentSession } from "../utils/mock-server"
|
||||||
|
|
||||||
const serverA = "http://127.0.0.1:4096"
|
const serverA = "http://127.0.0.1:4096"
|
||||||
const serverB = "http://127.0.0.1:4097"
|
const serverB = "http://127.0.0.1:4097"
|
||||||
|
|
@ -57,11 +58,15 @@ async function mockServers(page: Page) {
|
||||||
const current = url.origin === serverA ? sessionA : sessionB
|
const current = url.origin === serverA ? sessionA : sessionB
|
||||||
const directory = url.searchParams.get("directory")
|
const directory = url.searchParams.get("directory")
|
||||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
return sse(route, url.pathname === "/api/event")
|
||||||
if (url.pathname === "/session/status")
|
if (url.pathname === "/global/health") return json(route, {}, 404)
|
||||||
return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {})
|
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||||
if (url.pathname === "/session") return json(route, [current])
|
if (url.pathname === "/api/session/active")
|
||||||
|
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
|
||||||
|
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||||
|
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||||
|
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||||
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
if (url.pathname === `/session/${current.id}`) return json(route, current)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
|
||||||
|
|
@ -90,7 +95,20 @@ async function mockServers(page: Page) {
|
||||||
directory: current.directory,
|
directory: current.directory,
|
||||||
home: current.directory,
|
home: current.directory,
|
||||||
})
|
})
|
||||||
|
if (url.pathname === "/api/path")
|
||||||
|
return json(route, {
|
||||||
|
state: current.directory,
|
||||||
|
config: current.directory,
|
||||||
|
worktree: current.directory,
|
||||||
|
directory: current.directory,
|
||||||
|
home: current.directory,
|
||||||
|
})
|
||||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||||
|
if (url.pathname === "/api/vcs")
|
||||||
|
return json(route, {
|
||||||
|
location: { directory: current.directory },
|
||||||
|
data: { branch: "main", defaultBranch: "main" },
|
||||||
|
})
|
||||||
return json(route, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +122,10 @@ function json(route: Route, body: unknown, status = 200) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function sse(route: Route) {
|
function sse(route: Route, current: boolean) {
|
||||||
return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" })
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/event-stream",
|
||||||
|
body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ test("stages a submitted line comment in the prompt context", async ({ page }) =
|
||||||
async function openReview(page: Page) {
|
async function openReview(page: Page) {
|
||||||
await page.setViewportSize({ width: 700, height: 900 })
|
await page.setViewportSize({ width: 700, height: 900 })
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: "proj_review_line_comment_regression",
|
id: "proj_review_line_comment_regression",
|
||||||
|
|
@ -143,9 +144,9 @@ async function openReview(page: Page) {
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff")
|
const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff")
|
||||||
await page.getByRole("tab", { name: "Changes" }).click()
|
await page.getByRole("tab", { name: "Changes" }).click()
|
||||||
expect(await (await diffResponse).json()).toHaveLength(1)
|
expect((await (await diffResponse).json()).data).toHaveLength(1)
|
||||||
|
|
||||||
const review = page.locator('[data-component="session-review"]')
|
const review = page.locator('[data-component="session-review"]')
|
||||||
await expectAppVisible(review)
|
await expectAppVisible(review)
|
||||||
|
|
|
||||||
163
packages/app/e2e/regression/review-open-file.spec.ts
Normal file
163
packages/app/e2e/regression/review-open-file.spec.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/ReviewOpenFile"
|
||||||
|
const projectID = "proj_review_open_file"
|
||||||
|
const sessionID = "ses_review_open_file"
|
||||||
|
const title = "Review open file"
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 1440, height: 900 } })
|
||||||
|
|
||||||
|
test("opens and searches project files inline", async ({ page }) => {
|
||||||
|
const searches: { query: string; dirs?: string; limit?: number }[] = []
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "open-file-project",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "test" },
|
||||||
|
},
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: sessionID,
|
||||||
|
slug: sessionID,
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vcsDiff: [fileDiff("src/changed.ts")],
|
||||||
|
fileList: (path) => {
|
||||||
|
if (path) return []
|
||||||
|
return [
|
||||||
|
fileNode("README.md"),
|
||||||
|
{ name: "src", path: "src", absolute: `${directory}/src`, type: "directory", ignored: false },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||||
|
findFiles: (input) => {
|
||||||
|
searches.push(input)
|
||||||
|
return input.query === "nested" ? ["src/nested.ts"] : []
|
||||||
|
},
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
await page.addInitScript(
|
||||||
|
({ directory, server, sessionID }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({
|
||||||
|
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||||
|
lastProject: { local: directory },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:layout",
|
||||||
|
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:review-panel-v2",
|
||||||
|
JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.window.browser.dat:tabs",
|
||||||
|
JSON.stringify([{ type: "session", server, sessionId: sessionID }]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory, server, sessionID },
|
||||||
|
)
|
||||||
|
|
||||||
|
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
|
const panel = page.locator("#review-panel")
|
||||||
|
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
|
||||||
|
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
|
||||||
|
const contextButton = page.getByRole("button", { name: "View context usage" })
|
||||||
|
await contextButton.click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
||||||
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(sidebarToggle).toBeDisabled()
|
||||||
|
await expect(sidebar).toBeVisible()
|
||||||
|
await contextButton.click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(sidebar).toBeHidden()
|
||||||
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
|
const filter = panel.getByRole("combobox", { name: "Filter files" })
|
||||||
|
await expect(filter).toBeFocused()
|
||||||
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(panel.getByText("open-file-project", { exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
await panel.getByRole("button", { name: "README.md" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(sidebarToggle).toBeEnabled()
|
||||||
|
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||||
|
await expect(sidebar).toHaveCount(0)
|
||||||
|
|
||||||
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
|
||||||
|
await expect(sidebar).toBeVisible()
|
||||||
|
await filter.fill("nested")
|
||||||
|
const result = panel.getByRole("option", { name: /nested\.ts/ })
|
||||||
|
await expect(result).toBeVisible()
|
||||||
|
const resultID = await result.getAttribute("id")
|
||||||
|
expect(resultID).toBeTruthy()
|
||||||
|
await expect(filter).toHaveAttribute("aria-activedescendant", resultID!)
|
||||||
|
await filter.press("Enter")
|
||||||
|
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(sidebarToggle).toBeEnabled()
|
||||||
|
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
|
||||||
|
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 })
|
||||||
|
|
||||||
|
await panel.getByRole("button", { name: "Open file" }).click()
|
||||||
|
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
|
||||||
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "")
|
||||||
|
await expect(sidebarToggle).toBeDisabled()
|
||||||
|
await panel.locator("#session-side-panel-review-tab").click()
|
||||||
|
await expect(sidebarToggle).toBeEnabled()
|
||||||
|
await panel.getByRole("tab", { name: "Open file" }).click()
|
||||||
|
await page.keyboard.press("Control+w")
|
||||||
|
await expect(panel.getByRole("tab", { name: "Open file" })).toHaveCount(0)
|
||||||
|
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
|
||||||
|
})
|
||||||
|
|
||||||
|
function fileNode(path: string) {
|
||||||
|
return {
|
||||||
|
name: path,
|
||||||
|
path,
|
||||||
|
absolute: `${directory}/${path}`,
|
||||||
|
type: "file",
|
||||||
|
ignored: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileDiff(file: string) {
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
before: "before\n",
|
||||||
|
after: "after\n",
|
||||||
|
additions: 1,
|
||||||
|
deletions: 1,
|
||||||
|
status: "modified",
|
||||||
|
}
|
||||||
|
}
|
||||||
153
packages/app/e2e/regression/review-state-persistence.spec.ts
Normal file
153
packages/app/e2e/regression/review-state-persistence.spec.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/ReviewStatePersistence"
|
||||||
|
const projectID = "proj_review_state_persistence"
|
||||||
|
const sessionA = "ses_review_state_a"
|
||||||
|
const sessionB = "ses_review_state_b"
|
||||||
|
const titleA = "Alpha review state"
|
||||||
|
const titleB = "Beta review state"
|
||||||
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 1440, height: 900 } })
|
||||||
|
|
||||||
|
test("restores review mode and selected file per session", async ({ page }) => {
|
||||||
|
await setup(page)
|
||||||
|
await page.goto(sessionHref(sessionA))
|
||||||
|
await expectSessionTitle(page, titleA)
|
||||||
|
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||||
|
|
||||||
|
await selectMode(page, "Git changes", "Branch changes")
|
||||||
|
await selectFile(page, "beta.ts")
|
||||||
|
|
||||||
|
await switchSession(page, titleB)
|
||||||
|
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
|
||||||
|
await selectFile(page, "gamma.ts")
|
||||||
|
|
||||||
|
await switchSession(page, titleA)
|
||||||
|
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
|
||||||
|
await expectSelectedFile(page, "beta.ts")
|
||||||
|
await selectMode(page, "Branch changes", "Git changes")
|
||||||
|
await expectSelectedFile(page, "alpha.ts")
|
||||||
|
await selectMode(page, "Git changes", "Branch changes")
|
||||||
|
await expectSelectedFile(page, "beta.ts")
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
await expectSessionTitle(page, titleA)
|
||||||
|
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
|
||||||
|
await expectSelectedFile(page, "beta.ts")
|
||||||
|
|
||||||
|
await switchSession(page, titleB)
|
||||||
|
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
|
||||||
|
await expectSelectedFile(page, "gamma.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
async function selectMode(page: Page, current: string, next: string) {
|
||||||
|
await page.getByRole("button", { name: current }).click()
|
||||||
|
await page.getByRole("option", { name: next }).dispatchEvent("click")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectFile(page: Page, file: string) {
|
||||||
|
await page.getByRole("button", { name: file }).click()
|
||||||
|
await expectSelectedFile(page, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSelectedFile(page: Page, file: string) {
|
||||||
|
await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchSession(page: Page, title: string) {
|
||||||
|
await page.locator("[data-titlebar-tab-slot]", { hasText: title }).click()
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setup(page: Page) {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v1",
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "review-state-persistence",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "test" },
|
||||||
|
},
|
||||||
|
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ branch: "feature", default_branch: "dev" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.route("**/vcs/diff**", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(
|
||||||
|
new URL(route.request().url()).searchParams.get("mode") === "branch"
|
||||||
|
? [diff("src/alpha.ts"), diff("src/beta.ts")]
|
||||||
|
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.addInitScript(
|
||||||
|
({ directory, server, sessions }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:server",
|
||||||
|
JSON.stringify({
|
||||||
|
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||||
|
lastProject: { local: directory },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.window.browser.dat:tabs",
|
||||||
|
JSON.stringify(sessions.map((sessionId: string) => ({ type: "session", server, sessionId }))),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ directory, server, sessions: [sessionA, sessionB] },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function session(id: string, title: string, created: number) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
slug: id,
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created, updated: created },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function diff(file: string) {
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
additions: 1,
|
||||||
|
deletions: 1,
|
||||||
|
status: "modified",
|
||||||
|
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionHref(sessionID: string) {
|
||||||
|
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,9 @@ const sessionB = "ses_review_tab_b"
|
||||||
const titleA = "Alpha session"
|
const titleA = "Alpha session"
|
||||||
const titleB = "Beta session"
|
const titleB = "Beta session"
|
||||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||||
|
const diffs = Array.from({ length: 2_740 }, (_, index) =>
|
||||||
|
fileDiff(`src/generated-${String(index).padStart(4, "0")}.ts`),
|
||||||
|
)
|
||||||
// Marks the review pane DOM node so a remount (fresh node) is detectable.
|
// Marks the review pane DOM node so a remount (fresh node) is detectable.
|
||||||
const PROBE = "original"
|
const PROBE = "original"
|
||||||
|
|
||||||
|
|
@ -25,20 +28,34 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac
|
||||||
await expectSessionTitle(page, titleA)
|
await expectSessionTitle(page, titleA)
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||||
|
const reviewTab = page.locator("#session-side-panel-review-tab")
|
||||||
|
const reviewTabPanel = page.locator("#session-side-panel-review-tabpanel")
|
||||||
|
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
|
||||||
|
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
|
||||||
const review = page.locator('#review-panel [data-component="session-review-v2"]')
|
const review = page.locator('#review-panel [data-component="session-review-v2"]')
|
||||||
await expectAppVisible(review)
|
await expectAppVisible(review)
|
||||||
await expectAppVisible(page.getByRole("button", { name: /example\.ts/ }))
|
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
||||||
await writeProbe(page)
|
await writeProbe(page)
|
||||||
|
|
||||||
await switchTab(page, titleB)
|
await switchTab(page, titleB)
|
||||||
await expectSessionTitle(page, titleB)
|
await expectSessionTitle(page, titleB)
|
||||||
await expectAppVisible(review)
|
await expectAppVisible(review)
|
||||||
|
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
||||||
expect(await readProbe(page)).toBe(PROBE)
|
expect(await readProbe(page)).toBe(PROBE)
|
||||||
|
|
||||||
await switchTab(page, titleA)
|
await switchTab(page, titleA)
|
||||||
await expectSessionTitle(page, titleA)
|
await expectSessionTitle(page, titleA)
|
||||||
await expectAppVisible(review)
|
await expectAppVisible(review)
|
||||||
|
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
||||||
expect(await readProbe(page)).toBe(PROBE)
|
expect(await readProbe(page)).toBe(PROBE)
|
||||||
|
|
||||||
|
const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
||||||
|
await viewport.hover()
|
||||||
|
await page.mouse.wheel(0, 100_000)
|
||||||
|
await expect
|
||||||
|
.poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||||
|
.toBeLessThanOrEqual(1)
|
||||||
|
await expect(page.getByRole("button", { name: "generated-2739.ts" })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
type Probed = HTMLElement & { __e2eProbe?: string }
|
type Probed = HTMLElement & { __e2eProbe?: string }
|
||||||
|
|
@ -80,16 +97,7 @@ async function setup(page: Page) {
|
||||||
default: { providerID: "opencode", modelID: "test" },
|
default: { providerID: "opencode", modelID: "test" },
|
||||||
},
|
},
|
||||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||||
vcsDiff: [
|
vcsDiff: diffs,
|
||||||
{
|
|
||||||
file: "src/example.ts",
|
|
||||||
additions: 1,
|
|
||||||
deletions: 1,
|
|
||||||
status: "modified",
|
|
||||||
patch:
|
|
||||||
"diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -127,3 +135,13 @@ function session(id: string, title: string, created: number) {
|
||||||
function sessionHref(sessionID: string) {
|
function sessionHref(sessionID: string) {
|
||||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fileDiff(file: string) {
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
additions: 1,
|
||||||
|
deletions: 1,
|
||||||
|
status: "modified",
|
||||||
|
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
306
packages/app/e2e/regression/review-terminal-stacked.spec.ts
Normal file
306
packages/app/e2e/regression/review-terminal-stacked.spec.ts
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/ReviewTerminalStacked"
|
||||||
|
const projectID = "proj_review_terminal_stacked"
|
||||||
|
const sessionID = "ses_review_terminal_stacked"
|
||||||
|
const title = "Review terminal stacked"
|
||||||
|
const branchDiffs = [
|
||||||
|
fileDiff(".github/actions/setup-bun/action.yml", 7),
|
||||||
|
...Array.from({ length: 2_739 }, (_, index) =>
|
||||||
|
fileDiff(
|
||||||
|
`src/branch/d${String(Math.floor(index / 100)).padStart(5, "0")}/generated-${String(index).padStart(4, "0")}.ts`,
|
||||||
|
100,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
const events: Array<{ directory: string; payload: Record<string, unknown> }> = []
|
||||||
|
const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } }
|
||||||
|
let detailVersion = 1
|
||||||
|
let detailFailures = 1
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 })
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v1",
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "review-terminal-stacked",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "test" },
|
||||||
|
},
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: sessionID,
|
||||||
|
slug: "review-terminal-stacked",
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title,
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sessionStatus: () => sessionStatus,
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
events: () => events.splice(0, 1),
|
||||||
|
eventRetry: 16,
|
||||||
|
})
|
||||||
|
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
branch: "review-pane-performance",
|
||||||
|
default_branch: "dev",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.route("**/vcs/diff**", (route) => {
|
||||||
|
const url = new URL(route.request().url())
|
||||||
|
const scope = url.searchParams.get("directory")?.replaceAll("\\", "/")
|
||||||
|
const detail = scope?.endsWith("/src/branch/d00027")
|
||||||
|
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(
|
||||||
|
url.searchParams.get("mode") === "branch"
|
||||||
|
? detail
|
||||||
|
? branchDiffs
|
||||||
|
.filter((diff) => diff.file.startsWith("src/branch/d00027/"))
|
||||||
|
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
|
||||||
|
: branchDiffs
|
||||||
|
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await page.route("**/pty*", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
location: { directory, project: { id: projectID, directory } },
|
||||||
|
data: {
|
||||||
|
id: "pty_review_terminal",
|
||||||
|
title: "Terminal 1",
|
||||||
|
command: "cmd.exe",
|
||||||
|
args: [],
|
||||||
|
cwd: directory,
|
||||||
|
status: "running",
|
||||||
|
pid: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.route("**/pty/pty_review_terminal*", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
location: { directory, project: { id: projectID, directory } },
|
||||||
|
data: {
|
||||||
|
id: "pty_review_terminal",
|
||||||
|
title: "Terminal 1",
|
||||||
|
command: "cmd.exe",
|
||||||
|
args: [],
|
||||||
|
cwd: directory,
|
||||||
|
status: "running",
|
||||||
|
pid: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.route("**/pty/pty_review_terminal/connect-token*", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
location: { directory, project: { id: projectID, directory } },
|
||||||
|
data: { ticket: "e2e-ticket", expires_in: 60 },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.global.dat:layout",
|
||||||
|
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
await expect(page.locator("#review-panel")).toBeVisible()
|
||||||
|
await expectTree(page, 8, "git-0.ts")
|
||||||
|
|
||||||
|
await selectMode(page, "Git changes", "Branch changes")
|
||||||
|
await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
|
||||||
|
await page.keyboard.press("Control+Backquote")
|
||||||
|
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||||
|
await expectTree(page, 2_773, "action.yml")
|
||||||
|
await expectStackGeometry(page)
|
||||||
|
|
||||||
|
const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
||||||
|
await treeViewport.hover()
|
||||||
|
await page.mouse.wheel(0, 100_000)
|
||||||
|
await expect
|
||||||
|
.poll(() => treeViewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||||
|
.toBeLessThanOrEqual(1)
|
||||||
|
const lastFile = page.getByRole("button", { name: "generated-2738.ts" })
|
||||||
|
await expect(lastFile).toBeVisible()
|
||||||
|
const bottomGap = await lastFile.evaluate((element) => {
|
||||||
|
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
|
||||||
|
return viewport.bottom - element.getBoundingClientRect().bottom
|
||||||
|
})
|
||||||
|
expect(bottomGap).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(bottomGap).toBeLessThanOrEqual(16)
|
||||||
|
const lazyDiff = page.waitForRequest((request) => {
|
||||||
|
const url = new URL(request.url())
|
||||||
|
return (
|
||||||
|
url.pathname === "/vcs/diff" &&
|
||||||
|
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
await lastFile.click()
|
||||||
|
await lazyDiff
|
||||||
|
const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]')
|
||||||
|
await expect(preview).toContainText("after-1")
|
||||||
|
detailVersion = 2
|
||||||
|
sessionStatus[sessionID] = { type: "busy" }
|
||||||
|
events.push(statusEvent("busy"))
|
||||||
|
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||||
|
const refreshedDiff = page.waitForRequest((request) => {
|
||||||
|
const url = new URL(request.url())
|
||||||
|
return (
|
||||||
|
url.pathname === "/vcs/diff" &&
|
||||||
|
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
sessionStatus[sessionID] = { type: "idle" }
|
||||||
|
events.push(statusEvent("idle"))
|
||||||
|
await refreshedDiff
|
||||||
|
await expect(preview).toContainText("after-2")
|
||||||
|
await selectMode(page, "Branch changes", "Git changes")
|
||||||
|
await expectTree(page, 8, "git-0.ts")
|
||||||
|
await page.getByRole("button", { name: "git-0.ts" }).click()
|
||||||
|
await selectMode(page, "Git changes", "Branch changes")
|
||||||
|
await expectTree(page, 2_773, "action.yml")
|
||||||
|
|
||||||
|
const filter = page.getByRole("searchbox", { name: "Filter files" })
|
||||||
|
await filter.fill("generated-2738")
|
||||||
|
await expectTree(page, 1, "generated-2738.ts")
|
||||||
|
await filter.fill("")
|
||||||
|
await expectTree(page, 2_773, "action.yml")
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Toggle file tree" }).click()
|
||||||
|
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
|
||||||
|
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
|
||||||
|
await page.getByRole("button", { name: "Toggle file tree" }).click()
|
||||||
|
await expectTree(page, 2_773, "action.yml")
|
||||||
|
|
||||||
|
await page.keyboard.press("Control+Backquote")
|
||||||
|
await expect(page.locator("#terminal-panel")).toHaveCount(0)
|
||||||
|
await expectTree(page, 2_773, "action.yml")
|
||||||
|
await page.keyboard.press("Control+Backquote")
|
||||||
|
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||||
|
await expectTree(page, 2_773, "action.yml")
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||||
|
await expect(page.locator("#review-panel")).toHaveCount(0)
|
||||||
|
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||||
|
await expectTree(page, 2_773, "action.yml")
|
||||||
|
await page.setViewportSize({ width: 1_000, height: 700 })
|
||||||
|
await expectTree(page, 2_773, "action.yml")
|
||||||
|
await expectStackGeometry(page)
|
||||||
|
await page.setViewportSize({ width: 1_000, height: 120 })
|
||||||
|
await page.setViewportSize({ width: 1_400, height: 900 })
|
||||||
|
await expectTree(page, 2_773, "action.yml")
|
||||||
|
await expectStackGeometry(page)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function selectMode(page: Page, current: string, next: string) {
|
||||||
|
await page.getByRole("button", { name: current }).click()
|
||||||
|
const option = page.getByRole("option", { name: next })
|
||||||
|
await expect(option).toBeVisible()
|
||||||
|
await option.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectTree(page: Page, total: number, file: string) {
|
||||||
|
await expectMountedTree(page, total)
|
||||||
|
await expect(page.getByRole("button", { name: file })).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectMountedTree(page: Page, total: number) {
|
||||||
|
const tree = page.locator('#review-panel [data-component="file-tree-v2"]')
|
||||||
|
await expect(tree).toHaveAttribute("data-total-rows", String(total))
|
||||||
|
await expect
|
||||||
|
.poll(() => tree.evaluate((element) => element.querySelectorAll('[data-slot="file-tree-v2-row"]').length))
|
||||||
|
.toBeGreaterThan(0)
|
||||||
|
const state = await tree.evaluate((element) => ({
|
||||||
|
root: element.getBoundingClientRect().height,
|
||||||
|
viewport: element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect().height,
|
||||||
|
rows: element.querySelectorAll('[data-slot="file-tree-v2-row"]').length,
|
||||||
|
}))
|
||||||
|
expect(state.viewport).toBeGreaterThan(0)
|
||||||
|
expect(state.root).toBeGreaterThan(0)
|
||||||
|
expect(state.rows).toBeGreaterThan(0)
|
||||||
|
expect(state.rows).toBeLessThanOrEqual(60)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectStackGeometry(page: Page) {
|
||||||
|
const geometry = await page.evaluate(() => {
|
||||||
|
const review = document.querySelector<HTMLElement>("#review-panel")!
|
||||||
|
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
|
||||||
|
const reviewParent = review.parentElement!.getBoundingClientRect()
|
||||||
|
const terminalParent = terminal.parentElement!.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
review: review.getBoundingClientRect().height,
|
||||||
|
reviewParent: reviewParent.height,
|
||||||
|
terminal: terminal.getBoundingClientRect().height,
|
||||||
|
terminalParent: terminalParent.height,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1)
|
||||||
|
expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64Encode(value: string) {
|
||||||
|
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusEvent(type: "busy" | "idle") {
|
||||||
|
return {
|
||||||
|
directory,
|
||||||
|
payload: { type: "session.status", properties: { sessionID, status: { type } } },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileDiff(file: string, additions: number, loaded = true, version = 1) {
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
additions,
|
||||||
|
deletions: 0,
|
||||||
|
status: "modified",
|
||||||
|
patch: loaded
|
||||||
|
? `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after-${version}'\n`
|
||||||
|
: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async (
|
||||||
const pathBlocked = new Promise<void>((resolve) => {
|
const pathBlocked = new Promise<void>((resolve) => {
|
||||||
releasePath = resolve
|
releasePath = resolve
|
||||||
})
|
})
|
||||||
await page.route("**/path?*", async (route) => {
|
await page.route("**/api/path?*", async (route) => {
|
||||||
if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback()
|
if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback()
|
||||||
await pathBlocked
|
await pathBlocked
|
||||||
return route.fallback()
|
return route.fallback()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { expect, test, type Page } from "@playwright/test"
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { installSseTransport } from "../utils/sse-transport"
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const directory = "C:/OpenCode/RequestDocks"
|
const directory = "C:/OpenCode/RequestDocks"
|
||||||
|
|
@ -41,7 +42,8 @@ test("shows a pending question dock", async ({ page }) => {
|
||||||
const rejectRequests: string[] = []
|
const rejectRequests: string[] = []
|
||||||
page.on("request", (request) => {
|
page.on("request", (request) => {
|
||||||
if (request.method() !== "POST") return
|
if (request.method() !== "POST") return
|
||||||
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
|
if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`)
|
||||||
|
rejectRequests.push(request.url())
|
||||||
})
|
})
|
||||||
|
|
||||||
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
||||||
|
|
@ -63,7 +65,9 @@ test("shows a pending question dock", async ({ page }) => {
|
||||||
|
|
||||||
await question.getByRole("radio", { name: /Minimal/ }).click()
|
await question.getByRole("radio", { name: /Minimal/ }).click()
|
||||||
const reply = page.waitForRequest(
|
const reply = page.waitForRequest(
|
||||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
|
(request) =>
|
||||||
|
request.method() === "POST" &&
|
||||||
|
new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`,
|
||||||
)
|
)
|
||||||
await question.getByRole("button", { name: "Submit" }).click()
|
await question.getByRole("button", { name: "Submit" }).click()
|
||||||
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
||||||
|
|
@ -96,8 +100,69 @@ test("shows a pending permission dock", async ({ page }) => {
|
||||||
const reply = page.waitForRequest((request) => request.method() === "POST")
|
const reply = page.waitForRequest((request) => request.method() === "POST")
|
||||||
await permission.getByRole("button", { name: "Allow once" }).click()
|
await permission.getByRole("button", { name: "Allow once" }).click()
|
||||||
const request = await reply
|
const request = await reply
|
||||||
expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`)
|
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
|
||||||
expect(request.postDataJSON()).toEqual({ response: "once" })
|
expect(request.postDataJSON()).toEqual({ reply: "once" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
||||||
|
const transport = await installSseTransport(page, {
|
||||||
|
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||||
|
retry: 20,
|
||||||
|
})
|
||||||
|
await mockServer(page, { questions: [] })
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
await transport.waitForConnection()
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
|
||||||
|
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
|
||||||
|
const draft = "keep the caret at the end"
|
||||||
|
await editor.fill(draft)
|
||||||
|
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
|
||||||
|
for (let index = 0; index < 4; index++) await page.keyboard.press("ArrowLeft")
|
||||||
|
const cursor = draft.length - 4
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
editor.evaluate((element) => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection?.rangeCount || !element.contains(selection.anchorNode)) return -1
|
||||||
|
const range = selection.getRangeAt(0).cloneRange()
|
||||||
|
range.selectNodeContents(element)
|
||||||
|
range.setEnd(selection.anchorNode!, selection.anchorOffset)
|
||||||
|
return range.toString().length
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe(cursor)
|
||||||
|
await transport.send({
|
||||||
|
directory,
|
||||||
|
payload: {
|
||||||
|
type: "question.asked",
|
||||||
|
properties: {
|
||||||
|
id: "question-caret",
|
||||||
|
sessionID,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
header: "Continue",
|
||||||
|
question: "Continue?",
|
||||||
|
options: [{ label: "Yes", description: "Continue the session" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tool: { messageID: "message-caret", callID: "call-caret" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||||
|
await expect(question).toBeVisible()
|
||||||
|
await expect(editor).toHaveCount(0)
|
||||||
|
|
||||||
|
await transport.send({
|
||||||
|
directory,
|
||||||
|
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
|
||||||
|
})
|
||||||
|
await expect(question).toHaveCount(0)
|
||||||
|
await expect(editor).toBeVisible()
|
||||||
|
await page.keyboard.press("x")
|
||||||
|
|
||||||
|
await expect(editor).toHaveText(`${draft.slice(0, cursor)}x${draft.slice(cursor)}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function mockServer(
|
async function mockServer(
|
||||||
|
|
@ -108,6 +173,7 @@ async function mockServer(
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
|
import {
|
||||||
|
assistantMessage,
|
||||||
|
directory,
|
||||||
|
messageUpdated,
|
||||||
|
project,
|
||||||
|
session,
|
||||||
|
sessionID,
|
||||||
|
status,
|
||||||
|
textPart,
|
||||||
|
title,
|
||||||
|
userID,
|
||||||
|
userMessage,
|
||||||
|
} from "../performance/timeline-stability/fixture"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { installSseTransport } from "../utils/sse-transport"
|
||||||
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
|
const initialPageSize = 20
|
||||||
|
const historyPageSize = 200
|
||||||
|
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
|
||||||
|
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
|
||||||
|
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
||||||
|
parentID: userID,
|
||||||
|
created: 1700000001000 + index * 1_000,
|
||||||
|
completed: index < initialPageSize,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const messages = [userMessage(), ...assistants]
|
||||||
|
const lastAssistant = assistants.at(-1)!
|
||||||
|
const lastPartID = assistants.at(-1)!.parts[0]!.id
|
||||||
|
const userPartID = `prt_${userID}_text`
|
||||||
|
const completed = {
|
||||||
|
...lastAssistant.info,
|
||||||
|
time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
|
||||||
|
}
|
||||||
|
const scenarios = [
|
||||||
|
{ name: "completion", info: completed, idleFirst: false, interrupted: false },
|
||||||
|
{
|
||||||
|
name: "interruption",
|
||||||
|
info: { ...completed, error: { name: "MessageAbortedError", data: { message: "Stopped" } } },
|
||||||
|
idleFirst: true,
|
||||||
|
interrupted: true,
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 646, height: 1385 } })
|
||||||
|
|
||||||
|
for (const scenario of scenarios) {
|
||||||
|
test(`keeps visible timeline content visible through ${scenario.name}`, async ({ page }) => {
|
||||||
|
const requests: { before?: string; phase: "start" | "end" }[] = []
|
||||||
|
const pages: { before?: string; limit: number }[] = []
|
||||||
|
const roots: { sessionID: string; messageID: string }[] = []
|
||||||
|
const sequence: string[] = []
|
||||||
|
const history = Promise.withResolvers<void>()
|
||||||
|
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
||||||
|
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||||
|
retry: 20,
|
||||||
|
})
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: project(),
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: {
|
||||||
|
"claude-opus-4-6": {
|
||||||
|
id: "claude-opus-4-6",
|
||||||
|
name: "Claude Opus 4.6",
|
||||||
|
limit: { context: 200_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||||
|
},
|
||||||
|
sessions: [session()],
|
||||||
|
sessionStatus: { [sessionID]: { type: "busy" } },
|
||||||
|
beforeMessagesResponse: (request) => (request.before ? history.promise : Promise.resolve()),
|
||||||
|
onMessages: (request) => {
|
||||||
|
requests.push(request)
|
||||||
|
sequence.push(`messages:${request.phase}:${request.before ?? "latest"}`)
|
||||||
|
},
|
||||||
|
onMessage: (request) => {
|
||||||
|
roots.push(request)
|
||||||
|
sequence.push(`message:${request.messageID}`)
|
||||||
|
},
|
||||||
|
message: (requestedSessionID, messageID) => {
|
||||||
|
if (requestedSessionID !== sessionID) return
|
||||||
|
return messages.find((item) => item.info.id === messageID)
|
||||||
|
},
|
||||||
|
pageMessages: (_, limit, before) => {
|
||||||
|
pages.push({ before, limit })
|
||||||
|
const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length
|
||||||
|
const start = Math.max(0, end - limit)
|
||||||
|
return {
|
||||||
|
items: messages.slice(start, end),
|
||||||
|
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const visibleParts = () => {
|
||||||
|
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
||||||
|
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
||||||
|
const view = viewport?.getBoundingClientRect()
|
||||||
|
if (!viewport || !view) return []
|
||||||
|
return [...viewport.querySelectorAll<HTMLElement>("[data-timeline-part-id]")]
|
||||||
|
.filter((part) => {
|
||||||
|
const rect = part.getBoundingClientRect()
|
||||||
|
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
|
||||||
|
})
|
||||||
|
.flatMap((part) => (part.dataset.timelinePartId ? [part.dataset.timelinePartId] : []))
|
||||||
|
}
|
||||||
|
const state = {
|
||||||
|
armed: false,
|
||||||
|
hidden: false,
|
||||||
|
visibleParts: [] as string[],
|
||||||
|
samples: 0,
|
||||||
|
stop: false,
|
||||||
|
arm() {
|
||||||
|
state.visibleParts = visibleParts()
|
||||||
|
state.armed = true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
|
||||||
|
const sample = () => {
|
||||||
|
if (state.armed) {
|
||||||
|
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
||||||
|
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
||||||
|
const view = viewport?.getBoundingClientRect()
|
||||||
|
const visible = (partID: string) => {
|
||||||
|
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${CSS.escape(partID)}"]`)
|
||||||
|
const rect = part?.getBoundingClientRect()
|
||||||
|
return (
|
||||||
|
!!rect && !!view && rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!virtual || state.visibleParts.length === 0 || state.visibleParts.some((partID) => !visible(partID)))
|
||||||
|
state.hidden = true
|
||||||
|
state.samples++
|
||||||
|
}
|
||||||
|
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
|
||||||
|
}
|
||||||
|
requestAnimationFrame(() => setTimeout(sample, 0))
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
await transport.waitForConnection()
|
||||||
|
await expectSessionTitle(page, title)
|
||||||
|
await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
|
||||||
|
await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
|
||||||
|
await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2)
|
||||||
|
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
|
||||||
|
expect(sequence.slice(0, 4)).toEqual([
|
||||||
|
"messages:start:latest",
|
||||||
|
"messages:end:latest",
|
||||||
|
`message:${userID}`,
|
||||||
|
`messages:start:${messages.at(-initialPageSize)!.info.id}`,
|
||||||
|
])
|
||||||
|
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize)
|
||||||
|
await page.evaluate(() => {
|
||||||
|
;(
|
||||||
|
window as Window & {
|
||||||
|
__historyRootProbe?: { arm(): void }
|
||||||
|
}
|
||||||
|
).__historyRootProbe!.arm()
|
||||||
|
})
|
||||||
|
await waitForProbeSamples(page, 0)
|
||||||
|
expect(await visibleContentHidden(page)).toBe(false)
|
||||||
|
const beforeHistory = await probeSamples(page)
|
||||||
|
history.resolve()
|
||||||
|
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length)
|
||||||
|
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
|
||||||
|
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||||
|
await waitForProbeSamples(page, beforeHistory)
|
||||||
|
expect(pages).toEqual([
|
||||||
|
{ before: undefined, limit: initialPageSize },
|
||||||
|
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
|
||||||
|
])
|
||||||
|
expect(roots).toEqual([{ sessionID, messageID: userID }])
|
||||||
|
|
||||||
|
const message = messageUpdated(scenario.info)
|
||||||
|
const idle = status("idle")
|
||||||
|
for (const event of scenario.idleFirst ? [idle, message] : [message, idle]) {
|
||||||
|
const beforeEvent = await probeSamples(page)
|
||||||
|
await transport.send(event)
|
||||||
|
if (event === idle) await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
|
||||||
|
if (event === message && scenario.interrupted)
|
||||||
|
await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
|
||||||
|
await waitForProbeSamples(page, beforeEvent)
|
||||||
|
const current = await timelineState(page)
|
||||||
|
expect(current, JSON.stringify(current)).toMatchObject({ virtual: true })
|
||||||
|
expect(current.rows, JSON.stringify(current)).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(requests[0]).toEqual({ before: undefined, phase: "start", sessionID })
|
||||||
|
expect(requests[1]).toEqual({ before: undefined, phase: "end", sessionID })
|
||||||
|
await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
|
||||||
|
await expect(page.locator('[data-timeline-row="bottom-spacer"]')).toBeVisible()
|
||||||
|
if (scenario.interrupted) await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
|
||||||
|
expect(
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const state = (window as Window & { __historyRootProbe?: { hidden: boolean; stop: boolean } })
|
||||||
|
.__historyRootProbe!
|
||||||
|
state.stop = true
|
||||||
|
return state.hidden
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function timelineState(page: Page) {
|
||||||
|
return page.evaluate(() => ({
|
||||||
|
virtual: !!document.querySelector("[data-timeline-virtual-content]"),
|
||||||
|
rows: document.querySelectorAll("[data-timeline-key]").length,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function probeSamples(page: Page) {
|
||||||
|
return page.evaluate(
|
||||||
|
() => (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForProbeSamples(page: Page, after: number) {
|
||||||
|
await page.waitForFunction(
|
||||||
|
(after) =>
|
||||||
|
(window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples >= after + 3,
|
||||||
|
after,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleContentHidden(page: Page) {
|
||||||
|
return page.evaluate(
|
||||||
|
() => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,23 @@ for (const expanded of [false, true]) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("shows and expands a running shell command without shimmering it", async ({ page }) => {
|
||||||
|
const id = "prt_shell_running_command"
|
||||||
|
const command = "sleep 10 && echo done"
|
||||||
|
await setupTimeline(page, {
|
||||||
|
messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })],
|
||||||
|
settings: { shellToolPartsExpanded: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const tool = page.locator(`[data-timeline-part-id="${id}"]`)
|
||||||
|
await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true")
|
||||||
|
await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command)
|
||||||
|
await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0)
|
||||||
|
await tool.locator('[data-slot="collapsible-trigger"]').click()
|
||||||
|
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||||
|
await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running")
|
||||||
|
})
|
||||||
|
|
||||||
test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
|
test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => {
|
||||||
const reasoningID = "prt_reasoning_hidden"
|
const reasoningID = "prt_reasoning_hidden"
|
||||||
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
|
const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false })
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,10 @@ test.describe("session timeline projection", () => {
|
||||||
parentID: "msg_2000_diff_next_user",
|
parentID: "msg_2000_diff_next_user",
|
||||||
created: 1700000011000,
|
created: 1700000011000,
|
||||||
})
|
})
|
||||||
await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
|
await setupTimeline(page, {
|
||||||
|
messages: [user, assistantMessage(), nextUser, nextAssistant],
|
||||||
|
settings: { newLayoutDesigns: false },
|
||||||
|
})
|
||||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||||
|
import {
|
||||||
|
assistantMessage,
|
||||||
|
setupTimeline,
|
||||||
|
shell,
|
||||||
|
textPart,
|
||||||
|
toolPart,
|
||||||
|
userMessage,
|
||||||
|
} from "../performance/timeline-stability/fixture"
|
||||||
|
|
||||||
|
for (const deviceScaleFactor of [1.25, 1.5]) {
|
||||||
|
test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => {
|
||||||
|
const shellID = "prt_shell_outline"
|
||||||
|
const timeline = await setupTimeline(page, {
|
||||||
|
messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])],
|
||||||
|
settings: { newLayoutDesigns: true, shellToolPartsExpanded: true },
|
||||||
|
reducedMotion: true,
|
||||||
|
deviceScaleFactor,
|
||||||
|
})
|
||||||
|
const part = page.locator(`[data-timeline-part-id="${shellID}"]`)
|
||||||
|
const output = part.locator('[data-component="bash-output"]')
|
||||||
|
const row = page.locator("[data-timeline-key]", { has: part })
|
||||||
|
await expect(output).toBeVisible()
|
||||||
|
await timeline.settle()
|
||||||
|
|
||||||
|
const geometry = await row.evaluate((element) => {
|
||||||
|
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')
|
||||||
|
if (!output) throw new Error("Shell output is unavailable")
|
||||||
|
const rowRect = element.getBoundingClientRect()
|
||||||
|
const outputRect = output.getBoundingClientRect()
|
||||||
|
// Match a rounded-down measurement at a fractional device-pixel phase.
|
||||||
|
element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px`
|
||||||
|
element.style.transform = "translateY(0.25px)"
|
||||||
|
output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)")
|
||||||
|
output.style.setProperty("background", "rgb(0, 0, 0)", "important")
|
||||||
|
const style = getComputedStyle(output)
|
||||||
|
return {
|
||||||
|
outputWidth: outputRect.width,
|
||||||
|
outputHeight: outputRect.height,
|
||||||
|
borderColor: style.borderTopColor,
|
||||||
|
boxShadow: style.boxShadow,
|
||||||
|
clipMargin: getComputedStyle(element).overflowClipMargin,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await timeline.settle()
|
||||||
|
|
||||||
|
const clipped = await row.evaluate((element) => {
|
||||||
|
const output = element.querySelector<HTMLElement>('[data-component="bash-output"]')!
|
||||||
|
return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom
|
||||||
|
})
|
||||||
|
expect(clipped).toBeCloseTo(0.49, 1)
|
||||||
|
|
||||||
|
expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor)
|
||||||
|
const edges = await captureCardEdges(page, output)
|
||||||
|
|
||||||
|
expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2)
|
||||||
|
expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2)
|
||||||
|
expect(geometry.borderColor).toBe("rgb(255, 0, 255)")
|
||||||
|
expect(geometry.boxShadow).toBe("none")
|
||||||
|
expect(geometry.clipMargin).toBe("0.5px")
|
||||||
|
expect(edges.magenta.top).toBeGreaterThan(0.75)
|
||||||
|
expect(edges.magenta.bottom).toBeGreaterThan(0.75)
|
||||||
|
expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => {
|
||||||
|
const patchID = "prt_patch_outline"
|
||||||
|
const file = {
|
||||||
|
filePath: "src/outline.ts",
|
||||||
|
relativePath: "src/outline.ts",
|
||||||
|
type: "update",
|
||||||
|
additions: 1,
|
||||||
|
deletions: 1,
|
||||||
|
before: "const outline = false\n",
|
||||||
|
after: "const outline = true\n",
|
||||||
|
}
|
||||||
|
const timeline = await setupTimeline(page, {
|
||||||
|
messages: [
|
||||||
|
userMessage(),
|
||||||
|
assistantMessage([
|
||||||
|
toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
settings: { editToolPartsExpanded: true, newLayoutDesigns: true },
|
||||||
|
reducedMotion: true,
|
||||||
|
})
|
||||||
|
const part = page.locator(`[data-timeline-part-id="${patchID}"]`)
|
||||||
|
const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]')
|
||||||
|
const row = page.locator("[data-timeline-key]", { has: part })
|
||||||
|
await expect(card).toBeVisible()
|
||||||
|
await timeline.settle()
|
||||||
|
|
||||||
|
const geometry = await row.evaluate((element) => {
|
||||||
|
const card = element.querySelector<HTMLElement>('[data-component="accordion"][data-scope="apply-patch"]')
|
||||||
|
if (!card) throw new Error("Patch card is unavailable")
|
||||||
|
const rowRect = element.getBoundingClientRect()
|
||||||
|
const cardRect = card.getBoundingClientRect()
|
||||||
|
element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px`
|
||||||
|
const clipMargin = getComputedStyle(element).overflowClipMargin
|
||||||
|
const bottom = element.getBoundingClientRect().bottom
|
||||||
|
return {
|
||||||
|
overflow: card.getBoundingClientRect().bottom - bottom,
|
||||||
|
paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin),
|
||||||
|
clipMargin,
|
||||||
|
cardWidth: cardRect.width,
|
||||||
|
cardHeight: cardRect.height,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await timeline.settle()
|
||||||
|
|
||||||
|
expect(geometry.overflow).toBeCloseTo(0.49, 1)
|
||||||
|
expect(geometry.paintOverflow).toBeLessThanOrEqual(0)
|
||||||
|
const edges = await captureCardEdges(page, card)
|
||||||
|
expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2)
|
||||||
|
expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2)
|
||||||
|
expect(edges.luminance.top).toBeLessThan(245)
|
||||||
|
expect(edges.luminance.bottom).toBeLessThan(245)
|
||||||
|
expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10)
|
||||||
|
expect(geometry.clipMargin).toBe("0.5px")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => {
|
||||||
|
const secondUserID = "msg_outline_second_user"
|
||||||
|
await setupTimeline(page, {
|
||||||
|
messages: [
|
||||||
|
userMessage(undefined, {
|
||||||
|
summary: {
|
||||||
|
diffs: [
|
||||||
|
{
|
||||||
|
file: "src/summary.ts",
|
||||||
|
additions: 1,
|
||||||
|
deletions: 1,
|
||||||
|
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
assistantMessage([textPart("prt_outline_text", "Assistant text")]),
|
||||||
|
userMessage(undefined, { id: secondUserID, created: 1700000010000 }),
|
||||||
|
assistantMessage([], {
|
||||||
|
id: "msg_outline_second_assistant",
|
||||||
|
parentID: secondUserID,
|
||||||
|
created: 1700000011000,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible()
|
||||||
|
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
||||||
|
|
||||||
|
const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) =>
|
||||||
|
elements.map((element) => ({
|
||||||
|
tag: element.querySelector<HTMLElement>("[data-timeline-row]")?.dataset.timelineRow,
|
||||||
|
clipMargin: getComputedStyle(element).overflowClipMargin,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true)
|
||||||
|
expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
async function captureCardEdges(page: Page, card: Locator) {
|
||||||
|
const box = await card.boundingBox()
|
||||||
|
if (!box) throw new Error("Tool card bounds are unavailable")
|
||||||
|
const viewport = page.viewportSize()
|
||||||
|
if (!viewport) throw new Error("Viewport bounds are unavailable")
|
||||||
|
const screenshot = await page.screenshot()
|
||||||
|
return page.evaluate(
|
||||||
|
async ({ source, box, viewport }) => {
|
||||||
|
const image = new Image()
|
||||||
|
image.src = source
|
||||||
|
await image.decode()
|
||||||
|
const canvas = document.createElement("canvas")
|
||||||
|
canvas.width = image.naturalWidth
|
||||||
|
canvas.height = image.naturalHeight
|
||||||
|
const context = canvas.getContext("2d")
|
||||||
|
if (!context) throw new Error("2D canvas is unavailable")
|
||||||
|
context.drawImage(image, 0, 0)
|
||||||
|
const scale = {
|
||||||
|
x: image.naturalWidth / viewport.width,
|
||||||
|
y: image.naturalHeight / viewport.height,
|
||||||
|
}
|
||||||
|
const rows = (candidates: number[]) => {
|
||||||
|
const left = Math.floor((box.x + 8) * scale.x)
|
||||||
|
const width = Math.floor((box.width - 16) * scale.x)
|
||||||
|
return candidates.map((row) => {
|
||||||
|
const pixels = context.getImageData(left, row, width, 1).data
|
||||||
|
const indexes = Array.from({ length: width }, (_, index) => index * 4)
|
||||||
|
return {
|
||||||
|
luminance:
|
||||||
|
indexes
|
||||||
|
.map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3)
|
||||||
|
.reduce((sum, value) => sum + value, 0) / width,
|
||||||
|
magenta:
|
||||||
|
indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200)
|
||||||
|
.length / width,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data
|
||||||
|
const columns = new Uint32Array(image.naturalWidth)
|
||||||
|
for (let index = 0; index < pixels.length; index += 4) {
|
||||||
|
if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue
|
||||||
|
columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1
|
||||||
|
}
|
||||||
|
const top = box.y * scale.y
|
||||||
|
const bottom = (box.y + box.height) * scale.y
|
||||||
|
const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)])
|
||||||
|
const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1])
|
||||||
|
return {
|
||||||
|
box,
|
||||||
|
luminance: {
|
||||||
|
top: Math.min(...topRows.map((row) => row.luminance)),
|
||||||
|
bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance,
|
||||||
|
},
|
||||||
|
magenta: {
|
||||||
|
top: Math.max(...topRows.map((row) => row.magenta)),
|
||||||
|
bottom: Math.max(...bottomRows.map((row) => row.magenta)),
|
||||||
|
vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: `data:image/png;base64,${screenshot.toString("base64")}`,
|
||||||
|
viewport,
|
||||||
|
box,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -89,8 +89,8 @@ test("reconnects after a stream error", async ({ page }) => {
|
||||||
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
|
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => {
|
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
||||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
|
||||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||||
id: "timeline-event-7",
|
id: "timeline-event-7",
|
||||||
})
|
})
|
||||||
|
|
@ -100,7 +100,7 @@ test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) =
|
||||||
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
|
||||||
|
|
||||||
expect(first.eventID).toBe("timeline-event-7")
|
expect(first.eventID).toBe("timeline-event-7")
|
||||||
expect(connection.headers["last-event-id"]).toBe("timeline-event-7")
|
expect(connection.headers["last-event-id"]).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("passes through non-event fetches", async ({ page }) => {
|
test("passes through non-event fetches", async ({ page }) => {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ test("animates todo lifecycle without replaying it across session tabs", async (
|
||||||
test.setTimeout(90_000)
|
test.setTimeout(90_000)
|
||||||
const events: EventPayload[] = []
|
const events: EventPayload[] = []
|
||||||
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
||||||
|
const sessionStatus: Record<string, { type: "busy" | "idle" }> = {}
|
||||||
|
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
directory,
|
directory,
|
||||||
|
|
@ -56,9 +57,11 @@ test("animates todo lifecycle without replaying it across session tabs", async (
|
||||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||||
},
|
},
|
||||||
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
||||||
|
sessionStatus: { [sourceID]: { type: "busy" } },
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
events: () => events.splice(0, 1),
|
events: () => events.splice(0, 1),
|
||||||
eventRetry: 16,
|
eventRetry: 16,
|
||||||
|
sessionStatus: () => sessionStatus,
|
||||||
todos: (sessionID) => todos[sessionID] ?? [],
|
todos: (sessionID) => todos[sessionID] ?? [],
|
||||||
})
|
})
|
||||||
await configurePage(page)
|
await configurePage(page)
|
||||||
|
|
@ -68,6 +71,7 @@ test("animates todo lifecycle without replaying it across session tabs", async (
|
||||||
const dock = page.locator('[data-component="session-todo-dock"]')
|
const dock = page.locator('[data-component="session-todo-dock"]')
|
||||||
await expect(dock).toHaveCount(0)
|
await expect(dock).toHaveCount(0)
|
||||||
|
|
||||||
|
sessionStatus[sourceID] = { type: "busy" }
|
||||||
events.push(statusEvent(sourceID, "busy"))
|
events.push(statusEvent(sourceID, "busy"))
|
||||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { expect, test, type Page } from "@playwright/test"
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
||||||
import { expectSessionTitle } from "../utils/waits"
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
const directory = "C:/OpenCode/SubagentNavigation"
|
const directory = "C:/OpenCode/SubagentNavigation"
|
||||||
|
|
@ -72,16 +72,19 @@ async function setup(page: Page, events?: () => EventPayload[]) {
|
||||||
events,
|
events,
|
||||||
eventRetry: events ? 16 : undefined,
|
eventRetry: events ? 16 : undefined,
|
||||||
})
|
})
|
||||||
// The child session resolves via /session/:id but is absent from the /session list,
|
// The child session resolves by ID but is absent from the session list,
|
||||||
// matching a subagent session that has not been loaded into the list cache yet.
|
// matching a subagent session that has not been loaded into the list cache yet.
|
||||||
await page.route(
|
await page.route(
|
||||||
(url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
(url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"),
|
||||||
(route) =>
|
(route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
headers: { "access-control-allow-origin": "*" },
|
||||||
body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]),
|
body: JSON.stringify({
|
||||||
|
data: [currentSession(session(parentID, parentTitle, 1700000000000))],
|
||||||
|
cursor: {},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await configurePage(page)
|
await configurePage(page)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { expect, test, type Page, type Route } from "@playwright/test"
|
import { expect, test, type Page, type Route } from "@playwright/test"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { currentSession } from "../utils/mock-server"
|
||||||
|
|
||||||
const server = "http://127.0.0.1:4096"
|
const server = "http://127.0.0.1:4096"
|
||||||
const sessionA = session("ses_tab_a", "Tab A session")
|
const sessionA = session("ses_tab_a", "Tab A session")
|
||||||
const sessionB = session("ses_tab_b", "Tab B session")
|
const sessionB = session("ses_tab_b", "Tab B session")
|
||||||
|
const sessionC = session("ses_tab_c", "Tab C session")
|
||||||
|
const unresolvedSessionID = "ses_tab_unresolved"
|
||||||
|
|
||||||
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => {
|
||||||
await mockServer(page)
|
await mockServer(page)
|
||||||
|
|
@ -39,6 +42,34 @@ test("pressing mouse down on a tab navigates before mouse up", async ({ page })
|
||||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keyboard navigation follows the visible tab order", async ({ page }) => {
|
||||||
|
await mockServer(page)
|
||||||
|
await page.addInitScript(
|
||||||
|
({ server, sessionA, unresolved, sessionC }) => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
"opencode.window.browser.dat:tabs",
|
||||||
|
JSON.stringify([
|
||||||
|
{ type: "session", server, sessionId: sessionA },
|
||||||
|
{ type: "session", server, sessionId: unresolved },
|
||||||
|
{ type: "session", server, sessionId: sessionC },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id },
|
||||||
|
)
|
||||||
|
|
||||||
|
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
|
||||||
|
const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}`
|
||||||
|
await page.goto(hrefA)
|
||||||
|
await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2)
|
||||||
|
await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press("Control+Alt+ArrowRight")
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||||
|
})
|
||||||
|
|
||||||
function session(id: string, title: string) {
|
function session(id: string, title: string) {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|
@ -52,13 +83,21 @@ function session(id: string, title: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mockServer(page: Page) {
|
async function mockServer(page: Page) {
|
||||||
const sessions = [sessionA, sessionB]
|
const sessions = [sessionA, sessionB, sessionC]
|
||||||
await page.route("**/*", async (route) => {
|
await page.route("**/*", async (route) => {
|
||||||
const url = new URL(route.request().url())
|
const url = new URL(route.request().url())
|
||||||
if (url.origin !== server) return route.fallback()
|
if (url.origin !== server) return route.fallback()
|
||||||
if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route)
|
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
||||||
|
return new Promise(() => {})
|
||||||
|
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event")
|
||||||
|
return sse(route)
|
||||||
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
if (url.pathname === "/global/health") return json(route, { healthy: true })
|
||||||
if (url.pathname === "/session") return json(route, sessions)
|
if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} })
|
||||||
|
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||||
|
const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`)
|
||||||
|
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||||
|
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
|
||||||
|
return json(route, { data: [], cursor: {} })
|
||||||
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
|
const byId = sessions.find((item) => url.pathname === `/session/${item.id}`)
|
||||||
if (byId) return json(route, byId)
|
if (byId) return json(route, byId)
|
||||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||||
|
|
@ -66,8 +105,7 @@ async function mockServer(page: Page) {
|
||||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||||
return json(route, [])
|
return json(route, [])
|
||||||
if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname))
|
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
|
||||||
return json(route, {})
|
|
||||||
if (url.pathname === "/provider")
|
if (url.pathname === "/provider")
|
||||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||||
|
|
@ -89,7 +127,20 @@ async function mockServer(page: Page) {
|
||||||
directory: sessionA.directory,
|
directory: sessionA.directory,
|
||||||
home: sessionA.directory,
|
home: sessionA.directory,
|
||||||
})
|
})
|
||||||
|
if (url.pathname === "/api/path")
|
||||||
|
return json(route, {
|
||||||
|
state: sessionA.directory,
|
||||||
|
config: sessionA.directory,
|
||||||
|
worktree: sessionA.directory,
|
||||||
|
directory: sessionA.directory,
|
||||||
|
home: sessionA.directory,
|
||||||
|
})
|
||||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||||
|
if (url.pathname === "/api/vcs")
|
||||||
|
return json(route, {
|
||||||
|
location: { directory: sessionA.directory },
|
||||||
|
data: { branch: "main", defaultBranch: "main" },
|
||||||
|
})
|
||||||
return json(route, {})
|
return json(route, {})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
227
packages/app/e2e/regression/terminal-composer-focus.spec.ts
Normal file
227
packages/app/e2e/regression/terminal-composer-focus.spec.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectSessionTitle } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/TerminalComposerFocus"
|
||||||
|
const projectID = "proj_terminal_composer_focus"
|
||||||
|
const sessionID = "ses_terminal_composer_focus"
|
||||||
|
const ptyID = "pty_terminal_composer_focus"
|
||||||
|
const newPtyID = "pty_terminal_composer_focus_new"
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 1440, height: 900 } })
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: projectID,
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "terminal-composer-focus",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: {
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "test" },
|
||||||
|
},
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: sessionID,
|
||||||
|
slug: "terminal-composer-focus",
|
||||||
|
projectID,
|
||||||
|
directory,
|
||||||
|
title: "Terminal composer focus",
|
||||||
|
version: "dev",
|
||||||
|
time: { created: 1700000000000, updated: 1700000000000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
})
|
||||||
|
await page.route("**/api/pty*", (route) => {
|
||||||
|
expect(new URL(route.request().url()).searchParams.get("location[directory]")).toBe(directory)
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await page.route(`**/api/pty/${ptyID}*`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined)
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, "Terminal composer focus")
|
||||||
|
|
||||||
|
const composer = page.locator('[data-component="prompt-input"]')
|
||||||
|
const terminal = page.locator('[data-component="terminal"]')
|
||||||
|
await page.keyboard.press("Control+Backquote")
|
||||||
|
await expect(terminal).toBeVisible()
|
||||||
|
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||||
|
|
||||||
|
await page.keyboard.type("x")
|
||||||
|
await expect(composer).toHaveText("")
|
||||||
|
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||||
|
await page.keyboard.type("a")
|
||||||
|
|
||||||
|
await expect(composer).toBeFocused()
|
||||||
|
await expect(composer).toHaveText("a")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps composer focus when a cached terminal finishes mounting", async ({ page }) => {
|
||||||
|
const ghostty = Promise.withResolvers<void>()
|
||||||
|
const release = Promise.withResolvers<void>()
|
||||||
|
const created = { count: 0 }
|
||||||
|
await page.route("**/api/pty*", (route) => {
|
||||||
|
created.count += 1
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await page.route(/ghostty-web/, async (route) => {
|
||||||
|
ghostty.resolve()
|
||||||
|
await release.promise
|
||||||
|
await route.continue()
|
||||||
|
})
|
||||||
|
await seedCachedTerminal(page)
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" })
|
||||||
|
await expectSessionTitle(page, "Terminal composer focus")
|
||||||
|
|
||||||
|
const composer = page.locator('[data-component="prompt-input"]')
|
||||||
|
const terminal = page.locator('[data-component="terminal"]')
|
||||||
|
await expect(terminal).toBeVisible()
|
||||||
|
expect(created.count).toBe(0)
|
||||||
|
await ghostty.promise
|
||||||
|
await composer.click()
|
||||||
|
await expect(composer).toBeFocused()
|
||||||
|
|
||||||
|
release.resolve()
|
||||||
|
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
await expect(composer).toBeFocused()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps newer composer focus while an explicit terminal open finishes", async ({ page }) => {
|
||||||
|
const ghostty = Promise.withResolvers<void>()
|
||||||
|
const release = Promise.withResolvers<void>()
|
||||||
|
await page.route(/ghostty-web/, async (route) => {
|
||||||
|
ghostty.resolve()
|
||||||
|
await release.promise
|
||||||
|
await route.continue()
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, "Terminal composer focus")
|
||||||
|
|
||||||
|
const composer = page.locator('[data-component="prompt-input"]')
|
||||||
|
const terminal = page.locator('[data-component="terminal"]')
|
||||||
|
await page.keyboard.press("Control+Backquote")
|
||||||
|
await expect(terminal).toBeVisible()
|
||||||
|
await ghostty.promise
|
||||||
|
await composer.click()
|
||||||
|
await expect(composer).toBeFocused()
|
||||||
|
|
||||||
|
release.resolve()
|
||||||
|
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||||
|
await page.waitForTimeout(50)
|
||||||
|
await expect(composer).toBeFocused()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("focuses a terminal created from the new-terminal button", async ({ page }) => {
|
||||||
|
const created = { count: 0 }
|
||||||
|
await page.route("**/api/pty*", (route) => {
|
||||||
|
created.count += 1
|
||||||
|
const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2")
|
||||||
|
return route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ location: ptyLocation(), data: next }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await page.route(`**/api/pty/${newPtyID}*`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(newPtyID, "Terminal 2") }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
headers: { "access-control-allow-origin": "*" },
|
||||||
|
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined)
|
||||||
|
|
||||||
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
|
await expectSessionTitle(page, "Terminal composer focus")
|
||||||
|
|
||||||
|
const composer = page.locator('[data-component="prompt-input"]')
|
||||||
|
const terminal = page.locator('[data-component="terminal"]')
|
||||||
|
await page.keyboard.press("Control+Backquote")
|
||||||
|
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||||
|
await composer.click()
|
||||||
|
await expect(composer).toBeFocused()
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "New terminal" }).click()
|
||||||
|
await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true")
|
||||||
|
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
function seedCachedTerminal(page: Page) {
|
||||||
|
return page.addInitScript(
|
||||||
|
({ terminalKey, ptyID }) => {
|
||||||
|
localStorage.setItem("opencode.global.dat:layout", JSON.stringify({ terminal: { height: 320, opened: true } }))
|
||||||
|
localStorage.setItem(
|
||||||
|
terminalKey,
|
||||||
|
JSON.stringify({
|
||||||
|
active: ptyID,
|
||||||
|
all: [{ id: ptyID, title: "Terminal 1", titleNumber: 1 }],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ptyLocation() {
|
||||||
|
return { directory, project: { id: projectID, directory } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ptyInfo(id: string, title: string) {
|
||||||
|
return { id, title, command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
|
||||||
|
}
|
||||||
|
|
@ -7,9 +7,10 @@ const projectID = "proj_hidden_terminal_regression"
|
||||||
const sessionID = "ses_hidden_terminal_regression"
|
const sessionID = "ses_hidden_terminal_regression"
|
||||||
const title = "Hidden terminal regression"
|
const title = "Hidden terminal regression"
|
||||||
|
|
||||||
test("unmounts the terminal renderer while the pane is hidden", async ({ page }) => {
|
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 900 })
|
await page.setViewportSize({ width: 1400, height: 900 })
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
|
|
@ -43,17 +44,53 @@ test("unmounts the terminal renderer while the pane is hidden", async ({ page })
|
||||||
],
|
],
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
})
|
})
|
||||||
await page.route("**/pty", (route) =>
|
await page.route("**/api/pty*", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }),
|
body: JSON.stringify({
|
||||||
|
location: { directory, project: { id: projectID, directory } },
|
||||||
|
data: {
|
||||||
|
id: "pty_hidden_terminal",
|
||||||
|
title: "Terminal 1",
|
||||||
|
command: "cmd.exe",
|
||||||
|
args: [],
|
||||||
|
cwd: directory,
|
||||||
|
status: "running",
|
||||||
|
pid: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route("**/pty/pty_hidden_terminal", (route) =>
|
await page.route("**/api/pty/pty_hidden_terminal*", (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
location: { directory, project: { id: projectID, directory } },
|
||||||
|
data: {
|
||||||
|
id: "pty_hidden_terminal",
|
||||||
|
title: "Terminal 1",
|
||||||
|
command: "cmd.exe",
|
||||||
|
args: [],
|
||||||
|
cwd: directory,
|
||||||
|
status: "running",
|
||||||
|
pid: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
await page.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined)
|
await page.route("**/api/pty/pty_hidden_terminal/connect-token*", (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({
|
||||||
|
location: { directory, project: { id: projectID, directory } },
|
||||||
|
data: { ticket: "e2e-ticket", expires_in: 60 },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined)
|
||||||
|
|
||||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||||
await expectSessionTitle(page, title)
|
await expectSessionTitle(page, title)
|
||||||
|
|
@ -64,13 +101,14 @@ test("unmounts the terminal renderer while the pane is hidden", async ({ page })
|
||||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||||
|
|
||||||
await page.keyboard.press("Control+Backquote")
|
await page.keyboard.press("Control+Backquote")
|
||||||
await expect(panel).toHaveAttribute("aria-hidden", "true")
|
await expect(panel).toHaveCount(0)
|
||||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||||
|
|
||||||
await page.setViewportSize({ width: 1200, height: 700 })
|
await page.setViewportSize({ width: 1200, height: 700 })
|
||||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||||
|
|
||||||
await page.keyboard.press("Control+Backquote")
|
await page.keyboard.press("Control+Backquote")
|
||||||
|
await expect(panel).toBeVisible()
|
||||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,10 @@ test("keeps the terminal session alive when switching session tabs in a workspac
|
||||||
const terminal = page.locator('[data-component="terminal"]')
|
const terminal = page.locator('[data-component="terminal"]')
|
||||||
await expect(terminal).toBeVisible()
|
await expect(terminal).toBeVisible()
|
||||||
await expect.poll(() => connections.length).toBe(1)
|
await expect.poll(() => connections.length).toBe(1)
|
||||||
|
const connection = new URL(connections[0]!)
|
||||||
|
expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`)
|
||||||
|
expect(connection.searchParams.get("location[directory]")).toBe(directory)
|
||||||
|
expect(connection.searchParams.get("ticket")).toBeNull()
|
||||||
await writeProbe(page)
|
await writeProbe(page)
|
||||||
|
|
||||||
await switchTab(page, titleB)
|
await switchTab(page, titleB)
|
||||||
|
|
@ -62,6 +66,7 @@ async function readProbe(page: Page) {
|
||||||
|
|
||||||
async function setup(page: Page) {
|
async function setup(page: Page) {
|
||||||
await mockOpenCodeServer(page, {
|
await mockOpenCodeServer(page, {
|
||||||
|
protocol: "v2",
|
||||||
directory,
|
directory,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
|
|
@ -85,26 +90,33 @@ async function setup(page: Page) {
|
||||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
})
|
})
|
||||||
await page.route("**/pty", (route) =>
|
await page.route("**/api/pty*", (route) =>
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
await page.route(`**/pty/${ptyID}`, (route) =>
|
await page.route(`**/api/pty/${ptyID}*`, (route) =>
|
||||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
|
||||||
)
|
|
||||||
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
|
|
||||||
route.fulfill({
|
route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => {
|
||||||
|
expect(route.request().headers()["x-opencode-ticket"]).toBe("1")
|
||||||
|
const url = new URL(route.request().url())
|
||||||
|
expect(url.searchParams.get("location[directory]")).toBe(directory)
|
||||||
|
return route.fulfill({
|
||||||
status: 200,
|
status: 200,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
headers: { "access-control-allow-origin": "*" },
|
headers: { "access-control-allow-origin": "*" },
|
||||||
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }),
|
||||||
}),
|
})
|
||||||
)
|
})
|
||||||
const connections: string[] = []
|
const connections: string[] = []
|
||||||
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => {
|
await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => {
|
||||||
connections.push(ws.url())
|
connections.push(ws.url())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -143,3 +155,11 @@ function session(id: string, title: string, created: number) {
|
||||||
function sessionHref(sessionID: string) {
|
function sessionHref(sessionID: string) {
|
||||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ptyLocation() {
|
||||||
|
return { directory, project: { id: projectID, directory } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ptyInfo() {
|
||||||
|
return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 }
|
||||||
|
}
|
||||||
|
|
|
||||||
34
packages/app/e2e/reproduction/timeline-suspense/index.html
Normal file
34
packages/app/e2e/reproduction/timeline-suspense/index.html
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Timeline Suspense Reproduction</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #171717;
|
||||||
|
color: #f5f5f5;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main id="root"></main>
|
||||||
|
<script type="module" src="/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
315
packages/app/e2e/reproduction/timeline-suspense/main.tsx
Normal file
315
packages/app/e2e/reproduction/timeline-suspense/main.tsx
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
import { createResource, createSignal, For, onMount, Suspense } from "solid-js"
|
||||||
|
import { render } from "solid-js/web"
|
||||||
|
import { createVirtualizer, observeElementOffset, observeElementRect } from "@tanstack/solid-virtual"
|
||||||
|
import { observeElementOffsetReconnectAware } from "../../../src/pages/session/timeline/observe-element-offset"
|
||||||
|
|
||||||
|
const rowCount = 2_000
|
||||||
|
const rowHeight = 40
|
||||||
|
const parameters = new URLSearchParams(location.search)
|
||||||
|
const resourceMode = parameters.get("resource") === "guard" ? "guard" : "baseline"
|
||||||
|
const reconnectMode = parameters.get("reconnect") === "candidate" ? "candidate" : "baseline"
|
||||||
|
|
||||||
|
type MutationEvent = {
|
||||||
|
kind: "removed" | "added"
|
||||||
|
callbackTime: number
|
||||||
|
callbackFrame: number
|
||||||
|
routeConnectedInCallback: boolean
|
||||||
|
nativeOffsetInCallback: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Snapshot = {
|
||||||
|
mode: {
|
||||||
|
resource: "baseline" | "guard"
|
||||||
|
reconnect: "baseline" | "candidate"
|
||||||
|
}
|
||||||
|
operation: {
|
||||||
|
sequence: number
|
||||||
|
phase: string
|
||||||
|
time: number
|
||||||
|
frame: number
|
||||||
|
}
|
||||||
|
resourceState: string
|
||||||
|
routeConnected: boolean
|
||||||
|
viewportConnected: boolean
|
||||||
|
viewportOwnedByRoute: boolean
|
||||||
|
sameRoute: boolean
|
||||||
|
sameViewport: boolean
|
||||||
|
sameSurface: boolean
|
||||||
|
sameMountedRows: boolean
|
||||||
|
nativeOffset: number
|
||||||
|
coreOffset: number
|
||||||
|
rangeStart: number
|
||||||
|
rangeEnd: number
|
||||||
|
indexes: number[]
|
||||||
|
domIndexes: number[]
|
||||||
|
logicalSurfaceHeight: number
|
||||||
|
renderedSurfaceHeight: number
|
||||||
|
viewportClientHeight: number
|
||||||
|
viewportScrollHeight: number
|
||||||
|
visibleRows: number
|
||||||
|
minimumRowTop: number
|
||||||
|
domScrollEvents: number
|
||||||
|
lastScrollTrusted: boolean
|
||||||
|
coreOffsetCallbackCalls: number
|
||||||
|
offsetCallbackSources: "observer"[]
|
||||||
|
rectObserverCallbacks: number
|
||||||
|
ignoredDetachedZeroRects: number
|
||||||
|
syntheticScrollDispatches: number
|
||||||
|
mutationEvents: MutationEvent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
timelineSuspense: {
|
||||||
|
prepare: () => Promise<Snapshot>
|
||||||
|
trigger: () => void
|
||||||
|
resolve: () => void
|
||||||
|
frames: (count?: number) => Promise<void>
|
||||||
|
snapshot: () => Snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [refresh, setRefresh] = createSignal(false)
|
||||||
|
let resolveResource: (() => void) | undefined
|
||||||
|
const [resource] = createResource(
|
||||||
|
refresh,
|
||||||
|
(version) =>
|
||||||
|
new Promise<string>((resolve) => {
|
||||||
|
resolveResource = () => resolve(`settled-${version}`)
|
||||||
|
}),
|
||||||
|
{ initialValue: "settled" },
|
||||||
|
)
|
||||||
|
|
||||||
|
function Route() {
|
||||||
|
let route: HTMLElement | undefined
|
||||||
|
let viewport: HTMLDivElement | undefined
|
||||||
|
let surface: HTMLDivElement | undefined
|
||||||
|
let initialRoute: HTMLElement | undefined
|
||||||
|
let initialViewport: HTMLDivElement | undefined
|
||||||
|
let initialSurface: HTMLDivElement | undefined
|
||||||
|
let initialRows: HTMLElement[] = []
|
||||||
|
let phase = "mounting"
|
||||||
|
let browserFrame = 0
|
||||||
|
let snapshotSequence = 0
|
||||||
|
let domScrollEvents = 0
|
||||||
|
let lastScrollTrusted = false
|
||||||
|
let coreOffsetCallbackCalls = 0
|
||||||
|
let rectObserverCallbacks = 0
|
||||||
|
let ignoredDetachedZeroRects = 0
|
||||||
|
const offsetCallbackSources: "observer"[] = []
|
||||||
|
const mutationEvents: MutationEvent[] = []
|
||||||
|
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||||
|
count: rowCount,
|
||||||
|
getScrollElement: () => viewport ?? null,
|
||||||
|
estimateSize: () => rowHeight,
|
||||||
|
initialRect: { width: 900, height: 600 },
|
||||||
|
overscan: 2,
|
||||||
|
observeElementRect: (instance, callback) =>
|
||||||
|
observeElementRect(instance, (rect) => {
|
||||||
|
rectObserverCallbacks++
|
||||||
|
// A fixed 600px viewport has no usable geometry while detached. Keep the last connected rect.
|
||||||
|
if (!instance.scrollElement?.isConnected && rect.height === 0) {
|
||||||
|
ignoredDetachedZeroRects++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback(rect)
|
||||||
|
}),
|
||||||
|
observeElementOffset: (instance, callback) => {
|
||||||
|
const deliver = (offset: number, isScrolling: boolean) => {
|
||||||
|
coreOffsetCallbackCalls++
|
||||||
|
offsetCallbackSources.push("observer")
|
||||||
|
callback(offset, isScrolling)
|
||||||
|
}
|
||||||
|
if (reconnectMode === "candidate") return observeElementOffsetReconnectAware(instance, deliver)
|
||||||
|
return observeElementOffset(instance, deliver)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const frames = async (count = 2) => {
|
||||||
|
for (let index = 0; index < count; index++) {
|
||||||
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mountedRows = () => [...(surface?.querySelectorAll<HTMLElement>("[data-row-index]") ?? [])]
|
||||||
|
const snapshot = (): Snapshot => {
|
||||||
|
const rows = mountedRows()
|
||||||
|
const view = viewport?.getBoundingClientRect()
|
||||||
|
const visibleRows =
|
||||||
|
viewport?.isConnected && view
|
||||||
|
? rows.filter((row) => {
|
||||||
|
const rect = row.getBoundingClientRect()
|
||||||
|
return rect.bottom > view.top && rect.top < view.bottom
|
||||||
|
}).length
|
||||||
|
: 0
|
||||||
|
return {
|
||||||
|
mode: { resource: resourceMode, reconnect: reconnectMode },
|
||||||
|
operation: {
|
||||||
|
sequence: ++snapshotSequence,
|
||||||
|
phase,
|
||||||
|
time: performance.now(),
|
||||||
|
frame: browserFrame,
|
||||||
|
},
|
||||||
|
resourceState: resource.state,
|
||||||
|
routeConnected: route?.isConnected ?? false,
|
||||||
|
viewportConnected: viewport?.isConnected ?? false,
|
||||||
|
viewportOwnedByRoute: !!route && !!viewport && route.contains(viewport),
|
||||||
|
sameRoute: route === initialRoute,
|
||||||
|
sameViewport: viewport === initialViewport,
|
||||||
|
sameSurface: surface === initialSurface,
|
||||||
|
sameMountedRows:
|
||||||
|
initialRows.length > 0 &&
|
||||||
|
initialRows.length === rows.length &&
|
||||||
|
initialRows.every((row, index) => row === rows[index]),
|
||||||
|
nativeOffset: viewport?.scrollTop ?? -1,
|
||||||
|
coreOffset: virtualizer.scrollOffset ?? -1,
|
||||||
|
rangeStart: virtualizer.range?.startIndex ?? -1,
|
||||||
|
rangeEnd: virtualizer.range?.endIndex ?? -1,
|
||||||
|
indexes: virtualizer.getVirtualItems().map((item) => item.index),
|
||||||
|
domIndexes: rows.map((row) => Number(row.dataset.rowIndex)),
|
||||||
|
logicalSurfaceHeight: Number.parseFloat(surface?.style.height ?? "-1"),
|
||||||
|
renderedSurfaceHeight: surface?.getBoundingClientRect().height ?? -1,
|
||||||
|
viewportClientHeight: viewport?.clientHeight ?? -1,
|
||||||
|
viewportScrollHeight: viewport?.scrollHeight ?? -1,
|
||||||
|
visibleRows,
|
||||||
|
minimumRowTop:
|
||||||
|
rows.length && view ? Math.min(...rows.map((row) => row.getBoundingClientRect().top - view.top)) : -1,
|
||||||
|
domScrollEvents,
|
||||||
|
lastScrollTrusted,
|
||||||
|
coreOffsetCallbackCalls,
|
||||||
|
offsetCallbackSources: [...offsetCallbackSources],
|
||||||
|
rectObserverCallbacks,
|
||||||
|
ignoredDetachedZeroRects,
|
||||||
|
syntheticScrollDispatches: 0,
|
||||||
|
mutationEvents: mutationEvents.map((event) => ({ ...event })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!route || !viewport || !surface) throw new Error("Timeline fixture did not mount")
|
||||||
|
const routeRoot = route.parentElement
|
||||||
|
if (!routeRoot) throw new Error("Timeline route root did not mount")
|
||||||
|
initialRoute = route
|
||||||
|
initialViewport = viewport
|
||||||
|
initialSurface = surface
|
||||||
|
viewport.addEventListener("scroll", (event) => {
|
||||||
|
domScrollEvents++
|
||||||
|
lastScrollTrusted = event.isTrusted
|
||||||
|
})
|
||||||
|
const countFrames = () => {
|
||||||
|
browserFrame++
|
||||||
|
requestAnimationFrame(countFrames)
|
||||||
|
}
|
||||||
|
requestAnimationFrame(countFrames)
|
||||||
|
new MutationObserver((records) => {
|
||||||
|
const callbackTime = performance.now()
|
||||||
|
records.forEach((record) => {
|
||||||
|
;([...(record.removedNodes ?? [])] as Node[]).forEach((node) => {
|
||||||
|
if (node !== route) return
|
||||||
|
phase = "detached"
|
||||||
|
mutationEvents.push({
|
||||||
|
kind: "removed",
|
||||||
|
callbackTime,
|
||||||
|
callbackFrame: browserFrame,
|
||||||
|
routeConnectedInCallback: route.isConnected,
|
||||||
|
nativeOffsetInCallback: viewport.scrollTop,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
;([...(record.addedNodes ?? [])] as Node[]).forEach((node) => {
|
||||||
|
if (node !== route) return
|
||||||
|
phase = "reinserted"
|
||||||
|
mutationEvents.push({
|
||||||
|
kind: "added",
|
||||||
|
callbackTime,
|
||||||
|
callbackFrame: browserFrame,
|
||||||
|
routeConnectedInCallback: route.isConnected,
|
||||||
|
nativeOffsetInCallback: viewport.scrollTop,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}).observe(routeRoot, { childList: true })
|
||||||
|
window.timelineSuspense = {
|
||||||
|
prepare: async () => {
|
||||||
|
phase = "preparing"
|
||||||
|
await frames(2)
|
||||||
|
viewport.scrollTop = viewport.scrollHeight
|
||||||
|
await frames(3)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||||
|
await frames(2)
|
||||||
|
initialRows = mountedRows()
|
||||||
|
phase = "prepared"
|
||||||
|
return snapshot()
|
||||||
|
},
|
||||||
|
trigger: () => {
|
||||||
|
phase = "triggering"
|
||||||
|
setRefresh(true)
|
||||||
|
},
|
||||||
|
resolve: () => {
|
||||||
|
if (!resolveResource) throw new Error("Resource is not pending")
|
||||||
|
phase = "resolving"
|
||||||
|
resolveResource()
|
||||||
|
},
|
||||||
|
frames,
|
||||||
|
snapshot,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={route} data-route style={{ width: "900px", margin: "0 auto" }}>
|
||||||
|
<span aria-hidden="true" style={{ display: "none" }}>
|
||||||
|
{resourceMode === "guard" && resource.state === "refreshing" ? resource.latest : resource()}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
ref={viewport}
|
||||||
|
data-viewport
|
||||||
|
style={{
|
||||||
|
height: "600px",
|
||||||
|
overflow: "auto",
|
||||||
|
"overflow-anchor": "none",
|
||||||
|
position: "relative",
|
||||||
|
background: "#202020",
|
||||||
|
outline: "1px solid #3f3f46",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={surface}
|
||||||
|
data-surface
|
||||||
|
style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative", "overflow-anchor": "none" }}
|
||||||
|
>
|
||||||
|
<For each={virtualizer.getVirtualItems()}>
|
||||||
|
{(item) => (
|
||||||
|
<div
|
||||||
|
data-row-index={item.index}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "0",
|
||||||
|
left: "0",
|
||||||
|
width: "100%",
|
||||||
|
height: `${item.size}px`,
|
||||||
|
transform: `translateY(${item.start}px)`,
|
||||||
|
padding: "10px 14px",
|
||||||
|
border: "0 solid #333",
|
||||||
|
"border-bottom-width": "1px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
logical row {item.index}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<Suspense>
|
||||||
|
<Route />
|
||||||
|
</Suspense>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
render(() => <App />, document.getElementById("root")!)
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { defineConfig, devices } from "@playwright/test"
|
||||||
|
|
||||||
|
const port = Number(process.env.PLAYWRIGHT_TIMELINE_SUSPENSE_PORT ?? 4317)
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: ".",
|
||||||
|
testMatch: "timeline-suspense.repro.ts",
|
||||||
|
outputDir: "../../test-results/timeline-suspense",
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
retries: 0,
|
||||||
|
reporter: "line",
|
||||||
|
timeout: 30_000,
|
||||||
|
expect: {
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: `bunx vite --config vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`,
|
||||||
|
cwd: import.meta.dirname,
|
||||||
|
url: `http://127.0.0.1:${port}`,
|
||||||
|
reuseExistingServer: false,
|
||||||
|
},
|
||||||
|
use: {
|
||||||
|
baseURL: `http://127.0.0.1:${port}`,
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
import { expect, test, type Page } from "@playwright/test"
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
page.on("pageerror", (error) => console.error(error))
|
||||||
|
await page.goto("/")
|
||||||
|
await expect.poll(() => page.evaluate(() => !!window.timelineSuspense)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("desired: preserves visible timeline continuity across descendant resource suspension", async ({ page }) => {
|
||||||
|
await page.goto("/?reconnect=candidate")
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.reconnect)).toBe("candidate")
|
||||||
|
const before = await prepare(page)
|
||||||
|
await triggerBaselineSuspension(page)
|
||||||
|
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||||
|
expect(pending.nativeOffset).toBe(0)
|
||||||
|
expect(pending.coreOffset).toBe(before.coreOffset)
|
||||||
|
expect(pending.indexes).toEqual(before.indexes)
|
||||||
|
expect(pending.sameRoute).toBe(true)
|
||||||
|
expect(pending.sameViewport).toBe(true)
|
||||||
|
expect(pending.sameSurface).toBe(true)
|
||||||
|
expect(pending.sameMountedRows).toBe(true)
|
||||||
|
|
||||||
|
await resolveSuspension(page)
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().coreOffset)).toBe(0)
|
||||||
|
await page.waitForTimeout(250)
|
||||||
|
await page.evaluate(() => window.timelineSuspense.frames(2))
|
||||||
|
const after = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||||
|
expect(after.sameRoute).toBe(true)
|
||||||
|
expect(after.sameViewport).toBe(true)
|
||||||
|
expect(after.sameSurface).toBe(true)
|
||||||
|
expect(after.nativeOffset).toBe(0)
|
||||||
|
expect(after.coreOffset).toBe(0)
|
||||||
|
expect(after.rangeStart).toBeLessThan(10)
|
||||||
|
expect(after.visibleRows, diagnostic({ before, pending, after })).toBeGreaterThan(0)
|
||||||
|
expect(after.domScrollEvents).toBe(before.domScrollEvents)
|
||||||
|
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls + 1)
|
||||||
|
expect(after.offsetCallbackSources.at(-1)).toBe("observer")
|
||||||
|
expect(after.syntheticScrollDispatches).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("forensic: proves detached same-node viewport leaves TanStack's bottom range blank until a real scroll", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const before = await prepare(page)
|
||||||
|
const beforeRows = before.domIndexes
|
||||||
|
expect(before.mode).toEqual({ resource: "baseline", reconnect: "baseline" })
|
||||||
|
expect(before.logicalSurfaceHeight).toBe(80_000)
|
||||||
|
expect(before.renderedSurfaceHeight).toBe(80_000)
|
||||||
|
expect(before.viewportClientHeight).toBe(600)
|
||||||
|
expect(before.viewportScrollHeight).toBe(80_000)
|
||||||
|
expect(before.rangeStart).toBeGreaterThan(1_900)
|
||||||
|
expect(before.nativeOffset).toBe(before.coreOffset)
|
||||||
|
expect(before.visibleRows).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
await triggerBaselineSuspension(page)
|
||||||
|
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||||
|
expect(pending.resourceState).toBe("refreshing")
|
||||||
|
expect(pending.routeConnected).toBe(false)
|
||||||
|
expect(pending.viewportConnected).toBe(false)
|
||||||
|
expect(pending.viewportOwnedByRoute).toBe(true)
|
||||||
|
expect(pending.nativeOffset).toBe(0)
|
||||||
|
expect(pending.coreOffset).toBe(before.coreOffset)
|
||||||
|
expect(pending.rangeStart).toBe(before.rangeStart)
|
||||||
|
expect(pending.rangeEnd).toBe(before.rangeEnd)
|
||||||
|
expect(pending.indexes).toEqual(before.indexes)
|
||||||
|
expect(pending.domIndexes).toEqual(beforeRows)
|
||||||
|
expect(pending.sameMountedRows).toBe(true)
|
||||||
|
expect(pending.domScrollEvents).toBe(before.domScrollEvents)
|
||||||
|
expect(pending.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
|
||||||
|
expect(pending.ignoredDetachedZeroRects).toBeGreaterThan(before.ignoredDetachedZeroRects)
|
||||||
|
expect(pending.mutationEvents).toHaveLength(1)
|
||||||
|
expect(pending.mutationEvents[0]).toMatchObject({
|
||||||
|
kind: "removed",
|
||||||
|
routeConnectedInCallback: false,
|
||||||
|
nativeOffsetInCallback: 0,
|
||||||
|
})
|
||||||
|
expect(pending.mutationEvents[0]!.callbackTime).toBeLessThanOrEqual(pending.operation.time)
|
||||||
|
expect(pending.mutationEvents[0]!.callbackFrame).toBeLessThanOrEqual(pending.operation.frame)
|
||||||
|
|
||||||
|
const after = await resolveSuspension(page)
|
||||||
|
expect(after.resourceState).toBe("ready")
|
||||||
|
expect(after.routeConnected).toBe(true)
|
||||||
|
expect(after.viewportConnected).toBe(true)
|
||||||
|
expect(after.viewportOwnedByRoute).toBe(true)
|
||||||
|
expect(after.sameRoute).toBe(true)
|
||||||
|
expect(after.sameViewport).toBe(true)
|
||||||
|
expect(after.sameSurface).toBe(true)
|
||||||
|
expect(after.sameMountedRows).toBe(true)
|
||||||
|
expect(after.nativeOffset).toBe(0)
|
||||||
|
expect(after.coreOffset).toBe(before.coreOffset)
|
||||||
|
expect(after.rangeStart).toBe(before.rangeStart)
|
||||||
|
expect(after.rangeEnd).toBe(before.rangeEnd)
|
||||||
|
expect(after.indexes).toEqual(before.indexes)
|
||||||
|
expect(after.domIndexes).toEqual(beforeRows)
|
||||||
|
expect(after.domScrollEvents).toBe(before.domScrollEvents)
|
||||||
|
expect(after.coreOffsetCallbackCalls).toBe(before.coreOffsetCallbackCalls)
|
||||||
|
expect(after.mutationEvents).toHaveLength(2)
|
||||||
|
expect(after.mutationEvents[1]).toMatchObject({
|
||||||
|
kind: "added",
|
||||||
|
routeConnectedInCallback: true,
|
||||||
|
nativeOffsetInCallback: 0,
|
||||||
|
})
|
||||||
|
expect(after.mutationEvents[1]!.callbackTime).toBeLessThanOrEqual(after.operation.time)
|
||||||
|
expect(after.mutationEvents[1]!.callbackFrame).toBeLessThanOrEqual(after.operation.frame)
|
||||||
|
expect(after.visibleRows).toBe(0)
|
||||||
|
expect(after.minimumRowTop).toBeGreaterThan(50_000)
|
||||||
|
expect(after.syntheticScrollDispatches).toBe(0)
|
||||||
|
|
||||||
|
await page.locator("[data-viewport]").hover()
|
||||||
|
await page.mouse.wheel(0, 80)
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
page.evaluate(() => {
|
||||||
|
const value = window.timelineSuspense.snapshot()
|
||||||
|
return value.nativeOffset > 0 && value.coreOffset === value.nativeOffset
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
await page.evaluate(() => window.timelineSuspense.frames(2))
|
||||||
|
const recovered = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||||
|
expect(recovered.domScrollEvents).toBeGreaterThan(after.domScrollEvents)
|
||||||
|
expect(recovered.coreOffsetCallbackCalls).toBeGreaterThan(after.coreOffsetCallbackCalls)
|
||||||
|
expect(recovered.offsetCallbackSources.at(-1)).toBe("observer")
|
||||||
|
expect(recovered.lastScrollTrusted).toBe(true)
|
||||||
|
expect(recovered.rangeStart).toBeLessThan(10)
|
||||||
|
expect(recovered.visibleRows).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matrix: fixture-only settled-resource guard keeps the route connected", async ({ page }) => {
|
||||||
|
await page.goto("/?resource=guard")
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().mode.resource)).toBe("guard")
|
||||||
|
const before = await prepare(page)
|
||||||
|
|
||||||
|
await page.evaluate(() => window.timelineSuspense.trigger())
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
|
||||||
|
await page.evaluate(() => window.timelineSuspense.frames(3))
|
||||||
|
const pending = await page.evaluate(() => window.timelineSuspense.snapshot())
|
||||||
|
expect(pending.routeConnected).toBe(true)
|
||||||
|
expect(pending.mutationEvents).toEqual([])
|
||||||
|
expect(pending.nativeOffset).toBe(before.nativeOffset)
|
||||||
|
expect(pending.coreOffset).toBe(before.coreOffset)
|
||||||
|
expect(pending.visibleRows).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const after = await resolveSuspension(page)
|
||||||
|
expect(after.routeConnected).toBe(true)
|
||||||
|
expect(after.nativeOffset).toBe(before.nativeOffset)
|
||||||
|
expect(after.coreOffset).toBe(before.coreOffset)
|
||||||
|
expect(after.visibleRows).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function prepare(page: Page) {
|
||||||
|
const before = await page.evaluate(() => window.timelineSuspense.prepare())
|
||||||
|
expect(before.routeConnected).toBe(true)
|
||||||
|
expect(before.viewportConnected).toBe(true)
|
||||||
|
expect(before.viewportOwnedByRoute).toBe(true)
|
||||||
|
expect(before.sameMountedRows).toBe(true)
|
||||||
|
expect(before.rangeStart).toBeGreaterThan(1_900)
|
||||||
|
expect(before.nativeOffset).toBe(before.coreOffset)
|
||||||
|
return before
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerBaselineSuspension(page: Page) {
|
||||||
|
await page.evaluate(() => window.timelineSuspense.trigger())
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("refreshing")
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(false)
|
||||||
|
await page.evaluate(() => window.timelineSuspense.frames(3))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSuspension(page: Page) {
|
||||||
|
await page.evaluate(() => window.timelineSuspense.resolve())
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().resourceState)).toBe("ready")
|
||||||
|
await expect.poll(() => page.evaluate(() => window.timelineSuspense.snapshot().routeConnected)).toBe(true)
|
||||||
|
await page.evaluate(() => window.timelineSuspense.frames(3))
|
||||||
|
return page.evaluate(() => window.timelineSuspense.snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnostic(value: unknown) {
|
||||||
|
return JSON.stringify(value, null, 2)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from "vite"
|
||||||
|
import solid from "vite-plugin-solid"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
root: import.meta.dirname,
|
||||||
|
plugins: [solid()],
|
||||||
|
})
|
||||||
|
|
@ -736,5 +736,5 @@ async function switchTitlebarSession(page: Page, sessionID: string, title: strin
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectSessionReady(page: Page) {
|
async function expectSessionReady(page: Page) {
|
||||||
await expectAppVisible(page.getByRole("textbox", { name: /Ask anything/i }))
|
await expectAppVisible(page.getByRole("textbox", { name: "Prompt" }))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@
|
||||||
"./performance/timeline-stability/fixture.test.ts",
|
"./performance/timeline-stability/fixture.test.ts",
|
||||||
"./performance/timeline-stability/fixture.ts",
|
"./performance/timeline-stability/fixture.ts",
|
||||||
"./performance/unit/visual-stability.test.ts",
|
"./performance/unit/visual-stability.test.ts",
|
||||||
|
"./reproduction/timeline-suspense/**/*.ts",
|
||||||
|
"./reproduction/timeline-suspense/**/*.tsx",
|
||||||
|
"../src/pages/session/timeline/observe-element-offset.ts",
|
||||||
|
"./regression/new-session-panel-corner.spec.ts",
|
||||||
"./regression/session-timeline-context-resize.spec.ts",
|
"./regression/session-timeline-context-resize.spec.ts",
|
||||||
"./utils/**/*.ts"
|
"./utils/**/*.ts"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
97
packages/app/e2e/user-story/model-selection-flow.spec.ts
Normal file
97
packages/app/e2e/user-story/model-selection-flow.spec.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { expect, test } from "@playwright/test"
|
||||||
|
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||||
|
import { expectAppVisible } from "../utils/waits"
|
||||||
|
|
||||||
|
const directory = "C:/OpenCode/NewProject"
|
||||||
|
|
||||||
|
test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => {
|
||||||
|
let connectedGo = false
|
||||||
|
let pendingGo = false
|
||||||
|
const connections: Array<{ integrationID: string; body: unknown }> = []
|
||||||
|
|
||||||
|
await mockOpenCodeServer(page, {
|
||||||
|
directory,
|
||||||
|
project: {
|
||||||
|
id: "proj_model_selection_flow",
|
||||||
|
worktree: directory,
|
||||||
|
vcs: "git",
|
||||||
|
name: "NewProject",
|
||||||
|
time: { created: 1_700_000_000_000, updated: 1_700_000_000_000 },
|
||||||
|
sandboxes: [],
|
||||||
|
},
|
||||||
|
provider: () => ({
|
||||||
|
all: [
|
||||||
|
{
|
||||||
|
id: "opencode",
|
||||||
|
name: "OpenCode",
|
||||||
|
models: {
|
||||||
|
"free-model": {
|
||||||
|
id: "free-model",
|
||||||
|
name: "Free Model",
|
||||||
|
cost: { input: 0, output: 0 },
|
||||||
|
limit: { context: 200_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "opencode-go",
|
||||||
|
name: "OpenCode Go",
|
||||||
|
models: {
|
||||||
|
"go-model-1": {
|
||||||
|
id: "go-model-1",
|
||||||
|
name: "Go Model 1",
|
||||||
|
cost: { input: 1, output: 1 },
|
||||||
|
limit: { context: 200_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"],
|
||||||
|
default: { providerID: "opencode", modelID: "free-model" },
|
||||||
|
}),
|
||||||
|
integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] },
|
||||||
|
onConnectKey: (input) => {
|
||||||
|
connections.push(input)
|
||||||
|
if (input.integrationID === "opencode-go") pendingGo = true
|
||||||
|
},
|
||||||
|
onInstanceDispose: () => {
|
||||||
|
if (pendingGo) connectedGo = true
|
||||||
|
},
|
||||||
|
sessions: [],
|
||||||
|
pageMessages: () => ({ items: [] }),
|
||||||
|
fileList: (path) =>
|
||||||
|
path ? [] : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }],
|
||||||
|
findFiles: () => ["NewProject"],
|
||||||
|
})
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||||
|
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } }))
|
||||||
|
})
|
||||||
|
|
||||||
|
await page.goto("/")
|
||||||
|
const addProject = page.locator('[data-action="home-add-project-row"]')
|
||||||
|
await expectAppVisible(addProject)
|
||||||
|
await addProject.click()
|
||||||
|
await page.locator("[data-directory-path]").click()
|
||||||
|
|
||||||
|
await page.locator('[data-action="home-new-session"]').click()
|
||||||
|
await expectAppVisible(page.locator('[data-component="prompt-input-v2"]'))
|
||||||
|
|
||||||
|
const modelControl = page.locator('[data-action="prompt-model"]')
|
||||||
|
await modelControl.click()
|
||||||
|
await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode")
|
||||||
|
|
||||||
|
await page.locator('[data-provider-id="opencode-go"]').click()
|
||||||
|
await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key")
|
||||||
|
await page.locator('[data-action="provider-connect-submit"]').click()
|
||||||
|
await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0)
|
||||||
|
expect(connections).toEqual([{ integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }])
|
||||||
|
|
||||||
|
await expect(modelControl).toHaveAttribute("data-control-type", "popover")
|
||||||
|
await modelControl.click()
|
||||||
|
const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]')
|
||||||
|
await expect(goModel).toBeVisible()
|
||||||
|
await goModel.click()
|
||||||
|
|
||||||
|
await expect(modelControl).toContainText("Go Model 1")
|
||||||
|
})
|
||||||
|
|
@ -1,17 +1,24 @@
|
||||||
import type { Page, Route } from "@playwright/test"
|
import type { Page, Route } from "@playwright/test"
|
||||||
|
|
||||||
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
|
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
|
||||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp"])
|
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
|
||||||
|
|
||||||
export interface MockServerConfig {
|
export interface MockServerConfig {
|
||||||
provider: unknown
|
protocol?: "v1" | "v2"
|
||||||
|
provider: unknown | (() => unknown)
|
||||||
|
integrationMethods?: Record<string, unknown[]>
|
||||||
|
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
|
||||||
|
onInstanceDispose?: () => void
|
||||||
directory: string
|
directory: string
|
||||||
project: unknown
|
project: unknown
|
||||||
sessions: ({ id: string } & Record<string, unknown>)[]
|
sessions: ({ id: string } & Record<string, unknown>)[]
|
||||||
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
|
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
|
||||||
vcsDiff?: unknown[]
|
vcsDiff?: unknown[]
|
||||||
messageDelay?: number
|
messageDelay?: number
|
||||||
|
beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
|
||||||
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
||||||
|
message?: (sessionID: string, messageID: string) => unknown
|
||||||
|
onMessage?: (input: { sessionID: string; messageID: string }) => void
|
||||||
events?: () => unknown[]
|
events?: () => unknown[]
|
||||||
eventRetry?: number
|
eventRetry?: number
|
||||||
todos?: (sessionID: string) => unknown[]
|
todos?: (sessionID: string) => unknown[]
|
||||||
|
|
@ -19,14 +26,14 @@ export interface MockServerConfig {
|
||||||
questions?: unknown[] | (() => unknown[])
|
questions?: unknown[] | (() => unknown[])
|
||||||
fileList?: (path: string) => unknown | Promise<unknown>
|
fileList?: (path: string) => unknown | Promise<unknown>
|
||||||
fileContent?: (path: string) => unknown | Promise<unknown>
|
fileContent?: (path: string) => unknown | Promise<unknown>
|
||||||
sessionStatus?: unknown
|
findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown
|
||||||
|
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
const cursors = new Map<string, string>()
|
const cursors = new Map<string, string>()
|
||||||
let nextCursor = 0
|
let nextCursor = 0
|
||||||
const staticRoutes: Record<string, unknown> = {
|
const staticRoutes: Record<string, unknown> = {
|
||||||
"/provider": config.provider,
|
|
||||||
"/path": {
|
"/path": {
|
||||||
state: config.directory,
|
state: config.directory,
|
||||||
config: config.directory,
|
config: config.directory,
|
||||||
|
|
@ -50,39 +57,249 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
|
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
|
||||||
|
|
||||||
const path = url.pathname
|
const path = url.pathname
|
||||||
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
if (path === "/global/event" || path === "/event" || path === "/api/event") {
|
||||||
if (path === "/global/health") return json(route, { healthy: true })
|
const events = config.events?.()
|
||||||
|
return sse(
|
||||||
|
route,
|
||||||
|
path === "/api/event"
|
||||||
|
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
|
||||||
|
: [
|
||||||
|
...(path === "/global/event"
|
||||||
|
? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
|
||||||
|
: []),
|
||||||
|
...(events ?? []),
|
||||||
|
],
|
||||||
|
config.eventRetry,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (path === "/global/health")
|
||||||
|
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
|
||||||
|
if (path === "/api/health" && config.protocol === "v2")
|
||||||
|
return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
||||||
|
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
||||||
|
if (path === "/provider")
|
||||||
|
return json(route, typeof config.provider === "function" ? config.provider() : config.provider)
|
||||||
|
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
|
||||||
|
const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
|
||||||
|
if (legacyAuth && route.request().method() === "PUT") {
|
||||||
|
config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() })
|
||||||
|
return json(route, true)
|
||||||
|
}
|
||||||
|
if (path === "/instance/dispose" && route.request().method() === "POST") {
|
||||||
|
config.onInstanceDispose?.()
|
||||||
|
return json(route, true)
|
||||||
|
}
|
||||||
if (path === "/permission")
|
if (path === "/permission")
|
||||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||||
if (path === "/question")
|
if (path === "/question")
|
||||||
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
|
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
|
||||||
if (path === "/session/status") return json(route, config.sessionStatus ?? {})
|
if (path === "/session/status")
|
||||||
|
return json(
|
||||||
|
route,
|
||||||
|
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}),
|
||||||
|
)
|
||||||
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
||||||
if (path === "/file" && config.fileList)
|
if (path === "/file" && config.fileList)
|
||||||
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
||||||
if (path === "/file/content" && config.fileContent)
|
if (path === "/file/content" && config.fileContent)
|
||||||
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
|
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
|
||||||
|
if (path === "/find/file" && config.findFiles)
|
||||||
|
return json(
|
||||||
|
route,
|
||||||
|
await config.findFiles({
|
||||||
|
query: url.searchParams.get("query") ?? "",
|
||||||
|
dirs: url.searchParams.get("dirs") ?? undefined,
|
||||||
|
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (path === "/api/reference")
|
||||||
|
return json(route, {
|
||||||
|
location: {
|
||||||
|
directory: config.directory,
|
||||||
|
project: { id: (config.project as { id?: string }).id, directory: config.directory },
|
||||||
|
},
|
||||||
|
data: [],
|
||||||
|
})
|
||||||
|
if (path === "/api/agent")
|
||||||
|
return json(route, {
|
||||||
|
location: location(config),
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "build",
|
||||||
|
name: "Build",
|
||||||
|
mode: "primary",
|
||||||
|
hidden: false,
|
||||||
|
request: { settings: {}, headers: {}, body: {} },
|
||||||
|
permissions: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
if (path === "/api/command") return json(route, { location: location(config), data: [] })
|
||||||
|
if (path === "/api/mcp") return json(route, { location: location(config), data: [] })
|
||||||
|
if (path === "/api/mcp/resource")
|
||||||
|
return json(route, { location: location(config), data: { resources: [], templates: [] } })
|
||||||
|
const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1]
|
||||||
|
if (integration && route.request().method() === "GET")
|
||||||
|
return json(route, {
|
||||||
|
location: location(config),
|
||||||
|
data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] },
|
||||||
|
})
|
||||||
|
const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1]
|
||||||
|
if (integrationConnect && route.request().method() === "POST") {
|
||||||
|
config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() })
|
||||||
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
|
}
|
||||||
|
if (path === "/api/project") return json(route, [config.project])
|
||||||
|
if (path === "/api/project/current")
|
||||||
|
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
||||||
|
if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project)
|
||||||
|
if (path === "/api/path")
|
||||||
|
return json(route, {
|
||||||
|
state: config.directory,
|
||||||
|
config: config.directory,
|
||||||
|
worktree: config.directory,
|
||||||
|
directory: config.directory,
|
||||||
|
home: "C:/OpenCode",
|
||||||
|
})
|
||||||
|
if (path === "/api/permission/request")
|
||||||
|
return json(route, {
|
||||||
|
location: location(config),
|
||||||
|
data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map(
|
||||||
|
currentPermission,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
if (path === "/api/question/request")
|
||||||
|
return json(route, {
|
||||||
|
location: location(config),
|
||||||
|
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
|
||||||
|
})
|
||||||
|
if (path === "/api/vcs")
|
||||||
|
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
|
||||||
|
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
|
||||||
|
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
|
||||||
|
if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
|
||||||
|
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
|
||||||
|
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
|
||||||
if (emptyObject.has(path)) return json(route, {})
|
if (emptyObject.has(path)) return json(route, {})
|
||||||
if (emptyList.has(path)) return json(route, [])
|
if (emptyList.has(path)) return json(route, [])
|
||||||
|
if (path === "/api/session") {
|
||||||
|
const directory = url.searchParams.get("directory")
|
||||||
|
const parentID = url.searchParams.get("parentID")
|
||||||
|
const limit = Number(url.searchParams.get("limit") ?? 50)
|
||||||
|
const offset = Number(url.searchParams.get("cursor") ?? 0)
|
||||||
|
const sessions = config.sessions
|
||||||
|
.filter((session) => !directory || session.directory === directory)
|
||||||
|
.filter((session) => parentID !== "null" || session.parentID === undefined)
|
||||||
|
.filter((session) => {
|
||||||
|
const search = url.searchParams.get("search")?.toLowerCase()
|
||||||
|
return (
|
||||||
|
!search ||
|
||||||
|
String(session.title ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(search)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions
|
||||||
|
const data = ordered.slice(offset, offset + limit)
|
||||||
|
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
|
||||||
|
return json(route, {
|
||||||
|
data: data.map((session) => currentSession(session, config.directory)),
|
||||||
|
cursor: { next },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (path === "/api/session/active") {
|
||||||
|
const statuses = (config.sessionStatus ?? {}) as Record<string, { type?: string }>
|
||||||
|
return json(route, {
|
||||||
|
data: Object.fromEntries(
|
||||||
|
Object.entries(statuses).flatMap(([id, status]) =>
|
||||||
|
status.type === "idle" ? [] : [[id, { type: "running" }]],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
|
||||||
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
|
}
|
||||||
|
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
||||||
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
|
}
|
||||||
|
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||||
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
|
}
|
||||||
|
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
||||||
|
return json(route, true)
|
||||||
|
}
|
||||||
|
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") {
|
||||||
|
return json(route, true)
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
||||||
|
route.request().method() === "POST"
|
||||||
|
) {
|
||||||
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
|
}
|
||||||
|
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
|
||||||
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
|
}
|
||||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||||
|
|
||||||
|
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
|
||||||
|
if (currentSessionMatch) {
|
||||||
|
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
|
||||||
|
if (!session) return json(route, { error: "Session not found" }, undefined, 404)
|
||||||
|
return json(route, {
|
||||||
|
data: currentSession(session, config.directory),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
|
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
|
||||||
if (sessionMatch) {
|
if (sessionMatch) {
|
||||||
const session = config.sessions.find((s) => s.id === sessionMatch[1])
|
const session = config.sessions.find((s) => s.id === sessionMatch[1])
|
||||||
return json(route, session ?? {})
|
return json(route, session ?? {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const projectMatch = path.match(/^\/project\/([^/]+)$/)
|
||||||
|
if (projectMatch) return json(route, config.project)
|
||||||
|
|
||||||
|
const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/)
|
||||||
|
if (messageMatch) {
|
||||||
|
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
|
||||||
|
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||||
|
const message = config.message?.(messageMatch[1]!, messageMatch[2]!)
|
||||||
|
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
|
||||||
|
return json(route, message)
|
||||||
|
}
|
||||||
|
|
||||||
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
|
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
|
||||||
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
||||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||||
|
|
||||||
|
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
|
||||||
|
if (currentMessagesMatch) {
|
||||||
|
const token = url.searchParams.get("cursor") ?? undefined
|
||||||
|
const before = token ? cursors.get(token) : undefined
|
||||||
|
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
||||||
|
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" })
|
||||||
|
await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before })
|
||||||
|
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||||
|
const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
|
||||||
|
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" })
|
||||||
|
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
|
||||||
|
if (cursor) cursors.set(cursor, pageData.cursor!)
|
||||||
|
return json(route, {
|
||||||
|
data: pageData.items.map(currentMessage).reverse(),
|
||||||
|
cursor: { next: cursor },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||||
if (messagesMatch) {
|
if (messagesMatch) {
|
||||||
const token = url.searchParams.get("before") ?? undefined
|
const token = url.searchParams.get("before") ?? undefined
|
||||||
const before = token ? cursors.get(token) : undefined
|
const before = token ? cursors.get(token) : undefined
|
||||||
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
||||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
|
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
|
||||||
if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
|
||||||
|
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||||
const limit = Number(url.searchParams.get("limit") ?? 80)
|
const limit = Number(url.searchParams.get("limit") ?? 80)
|
||||||
const pageData = config.pageMessages(messagesMatch[1], limit, before)
|
const pageData = config.pageMessages(messagesMatch[1], limit, before)
|
||||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
||||||
|
|
@ -97,6 +314,115 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function location(config: MockServerConfig) {
|
||||||
|
return {
|
||||||
|
directory: config.directory,
|
||||||
|
project: { id: (config.project as { id?: string }).id, directory: config.directory },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentPermission(value: unknown) {
|
||||||
|
const permission = value as Record<string, unknown>
|
||||||
|
if (permission.action) return permission
|
||||||
|
const tool = permission.tool as { messageID?: string; callID?: string } | undefined
|
||||||
|
return {
|
||||||
|
id: permission.id,
|
||||||
|
sessionID: permission.sessionID,
|
||||||
|
action: permission.permission,
|
||||||
|
resources: permission.patterns ?? [],
|
||||||
|
save: permission.always,
|
||||||
|
metadata: permission.metadata,
|
||||||
|
source:
|
||||||
|
tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
|
||||||
|
const time = session.time && typeof session.time === "object" ? session.time : {}
|
||||||
|
return {
|
||||||
|
id: session.id,
|
||||||
|
parentID: session.parentID,
|
||||||
|
projectID: session.projectID ?? "project",
|
||||||
|
agent: session.agent ?? "build",
|
||||||
|
model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
|
||||||
|
cost: session.cost ?? 0,
|
||||||
|
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: {
|
||||||
|
created: "created" in time && typeof time.created === "number" ? time.created : 0,
|
||||||
|
updated: "updated" in time && typeof time.updated === "number" ? time.updated : 0,
|
||||||
|
...(session.time && typeof session.time === "object" && "archived" in session.time
|
||||||
|
? { archived: session.time.archived }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
title: session.title ?? session.id,
|
||||||
|
location: {
|
||||||
|
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
|
||||||
|
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
|
||||||
|
},
|
||||||
|
subpath: session.path,
|
||||||
|
revert: session.revert,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentMessage(value: unknown) {
|
||||||
|
const item = value as {
|
||||||
|
info: Record<string, unknown> & { id: string; role: "user" | "assistant"; time: { created: number } }
|
||||||
|
parts: Array<Record<string, unknown> & { type: string }>
|
||||||
|
}
|
||||||
|
if (item.info.role === "user") {
|
||||||
|
return {
|
||||||
|
id: item.info.id,
|
||||||
|
type: "user",
|
||||||
|
time: item.info.time,
|
||||||
|
text: item.parts
|
||||||
|
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
|
||||||
|
.join("\n"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: item.info.id,
|
||||||
|
type: "assistant",
|
||||||
|
time: item.info.time,
|
||||||
|
agent: item.info.agent ?? "build",
|
||||||
|
model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" },
|
||||||
|
cost: item.info.cost,
|
||||||
|
tokens: item.info.tokens,
|
||||||
|
error: item.info.error,
|
||||||
|
content: item.parts.flatMap<unknown>((part) => {
|
||||||
|
if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }]
|
||||||
|
if (part.type !== "tool") return []
|
||||||
|
const state = part.state as Record<string, unknown>
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "tool",
|
||||||
|
id: part.id,
|
||||||
|
name: part.tool,
|
||||||
|
time: state.time ?? { created: item.info.time.created },
|
||||||
|
state:
|
||||||
|
state.status === "pending"
|
||||||
|
? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) }
|
||||||
|
: state.status === "completed"
|
||||||
|
? {
|
||||||
|
status: "completed",
|
||||||
|
input: state.input ?? {},
|
||||||
|
structured: state.metadata ?? {},
|
||||||
|
content: [{ type: "text", text: state.output ?? "" }],
|
||||||
|
}
|
||||||
|
: state.status === "error"
|
||||||
|
? {
|
||||||
|
status: "error",
|
||||||
|
input: state.input ?? {},
|
||||||
|
structured: state.metadata ?? {},
|
||||||
|
content: [],
|
||||||
|
error: { type: "ToolError", message: state.error ?? "Tool failed" },
|
||||||
|
}
|
||||||
|
: { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
|
function json(route: Route, body: unknown, headers?: Record<string, string>, status = 200) {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
status,
|
status,
|
||||||
|
|
@ -117,3 +443,18 @@ function sse(route: Route, events?: unknown[], retry?: number) {
|
||||||
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
|
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentEvent(input: unknown) {
|
||||||
|
if (!input || typeof input !== "object" || !("payload" in input)) return input
|
||||||
|
const envelope = input as { directory?: string; payload?: unknown }
|
||||||
|
if (!envelope.payload || typeof envelope.payload !== "object") return input
|
||||||
|
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
|
||||||
|
if (!payload.type) return input
|
||||||
|
return {
|
||||||
|
id: payload.id ?? `evt_mock_${Date.now()}`,
|
||||||
|
created: Date.now(),
|
||||||
|
type: payload.type,
|
||||||
|
data: payload.properties ?? {},
|
||||||
|
location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { Page } from "@playwright/test"
|
||||||
export type SseConnectionRecord = {
|
export type SseConnectionRecord = {
|
||||||
id: number
|
id: number
|
||||||
url: string
|
url: string
|
||||||
path: "/global/event" | "/event"
|
path: "/global/event" | "/event" | "/api/event"
|
||||||
headers: Record<string, string>
|
headers: Record<string, string>
|
||||||
openedAt: number
|
openedAt: number
|
||||||
endedAt?: number
|
endedAt?: number
|
||||||
|
|
@ -93,6 +93,21 @@ export async function installSseTransport<T>(
|
||||||
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
||||||
`data: ${JSON.stringify(payload)}\n\n`,
|
`data: ${JSON.stringify(payload)}\n\n`,
|
||||||
].join("")
|
].join("")
|
||||||
|
const currentEvent = (input: unknown) => {
|
||||||
|
if (!input || typeof input !== "object" || !("payload" in input)) return input
|
||||||
|
const envelope = input as { directory?: string; payload?: unknown }
|
||||||
|
if (!envelope.payload || typeof envelope.payload !== "object") return input
|
||||||
|
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
|
||||||
|
if (!payload.type) return input
|
||||||
|
return {
|
||||||
|
id: payload.id ?? `evt_mock_${Date.now()}`,
|
||||||
|
created: Date.now(),
|
||||||
|
type: payload.type,
|
||||||
|
data: payload.properties ?? {},
|
||||||
|
location:
|
||||||
|
envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
const acknowledge = (
|
const acknowledge = (
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
bytes: number,
|
bytes: number,
|
||||||
|
|
@ -140,15 +155,13 @@ export async function installSseTransport<T>(
|
||||||
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
||||||
return acknowledge(connection, input.bytes.length, output.length)
|
return acknowledge(connection, input.bytes.length, output.length)
|
||||||
}
|
}
|
||||||
const encoded = input.deliveries.map((delivery) => ({
|
const encoded = input.deliveries.map((delivery) => {
|
||||||
delivery,
|
const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
|
||||||
bytes: encoder.encode(frame(delivery.payload, delivery.options)),
|
return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
|
||||||
}))
|
})
|
||||||
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
||||||
if (input.burst) {
|
if (input.burst) {
|
||||||
const bytes = encoder.encode(
|
const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join(""))
|
||||||
encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""),
|
|
||||||
)
|
|
||||||
connection.controller.enqueue(bytes)
|
connection.controller.enqueue(bytes)
|
||||||
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
|
return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id))
|
||||||
}
|
}
|
||||||
|
|
@ -161,8 +174,11 @@ export async function installSseTransport<T>(
|
||||||
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const request = new Request(input, init)
|
const request = new Request(input, init)
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event"))
|
if (
|
||||||
return originalFetch(input, init)
|
url.origin !== server ||
|
||||||
|
(url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event")
|
||||||
|
)
|
||||||
|
return originalFetch(request)
|
||||||
|
|
||||||
const id = ++nextConnectionID
|
const id = ++nextConnectionID
|
||||||
const record = {
|
const record = {
|
||||||
|
|
@ -177,6 +193,18 @@ export async function installSseTransport<T>(
|
||||||
record.controller = controller
|
record.controller = controller
|
||||||
connections.push(record)
|
connections.push(record)
|
||||||
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`))
|
||||||
|
if (url.pathname === "/api/event")
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })),
|
||||||
|
)
|
||||||
|
if (url.pathname === "/global/event")
|
||||||
|
controller.enqueue(
|
||||||
|
encoder.encode(
|
||||||
|
frame({
|
||||||
|
payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
request.signal.addEventListener(
|
request.signal.addEventListener(
|
||||||
"abort",
|
"abort",
|
||||||
() => {
|
() => {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.17.13",
|
"version": "1.18.11",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
@ -19,9 +19,9 @@
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"test": "bun run test:unit && bun run test:browser",
|
"test": "bun run test:unit && bun run test:browser",
|
||||||
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
|
"test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src",
|
||||||
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
|
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
|
||||||
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:local": "playwright test",
|
"test:e2e:local": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
|
@ -47,11 +47,13 @@
|
||||||
"vite-plugin-solid": "catalog:"
|
"vite-plugin-solid": "catalog:"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@corvu/drawer": "catalog:",
|
||||||
"@dnd-kit/abstract": "0.5.0",
|
"@dnd-kit/abstract": "0.5.0",
|
||||||
"@dnd-kit/dom": "0.5.0",
|
"@dnd-kit/dom": "0.5.0",
|
||||||
"@dnd-kit/helpers": "0.5.0",
|
"@dnd-kit/helpers": "0.5.0",
|
||||||
"@dnd-kit/solid": "0.5.0",
|
"@dnd-kit/solid": "0.5.0",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
|
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
"@opencode-ai/schema": "workspace:*",
|
"@opencode-ai/schema": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
|
@ -80,7 +82,7 @@
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"ghostty-web": "github:anomalyco/ghostty-web#513463a6f1190253057e8a3f0dac8f6ee8393553",
|
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
"marked": "catalog:",
|
"marked": "catalog:",
|
||||||
"marked-shiki": "catalog:",
|
"marked-shiki": "catalog:",
|
||||||
|
|
@ -88,6 +90,7 @@
|
||||||
"shiki": "catalog:",
|
"shiki": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"solid-list": "catalog:",
|
"solid-list": "catalog:",
|
||||||
|
"solid-presence": "0.2.0",
|
||||||
"tailwindcss": "catalog:"
|
"tailwindcss": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,14 @@ function writeAndWait(term: Terminal, data: string): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("SerializeAddon", () => {
|
describe("SerializeAddon", () => {
|
||||||
|
test("preserves color scheme reporting mode", async () => {
|
||||||
|
const { term, addon } = createTerminal()
|
||||||
|
await writeAndWait(term, "\x1b[?2031h")
|
||||||
|
|
||||||
|
expect(addon.serialize().startsWith("\x1b[?2031h")).toBe(true)
|
||||||
|
expect(addon.serialize({ excludeModes: true }).startsWith("\x1b[?2031h")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
describe("ANSI color preservation", () => {
|
describe("ANSI color preservation", () => {
|
||||||
test("should preserve text attributes (bold, italic, underline)", async () => {
|
test("should preserve text attributes (bold, italic, underline)", async () => {
|
||||||
const { term, addon } = createTerminal()
|
const { term, addon } = createTerminal()
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,13 @@ const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined =
|
||||||
return { active, normal, alternate }
|
return { active, normal, alternate }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getTerminalMode = (value: ITerminalCore, mode: number) => {
|
||||||
|
if (!isRecord(value)) return false
|
||||||
|
const terminal = value.wasmTerm
|
||||||
|
if (!isRecord(terminal) || typeof terminal.getMode !== "function") return false
|
||||||
|
return terminal.getMode(mode) === true
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Types
|
// Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -544,7 +551,8 @@ export class SerializeAddon implements ITerminalAddon {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = options?.range
|
let content = !options?.excludeModes && getTerminalMode(this._terminal, 2031) ? "\u001b[?2031h" : ""
|
||||||
|
content += options?.range
|
||||||
? this._serializeBufferByRange(normalBuffer, options.range, true)
|
? this._serializeBufferByRange(normalBuffer, options.range, true)
|
||||||
: this._serializeBufferByScrollback(normalBuffer, options?.scrollback)
|
: this._serializeBufferByScrollback(normalBuffer, options?.scrollback)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,19 @@ import { Font } from "@opencode-ai/ui/font"
|
||||||
import { Splash } from "@opencode-ai/ui/logo"
|
import { Splash } from "@opencode-ai/ui/logo"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
import {
|
||||||
|
type BaseRouterProps,
|
||||||
|
Navigate,
|
||||||
|
Route,
|
||||||
|
Router,
|
||||||
|
useLocation,
|
||||||
|
useNavigate,
|
||||||
|
useParams,
|
||||||
|
useSearchParams,
|
||||||
|
} from "@solidjs/router"
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import {
|
import {
|
||||||
type Component,
|
type Component,
|
||||||
createEffect,
|
createEffect,
|
||||||
|
|
@ -28,11 +38,12 @@ import {
|
||||||
Show,
|
Show,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
|
||||||
import { CommentsProvider } from "@/context/comments"
|
import { CommentsProvider } from "@/context/comments"
|
||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
import { ServerSDKProvider } from "@/context/server-sdk"
|
import { ServerSDKProvider } from "@/context/server-sdk"
|
||||||
import { ServerSyncProvider } from "@/context/server-sync"
|
import { ServerSyncProvider, useServerSync } from "@/context/server-sync"
|
||||||
import { GlobalProvider, useGlobal } from "@/context/global"
|
import { GlobalProvider, useGlobal } from "@/context/global"
|
||||||
import { HighlightsProvider } from "@/context/highlights"
|
import { HighlightsProvider } from "@/context/highlights"
|
||||||
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||||
|
|
@ -52,10 +63,12 @@ import LegacyLayout from "@/pages/layout"
|
||||||
import NewLayout from "@/pages/layout-new"
|
import NewLayout from "@/pages/layout-new"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { useCheckServerHealth } from "./utils/server-health"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||||
|
import { createSessionLineage } from "@/pages/session/session-lineage"
|
||||||
|
|
||||||
import { SessionPage, TargetSessionRouteContent } from "@/pages/session"
|
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
||||||
import { NewHome, LegacyHome } from "@/pages/home"
|
import { NewHome } from "@/pages/home"
|
||||||
|
import { LegacyHome } from "@/pages/home/legacy-home"
|
||||||
|
|
||||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
|
|
||||||
|
|
@ -88,10 +101,14 @@ const SessionRoute = () => {
|
||||||
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
tabs.newDraft({ server: server.key, directory: sdk().directory }, search.prompt)
|
||||||
})
|
})
|
||||||
|
|
||||||
return <SessionPage />
|
return (
|
||||||
|
<SessionRouteErrorBoundary sessionID={params.id}>
|
||||||
|
<SessionPage />
|
||||||
|
</SessionRouteErrorBoundary>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const TargetSessionRoute = () => {
|
function TargetServerRoute(props: ParentProps) {
|
||||||
const params = useParams<{ serverKey: string; id: string }>()
|
const params = useParams<{ serverKey: string; id: string }>()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const conn = createMemo(() => {
|
const conn = createMemo(() => {
|
||||||
|
|
@ -105,17 +122,49 @@ const TargetSessionRoute = () => {
|
||||||
// re-resolves reactively instead); both rely on this key for server changes.
|
// re-resolves reactively instead); both rely on this key for server changes.
|
||||||
<Show when={requireServerKey(params.serverKey)} keyed>
|
<Show when={requireServerKey(params.serverKey)} keyed>
|
||||||
<ServerSDKProvider server={conn}>
|
<ServerSDKProvider server={conn}>
|
||||||
<ServerSyncProvider server={conn}>
|
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
|
||||||
<TargetSessionRouteContent />
|
|
||||||
</ServerSyncProvider>
|
|
||||||
</ServerSDKProvider>
|
</ServerSDKProvider>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TargetSessionRoute = () => (
|
||||||
|
<TargetServerRoute>
|
||||||
|
<TargetSessionRouteContent />
|
||||||
|
</TargetServerRoute>
|
||||||
|
)
|
||||||
|
|
||||||
|
function LegacyTargetSessionRoute() {
|
||||||
|
const params = useParams<{ serverKey: string; id: string }>()
|
||||||
|
return (
|
||||||
|
<TargetServerRoute>
|
||||||
|
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
|
||||||
|
<LegacyTargetSessionRedirect />
|
||||||
|
</SessionRouteErrorBoundary>
|
||||||
|
</TargetServerRoute>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegacyTargetSessionRedirect() {
|
||||||
|
const params = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const sync = useServerSync()
|
||||||
|
const current = createSessionLineage(
|
||||||
|
() => params.id,
|
||||||
|
() => sync().session.lineage,
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const directory = current()?.session.directory
|
||||||
|
if (!directory) return
|
||||||
|
navigate(legacySessionHref(directory, params.id), { replace: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
||||||
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
// server via ServerKey, then provide the server-scoped shell for that server.
|
||||||
// Notification/Models + the visual Layout) for that server.
|
|
||||||
function SelectedServerProviders(props: ParentProps) {
|
function SelectedServerProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<ServerKey>
|
<ServerKey>
|
||||||
|
|
@ -126,16 +175,17 @@ function SelectedServerProviders(props: ParentProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacyServerLayout(props: ParentProps) {
|
function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||||
return (
|
return (
|
||||||
<SelectedServerProviders>
|
<SelectedServerProviders>
|
||||||
<LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
|
<LegacyServerScopedShell serverScoped={props.serverScoped}>{props.children}</LegacyServerScopedShell>
|
||||||
</SelectedServerProviders>
|
</SelectedServerProviders>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DraftRoute() {
|
function DraftRoute() {
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
|
const settings = useSettings()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
return (
|
return (
|
||||||
<Show when={tabs.ready()}>
|
<Show when={tabs.ready()}>
|
||||||
|
|
@ -144,7 +194,14 @@ function DraftRoute() {
|
||||||
keyed
|
keyed
|
||||||
fallback={<Navigate href="/" />}
|
fallback={<Navigate href="/" />}
|
||||||
>
|
>
|
||||||
{(draft) => <ResolvedDraftRoute draft={draft} />}
|
{(draft) => (
|
||||||
|
<Show
|
||||||
|
when={settings.general.newLayoutDesigns()}
|
||||||
|
fallback={<Navigate href={`/${base64Encode(draft.directory)}/session`} />}
|
||||||
|
>
|
||||||
|
<ResolvedDraftRoute draft={draft} />
|
||||||
|
</Show>
|
||||||
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
|
|
@ -160,7 +217,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||||
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
|
||||||
<ServerSDKProvider server={conn}>
|
<ServerSDKProvider server={conn}>
|
||||||
<ServerSyncProvider server={conn}>
|
<ServerSyncProvider server={conn}>
|
||||||
<DraftServerScopedProviders directory={directory}>
|
<ModelsProvider directory={directory}>
|
||||||
<SDKProvider directory={directory}>
|
<SDKProvider directory={directory}>
|
||||||
<DirectoryDataProvider directory={directory} server={serverKey}>
|
<DirectoryDataProvider directory={directory} server={serverKey}>
|
||||||
<DraftProviders>
|
<DraftProviders>
|
||||||
|
|
@ -168,7 +225,7 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
|
||||||
</DraftProviders>
|
</DraftProviders>
|
||||||
</DirectoryDataProvider>
|
</DirectoryDataProvider>
|
||||||
</SDKProvider>
|
</SDKProvider>
|
||||||
</DraftServerScopedProviders>
|
</ModelsProvider>
|
||||||
</ServerSyncProvider>
|
</ServerSyncProvider>
|
||||||
</ServerSDKProvider>
|
</ServerSDKProvider>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
@ -180,13 +237,37 @@ function UiI18nBridge(props: ParentProps) {
|
||||||
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LayoutCompatibility(props: ParentProps) {
|
||||||
|
const global = useGlobal()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const server = useServer()
|
||||||
|
const settings = useSettings()
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (settings.general.newLayoutDesigns()) return
|
||||||
|
const current = server.current
|
||||||
|
if (!current) return
|
||||||
|
const protocol = global.ensureServerCtx(current).sdk.protocolKind()
|
||||||
|
if (protocol !== "v2") return
|
||||||
|
const next = global.servers.list().find((s) => {
|
||||||
|
if (ServerConnection.key(s) === ServerConnection.key(current)) return false
|
||||||
|
return global.ensureServerCtx(s).sdk.protocolKind() !== "v2"
|
||||||
|
})
|
||||||
|
if (!next) return
|
||||||
|
navigate("/")
|
||||||
|
queueMicrotask(() => server.setActive(ServerConnection.key(next)))
|
||||||
|
})
|
||||||
|
|
||||||
|
return <>{props.children}</>
|
||||||
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
__OPENCODE__?: {
|
__OPENCODE__?: {
|
||||||
deepLinks?: string[]
|
deepLinks?: string[]
|
||||||
}
|
}
|
||||||
api?: {
|
api?: {
|
||||||
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
|
setTitlebar?: (theme: { mode: "light" | "dark"; scheme?: "system" | "light" | "dark" }) => Promise<void>
|
||||||
exportDebugLogs?: () => Promise<string>
|
exportDebugLogs?: () => Promise<string>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -262,45 +343,36 @@ function DesktopCommands() {
|
||||||
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
// Server-scoped providers shared by the legacy shell and the top-level new shell.
|
||||||
type ServerScopedShellProps = ParentProps<{
|
type ServerScopedShellProps = ParentProps<{
|
||||||
directory?: () => string | undefined
|
directory?: () => string | undefined
|
||||||
sessionID?: () => string | undefined
|
serverScoped?: JSX.Element
|
||||||
}>
|
}>
|
||||||
|
|
||||||
function ServerScopedProviders(props: ServerScopedShellProps) {
|
function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||||
return (
|
return (
|
||||||
<PermissionProvider directory={props.directory}>
|
<LayoutProvider>
|
||||||
<LayoutProvider>
|
{props.serverScoped}
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||||
</LayoutProvider>
|
</LayoutProvider>
|
||||||
</PermissionProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
||||||
return (
|
return (
|
||||||
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
|
<ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
|
||||||
<LegacyLayout>{props.children}</LegacyLayout>
|
<LegacyLayout>{props.children}</LegacyLayout>
|
||||||
</ServerScopedProviders>
|
</ServerScopedProviders>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NewAppLayout(props: ParentProps) {
|
function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||||
return (
|
return (
|
||||||
<SelectedServerProviders>
|
<SelectedServerProviders>
|
||||||
<ServerScopedProviders>
|
<ServerScopedProviders serverScoped={props.serverScoped}>
|
||||||
<NewLayout>{props.children}</NewLayout>
|
<NewLayout>{props.children}</NewLayout>
|
||||||
</ServerScopedProviders>
|
</ServerScopedProviders>
|
||||||
</SelectedServerProviders>
|
</SelectedServerProviders>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DraftServerScopedProviders(props: ParentProps<{ directory?: () => string | undefined }>) {
|
|
||||||
return (
|
|
||||||
<PermissionProvider directory={props.directory}>
|
|
||||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
|
||||||
</PermissionProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
// The draft page only renders the prompt composer, so it drops TerminalProvider.
|
||||||
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
|
||||||
function DraftProviders(props: ParentProps) {
|
function DraftProviders(props: ParentProps) {
|
||||||
|
|
@ -318,8 +390,8 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
<Font />
|
<Font />
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
onThemeApplied={(_, mode) => {
|
onThemeApplied={(_, mode, scheme) => {
|
||||||
void window.api?.setTitlebar?.({ mode })
|
void window.api?.setTitlebar?.({ mode, scheme })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LanguageProvider locale={props.locale}>
|
<LanguageProvider locale={props.locale}>
|
||||||
|
|
@ -347,7 +419,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise<void> }>) {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const checkServerHealth = useCheckServerHealth()
|
const checkServerHealth = useCheckServerHealth()
|
||||||
|
|
||||||
|
|
@ -376,34 +448,45 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
||||||
const checking = createMemo(
|
const checking = createMemo(
|
||||||
() => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
|
() => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
|
||||||
)
|
)
|
||||||
|
const [startup] = createResource(async () => {
|
||||||
|
if (!props.startup) return true
|
||||||
|
await props.startup.catch((error) => {
|
||||||
|
console.error("[startup] startup gate failed", error)
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
const startupChecking = createMemo(
|
||||||
|
() => startupHealthCheck.latest === true && ["unresolved", "pending"].includes(startup.state),
|
||||||
|
)
|
||||||
|
const loading = createMemo(() => checking() || startupChecking())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show
|
<>
|
||||||
when={!checking()}
|
<Show when={!checking()}>
|
||||||
fallback={
|
<Show
|
||||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
when={startupHealthCheck.latest}
|
||||||
|
fallback={
|
||||||
|
<ConnectionError
|
||||||
|
onRetry={() => {
|
||||||
|
if (checkMode() === "background") void healthCheckActions.refetch()
|
||||||
|
}}
|
||||||
|
onServerSelected={(key) => {
|
||||||
|
setCheckMode("blocking")
|
||||||
|
server.setActive(key)
|
||||||
|
void healthCheckActions.refetch()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
<Show when={loading()}>
|
||||||
|
<div class="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background-base">
|
||||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
>
|
|
||||||
<Show
|
|
||||||
when={startupHealthCheck.latest}
|
|
||||||
fallback={
|
|
||||||
<ConnectionError
|
|
||||||
onRetry={() => {
|
|
||||||
if (checkMode() === "background") void healthCheckActions.refetch()
|
|
||||||
}}
|
|
||||||
onServerSelected={(key) => {
|
|
||||||
setCheckMode("blocking")
|
|
||||||
server.setActive(key)
|
|
||||||
void healthCheckActions.refetch()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -470,6 +553,8 @@ export function AppInterface(props: {
|
||||||
servers?: Array<ServerConnection.Any>
|
servers?: Array<ServerConnection.Any>
|
||||||
router?: Component<BaseRouterProps>
|
router?: Component<BaseRouterProps>
|
||||||
disableHealthCheck?: boolean
|
disableHealthCheck?: boolean
|
||||||
|
startup?: Promise<void>
|
||||||
|
serverScoped?: JSX.Element
|
||||||
}) {
|
}) {
|
||||||
// The visual new layout lives in the router root so it remains mounted across
|
// The visual new layout lives in the router root so it remains mounted across
|
||||||
// route changes. Draft and session routes override only their server-bound data
|
// route changes. Draft and session routes override only their server-bound data
|
||||||
|
|
@ -491,23 +576,25 @@ export function AppInterface(props: {
|
||||||
>
|
>
|
||||||
<GlobalProvider>
|
<GlobalProvider>
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
|
||||||
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
|
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
|
||||||
<Dynamic
|
<Dynamic
|
||||||
component={props.router ?? Router}
|
component={props.router ?? Router}
|
||||||
root={(routerProps) => (
|
root={(routerProps) => (
|
||||||
<TabsProvider>
|
<TabsProvider>
|
||||||
<NotificationProvider>
|
<PermissionProvider>
|
||||||
<ServerShell>
|
<NotificationProvider>
|
||||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
<ServerShell>
|
||||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||||
</Show>
|
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
||||||
</ServerShell>
|
</Show>
|
||||||
</NotificationProvider>
|
</ServerShell>
|
||||||
|
</NotificationProvider>
|
||||||
|
</PermissionProvider>
|
||||||
</TabsProvider>
|
</TabsProvider>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Routes />
|
<Routes serverScoped={props.serverScoped} />
|
||||||
</Dynamic>
|
</Dynamic>
|
||||||
</Show>
|
</Show>
|
||||||
</ConnectionGate>
|
</ConnectionGate>
|
||||||
|
|
@ -517,13 +604,24 @@ export function AppInterface(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Routes() {
|
function Routes(props: { serverScoped?: JSX.Element }) {
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Route component={LegacyServerLayout}>
|
<Route
|
||||||
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
|
component={(routeProps) => (
|
||||||
|
<LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Show when={!settings.general.newLayoutDesigns()}>
|
||||||
|
{
|
||||||
|
<>
|
||||||
|
<Route path="/" component={LegacyHome} />
|
||||||
|
<Route path="/server/:serverKey/session/:id" component={LegacyTargetSessionRoute} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</Show>
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
<Route path="/" component={() => <Navigate href="session" />} />
|
<Route path="/" component={() => <Navigate href="session" />} />
|
||||||
<Route path="/session/:id?" component={SessionRoute} />
|
<Route path="/session/:id?" component={SessionRoute} />
|
||||||
|
|
@ -531,15 +629,15 @@ function Routes() {
|
||||||
</Route>
|
</Route>
|
||||||
<Show when={settings.general.newLayoutDesigns()}>
|
<Show when={settings.general.newLayoutDesigns()}>
|
||||||
<Route path="/" component={NewHome} />
|
<Route path="/" component={NewHome} />
|
||||||
<Route path="/:dir/session/:id" component={LegacyTargetSessionRoute} />
|
<Route path="/:dir/session/:id" component={NewLayoutLegacySessionRedirect} />
|
||||||
|
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||||
</Show>
|
</Show>
|
||||||
<Route path="/new-session" component={DraftRoute} />
|
<Route path="/new-session" component={DraftRoute} />
|
||||||
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacyTargetSessionRoute() {
|
function NewLayoutLegacySessionRedirect() {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const params = useParams<{ id: string }>()
|
const params = useParams<{ id: string }>()
|
||||||
|
|
|
||||||
BIN
packages/app/src/assets/help/home.png
Normal file
BIN
packages/app/src/assets/help/home.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
BIN
packages/app/src/assets/help/introducing-tabs.mp4
Normal file
BIN
packages/app/src/assets/help/introducing-tabs.mp4
Normal file
Binary file not shown.
BIN
packages/app/src/assets/help/placeholder.png
Normal file
BIN
packages/app/src/assets/help/placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
packages/app/src/assets/help/tabs.png
Normal file
BIN
packages/app/src/assets/help/tabs.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
281
packages/app/src/components/command-palette.ts
Normal file
281
packages/app/src/components/command-palette.ts
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import type { Project } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { createMemo, onCleanup } from "solid-js"
|
||||||
|
import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command"
|
||||||
|
import { useFile } from "@/context/file"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useLayout, type LocalProject } from "@/context/layout"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { useTabs } from "@/context/tabs"
|
||||||
|
import { displayName, projectForSession } from "@/pages/layout/helpers"
|
||||||
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
import { normalizeSessionInfo } from "@/utils/session"
|
||||||
|
|
||||||
|
export type CommandPaletteEntry = {
|
||||||
|
id: string
|
||||||
|
type: "command" | "file" | "session"
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
keybind?: string
|
||||||
|
category: string
|
||||||
|
option?: CommandOption
|
||||||
|
path?: string
|
||||||
|
directory?: string
|
||||||
|
sessionID?: string
|
||||||
|
server?: ServerConnection.Key
|
||||||
|
project?: LocalProject
|
||||||
|
archived?: number
|
||||||
|
updated?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTRY_LIMIT = 5
|
||||||
|
const COMMON_COMMAND_IDS = [
|
||||||
|
"session.new",
|
||||||
|
"workspace.new",
|
||||||
|
"session.previous",
|
||||||
|
"session.next",
|
||||||
|
"terminal.toggle",
|
||||||
|
"review.toggle",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function uniqueCommandPaletteEntries(items: CommandPaletteEntry[]) {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return items.filter((item) => {
|
||||||
|
if (seen.has(item.id)) return false
|
||||||
|
seen.add(item.id)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCommandPaletteFileEntry(path: string, category: string): CommandPaletteEntry {
|
||||||
|
return {
|
||||||
|
id: "file:" + path,
|
||||||
|
type: "file",
|
||||||
|
title: path,
|
||||||
|
category,
|
||||||
|
path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => void) {
|
||||||
|
const file = useFile()
|
||||||
|
const layout = useLayout()
|
||||||
|
const { tabs, view } = useSessionLayout()
|
||||||
|
|
||||||
|
return (path: string) => {
|
||||||
|
const value = file.tab(path)
|
||||||
|
void tabs().open(value)
|
||||||
|
void file.load(path)
|
||||||
|
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||||
|
layout.fileTree.setTab("all")
|
||||||
|
onOpenFile?.(path)
|
||||||
|
tabs().setActive(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCommandPaletteModel(props: { filesOnly?: () => boolean; onOpenFile?: (path: string) => void }) {
|
||||||
|
const command = useCommand()
|
||||||
|
const global = useGlobal()
|
||||||
|
const language = useLanguage()
|
||||||
|
const file = useFile()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const serverSDK = useServerSDK()()
|
||||||
|
const serverCtx = global.ensureServerCtx(serverSDK.server)
|
||||||
|
const appTabs = useTabs()
|
||||||
|
const { tabs: sessionTabs } = useSessionLayout()
|
||||||
|
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
|
||||||
|
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||||
|
const filesOnly = () => props.filesOnly?.() ?? false
|
||||||
|
|
||||||
|
const allowedCommands = createMemo(() => {
|
||||||
|
if (filesOnly()) return []
|
||||||
|
return commandPaletteOptions(command.options)
|
||||||
|
})
|
||||||
|
const commandEntries = createMemo(() => {
|
||||||
|
const category = language.t("palette.group.commands")
|
||||||
|
return allowedCommands().map((option) => createCommandPaletteCommandEntry(option, category))
|
||||||
|
})
|
||||||
|
const preferredCommandEntries = createMemo(() => {
|
||||||
|
const all = allowedCommands()
|
||||||
|
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
|
||||||
|
const picked = all.filter((option) => order.has(option.id))
|
||||||
|
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
|
||||||
|
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
||||||
|
const category = language.t("palette.group.commands")
|
||||||
|
return sorted.map((option) => createCommandPaletteCommandEntry(option, category))
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabState = createSessionTabs({
|
||||||
|
tabs: sessionTabs,
|
||||||
|
pathFromTab: file.pathFromTab,
|
||||||
|
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||||
|
})
|
||||||
|
const recentFileEntries = createMemo(() => {
|
||||||
|
const all = tabState.openedTabs()
|
||||||
|
const active = tabState.activeFileTab()
|
||||||
|
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const category = language.t("palette.group.files")
|
||||||
|
return order
|
||||||
|
.map((item) => file.pathFromTab(item))
|
||||||
|
.filter((path): path is string => {
|
||||||
|
if (!path || seen.has(path)) return false
|
||||||
|
seen.add(path)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.slice(0, ENTRY_LIMIT)
|
||||||
|
.map((path) => createCommandPaletteFileEntry(path, category))
|
||||||
|
})
|
||||||
|
const rootFileEntries = createMemo(() => {
|
||||||
|
const category = language.t("palette.group.files")
|
||||||
|
return file.tree
|
||||||
|
.children("")
|
||||||
|
.filter((node) => node.type === "file")
|
||||||
|
.map((node) => node.path)
|
||||||
|
.sort((a, b) => a.localeCompare(b))
|
||||||
|
.slice(0, ENTRY_LIMIT)
|
||||||
|
.map((path) => createCommandPaletteFileEntry(path, category))
|
||||||
|
})
|
||||||
|
|
||||||
|
const sessions = createServerSessionEntries({
|
||||||
|
server: ServerConnection.key(serverSDK.server),
|
||||||
|
opened: serverCtx.projects.list,
|
||||||
|
stored: () => serverCtx.sync.data.project,
|
||||||
|
load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||||
|
untitled: () => language.t("command.session.new"),
|
||||||
|
category: () => language.t("command.category.session"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const highlight = (item: CommandPaletteEntry | undefined) => {
|
||||||
|
state.cleanup?.()
|
||||||
|
state.cleanup = undefined
|
||||||
|
if (item?.type !== "command") return
|
||||||
|
state.cleanup = item.option?.onHighlight?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const select = (item: CommandPaletteEntry | undefined) => {
|
||||||
|
if (!item) return
|
||||||
|
state.committed = true
|
||||||
|
state.cleanup = undefined
|
||||||
|
dialog.close()
|
||||||
|
if (item.type === "command") {
|
||||||
|
item.option?.onSelect?.("palette")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (item.type === "session") {
|
||||||
|
if (!item.sessionID || !item.server) return
|
||||||
|
const directory = item.project?.worktree ?? item.directory
|
||||||
|
if (directory) {
|
||||||
|
serverCtx.projects.open(directory)
|
||||||
|
serverCtx.projects.touch(directory)
|
||||||
|
}
|
||||||
|
const tab = appTabs.addSessionTab({
|
||||||
|
server: item.server,
|
||||||
|
sessionId: item.sessionID,
|
||||||
|
})
|
||||||
|
appTabs.select(tab)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!item.path) return
|
||||||
|
openFile(item.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (state.committed) return
|
||||||
|
state.cleanup?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
language,
|
||||||
|
file,
|
||||||
|
commandEntries,
|
||||||
|
preferredCommandEntries,
|
||||||
|
recentFileEntries,
|
||||||
|
rootFileEntries,
|
||||||
|
sessions,
|
||||||
|
highlight,
|
||||||
|
select,
|
||||||
|
close: () => dialog.close(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCommandPaletteCommandEntry(option: CommandOption, category: string): CommandPaletteEntry {
|
||||||
|
return {
|
||||||
|
id: "command:" + option.id,
|
||||||
|
type: "command",
|
||||||
|
title: option.title,
|
||||||
|
description: option.description,
|
||||||
|
keybind: option.keybind,
|
||||||
|
category,
|
||||||
|
option,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServerSessionEntries(props: {
|
||||||
|
server: ServerConnection.Key
|
||||||
|
opened: () => LocalProject[]
|
||||||
|
stored: () => Project[]
|
||||||
|
load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }>
|
||||||
|
untitled: () => string
|
||||||
|
category: () => string
|
||||||
|
}) {
|
||||||
|
let abort: AbortController | undefined
|
||||||
|
|
||||||
|
onCleanup(() => abort?.abort())
|
||||||
|
|
||||||
|
return async (text: string): Promise<CommandPaletteEntry[]> => {
|
||||||
|
const search = text.trim()
|
||||||
|
if (!search) {
|
||||||
|
abort?.abort()
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
abort?.abort()
|
||||||
|
const current = new AbortController()
|
||||||
|
abort = current
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const timer = setTimeout(resolve, 100)
|
||||||
|
current.signal.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
resolve()
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if (current.signal.aborted) return []
|
||||||
|
const opened = props.opened()
|
||||||
|
const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])))
|
||||||
|
const stored = props.stored().map((project) => ({ ...project, expanded: false }))
|
||||||
|
const storedByID = new Map(stored.map((project) => [project.id, project] as const))
|
||||||
|
return props
|
||||||
|
.load(search, current.signal)
|
||||||
|
.then((result) =>
|
||||||
|
result.data
|
||||||
|
.map(normalizeSessionInfo)
|
||||||
|
.filter((session) => !session.time.archived)
|
||||||
|
.map((session) => {
|
||||||
|
const project =
|
||||||
|
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
|
||||||
|
return {
|
||||||
|
id: `session:${props.server}:${session.id}`,
|
||||||
|
type: "session" as const,
|
||||||
|
title: session.title || props.untitled(),
|
||||||
|
description: project ? displayName(project) : getFilename(session.directory),
|
||||||
|
category: props.category(),
|
||||||
|
directory: session.directory,
|
||||||
|
sessionID: session.id,
|
||||||
|
server: props.server,
|
||||||
|
project,
|
||||||
|
updated: session.time.updated,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch(() => [] as CommandPaletteEntry[])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePlatform } from "@/context/platform"
|
||||||
|
|
||||||
type Mem = Performance & {
|
type Mem = Performance & {
|
||||||
memory?: {
|
memory?: {
|
||||||
|
|
@ -65,7 +66,7 @@ function Cell(props: {
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"flex min-w-0 items-center": true,
|
"flex min-w-0 items-center": true,
|
||||||
"min-h-[20px] w-fit flex-row justify-start gap-1.5 px-1.5 py-0.5 text-left": !!props.inline,
|
"min-h-[20px] w-fit justify-start px-1.5 py-0.5 text-left": !!props.inline,
|
||||||
"justify-center text-center": !props.inline,
|
"justify-center text-center": !props.inline,
|
||||||
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
|
"min-h-[42px] w-full flex-col rounded-[8px] px-0.5 py-1": !props.inline,
|
||||||
"col-span-2": !!props.wide && !props.inline,
|
"col-span-2": !!props.wide && !props.inline,
|
||||||
|
|
@ -73,21 +74,29 @@ function Cell(props: {
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
|
"flex min-w-0": true,
|
||||||
|
"-translate-y-px items-baseline gap-1.5": !!props.inline,
|
||||||
|
"flex-col items-center": !props.inline,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{props.label}
|
<div
|
||||||
</div>
|
classList={{
|
||||||
<div
|
"text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70": true,
|
||||||
classList={{
|
}}
|
||||||
"uppercase leading-none font-bold tabular-nums": true,
|
>
|
||||||
"text-[11px]": !!props.inline,
|
{props.label}
|
||||||
"text-[13px] sm:text-[14px]": !props.inline,
|
</div>
|
||||||
"text-text-on-critical-base": !!props.bad,
|
<div
|
||||||
"opacity-70": !!props.dim,
|
classList={{
|
||||||
}}
|
"uppercase leading-none font-bold tabular-nums": true,
|
||||||
>
|
"text-[11px]": !!props.inline,
|
||||||
{props.value}
|
"text-[13px] sm:text-[14px]": !props.inline,
|
||||||
|
"text-text-on-critical-base": !!props.bad,
|
||||||
|
"opacity-70": !!props.dim,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.value}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -107,8 +116,55 @@ function Cell(props: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => void }) {
|
||||||
|
const content = () => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Force focus styles on all interactive elements"
|
||||||
|
aria-pressed={props.active}
|
||||||
|
classList={{
|
||||||
|
"flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true,
|
||||||
|
"min-h-[20px] w-fit justify-start rounded px-1.5 py-0.5 text-left": !!props.inline,
|
||||||
|
"min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline,
|
||||||
|
"bg-surface-raised-base text-text-strong": props.active,
|
||||||
|
}}
|
||||||
|
onClick={props.onClick}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
classList={{
|
||||||
|
flex: true,
|
||||||
|
"-translate-y-px items-baseline gap-1.5": !!props.inline,
|
||||||
|
"flex-col items-center": !props.inline,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="text-[10px] leading-none font-black tracking-[0.04em] opacity-70">FOCUS</span>
|
||||||
|
<span
|
||||||
|
classList={{ "leading-none font-bold": true, "text-[11px]": !!props.inline, "text-[13px]": !props.inline }}
|
||||||
|
>
|
||||||
|
{props.active ? "ON" : "OFF"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (props.inline) {
|
||||||
|
return (
|
||||||
|
<TooltipV2 value="Force focus styles on all interactive elements" placement="top">
|
||||||
|
{content()}
|
||||||
|
</TooltipV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip value="Force focus styles on all interactive elements" placement="top">
|
||||||
|
{content()}
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function DebugBar(props: { inline?: boolean } = {}) {
|
export function DebugBar(props: { inline?: boolean } = {}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const platform = usePlatform()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const routing = useIsRouting()
|
const routing = useIsRouting()
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
|
|
@ -116,6 +172,7 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||||
delay: undefined as number | undefined,
|
delay: undefined as number | undefined,
|
||||||
fps: undefined as number | undefined,
|
fps: undefined as number | undefined,
|
||||||
gap: undefined as number | undefined,
|
gap: undefined as number | undefined,
|
||||||
|
focus: false,
|
||||||
heap: {
|
heap: {
|
||||||
limit: undefined as number | undefined,
|
limit: undefined as number | undefined,
|
||||||
used: undefined as number | undefined,
|
used: undefined as number | undefined,
|
||||||
|
|
@ -142,6 +199,16 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||||
}
|
}
|
||||||
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
|
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()))
|
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
|
||||||
|
const toggleFocus = async () => {
|
||||||
|
if (!platform.setForceFocus) return
|
||||||
|
const enabled = !state.focus
|
||||||
|
await platform.setForceFocus(enabled)
|
||||||
|
setState("focus", enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (state.focus) void platform.setForceFocus?.(false).catch(() => undefined)
|
||||||
|
})
|
||||||
|
|
||||||
let prev = ""
|
let prev = ""
|
||||||
let start = 0
|
let start = 0
|
||||||
|
|
@ -490,8 +557,11 @@ export function DebugBar(props: { inline?: boolean } = {}) {
|
||||||
bad={bad(heap(), 0.8)}
|
bad={bad(heap(), 0.8)}
|
||||||
dim={state.heap.used === undefined}
|
dim={state.heap.used === undefined}
|
||||||
inline={props.inline}
|
inline={props.inline}
|
||||||
wide
|
wide={!platform.setForceFocus}
|
||||||
/>
|
/>
|
||||||
|
{platform.setForceFocus && (
|
||||||
|
<FocusCell active={state.focus} inline={props.inline} onClick={() => void toggleFocus()} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
220
packages/app/src/components/dialog-command-palette-v2.css
Normal file
220
packages/app/src/components/dialog-command-palette-v2.css
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
.command-palette-v2 {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Anchor to the top edge of where a centered 480px-tall dialog would sit, so the
|
||||||
|
top stays put while the content-driven height grows and shrinks. */
|
||||||
|
[data-component="dialog-v2"]:has(.command-palette-v2) {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="dialog-v2"]:has(.command-palette-v2) [data-slot="dialog-container"] {
|
||||||
|
width: min(calc(100vw - 24px), 640px);
|
||||||
|
height: auto;
|
||||||
|
min-height: 280px;
|
||||||
|
max-height: min(calc(100vh - 96px), 480px);
|
||||||
|
margin-top: max(48px, calc((100vh - 480px) / 2));
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--v2-background-bg-base);
|
||||||
|
box-shadow: var(--v2-elevation-floating);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-body {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-search {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-search [data-component="text-input-v2"] {
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 6px;
|
||||||
|
outline: 0;
|
||||||
|
background: color-mix(in srgb, var(--v2-background-bg-layer-02) 60%, transparent);
|
||||||
|
box-shadow: none;
|
||||||
|
transition:
|
||||||
|
background-color 120ms ease-in-out,
|
||||||
|
box-shadow 120ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]),
|
||||||
|
.command-palette-v2-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
|
||||||
|
background: var(--v2-background-bg-layer-02);
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] {
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] {
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] {
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-scroll {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 6px 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-group-title {
|
||||||
|
margin: 6px 0;
|
||||||
|
padding: 0 12px;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-row {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
text-align: left;
|
||||||
|
cursor: default;
|
||||||
|
scroll-margin: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-row[data-active] {
|
||||||
|
background: var(--v2-overlay-simple-overlay-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-row:focus-visible {
|
||||||
|
background: var(--v2-overlay-simple-overlay-hover);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-row-main {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-row-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--v2-icon-icon-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-row-text {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 530;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-description,
|
||||||
|
.command-palette-v2-meta {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-meta {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-file-path {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-file-dir {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
font-weight: 440;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-file-name {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--v2-text-text-base);
|
||||||
|
font-weight: 530;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-state {
|
||||||
|
display: grid;
|
||||||
|
min-height: 120px;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--v2-text-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 440;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: -0.04px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.command-palette-v2-row-text {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-v2-description {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
342
packages/app/src/components/dialog-command-palette-v2.tsx
Normal file
342
packages/app/src/components/dialog-command-palette-v2.tsx
Normal file
|
|
@ -0,0 +1,342 @@
|
||||||
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
|
import { Dialog, DialogBody } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||||
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||||
|
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import { useTabs } from "@/context/tabs"
|
||||||
|
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
|
||||||
|
import { getRelativeTime } from "@/utils/time"
|
||||||
|
import {
|
||||||
|
createCommandPaletteCommandEntry,
|
||||||
|
createCommandPaletteFileEntry,
|
||||||
|
createCommandPaletteModel,
|
||||||
|
createServerSessionEntries,
|
||||||
|
uniqueCommandPaletteEntries,
|
||||||
|
type CommandPaletteEntry,
|
||||||
|
} from "./command-palette"
|
||||||
|
import "./dialog-command-palette-v2.css"
|
||||||
|
|
||||||
|
function groups(entries: CommandPaletteEntry[]) {
|
||||||
|
const map = new Map<string, CommandPaletteEntry[]>()
|
||||||
|
for (const entry of entries) map.set(entry.category, [...(map.get(entry.category) ?? []), entry])
|
||||||
|
return Array.from(map.entries()).map(([category, entries]) => ({ category, entries }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesEntry(entry: CommandPaletteEntry, query: string) {
|
||||||
|
const value = query.toLowerCase()
|
||||||
|
return [entry.title, entry.description, entry.category].some((text) => text?.toLowerCase().includes(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogCommandPaletteV2(props: { onOpenFile?: (path: string) => void }) {
|
||||||
|
const palette = createCommandPaletteModel(props)
|
||||||
|
const loadItems = async (text: string) => {
|
||||||
|
const q = text.trim()
|
||||||
|
if (!q) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
|
||||||
|
|
||||||
|
const [files, nextSessions] = await Promise.all([palette.file.searchFiles(q), Promise.resolve(palette.sessions(q))])
|
||||||
|
const category = palette.language.t("palette.group.files")
|
||||||
|
return [
|
||||||
|
...palette.commandEntries().filter((entry) => matchesEntry(entry, q)),
|
||||||
|
...nextSessions,
|
||||||
|
...files.map((path) => createCommandPaletteFileEntry(path, category)),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandPaletteView
|
||||||
|
placeholder={palette.language.t("palette.search.placeholder")}
|
||||||
|
loadItems={loadItems}
|
||||||
|
highlight={palette.highlight}
|
||||||
|
select={palette.select}
|
||||||
|
close={palette.close}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DialogHomeCommandPaletteV2(props: {
|
||||||
|
server: ServerConnection.Any
|
||||||
|
onSelectSession: (entry: CommandPaletteEntry) => void
|
||||||
|
}) {
|
||||||
|
const command = useCommand()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const global = useGlobal()
|
||||||
|
const language = useLanguage()
|
||||||
|
const serverCtx = global.ensureServerCtx(props.server)
|
||||||
|
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||||
|
const commandEntries = createMemo(() => {
|
||||||
|
const category = language.t("palette.group.commands")
|
||||||
|
return commandPaletteOptions(command.options).map((option) => createCommandPaletteCommandEntry(option, category))
|
||||||
|
})
|
||||||
|
const sessions = createServerSessionEntries({
|
||||||
|
server: ServerConnection.key(props.server),
|
||||||
|
opened: serverCtx.projects.list,
|
||||||
|
stored: () => serverCtx.sync.data.project,
|
||||||
|
load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }),
|
||||||
|
untitled: () => language.t("command.session.new"),
|
||||||
|
category: () => language.t("command.category.session"),
|
||||||
|
})
|
||||||
|
|
||||||
|
const highlight = (item: CommandPaletteEntry | undefined) => {
|
||||||
|
state.cleanup?.()
|
||||||
|
state.cleanup = undefined
|
||||||
|
if (item?.type !== "command") return
|
||||||
|
state.cleanup = item.option?.onHighlight?.()
|
||||||
|
}
|
||||||
|
const select = (item: CommandPaletteEntry | undefined) => {
|
||||||
|
if (!item) return
|
||||||
|
state.committed = true
|
||||||
|
state.cleanup = undefined
|
||||||
|
dialog.close()
|
||||||
|
if (item.type === "command") {
|
||||||
|
item.option?.onSelect?.("palette")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (item.type === "session") props.onSelectSession(item)
|
||||||
|
}
|
||||||
|
const loadItems = async (text: string) => {
|
||||||
|
const query = text.trim()
|
||||||
|
if (!query) return commandEntries().slice(0, 5)
|
||||||
|
return [...commandEntries().filter((entry) => matchesEntry(entry, query)), ...(await sessions(query))]
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (state.committed) return
|
||||||
|
state.cleanup?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandPaletteView
|
||||||
|
placeholder={language.t("palette.search.placeholder.home")}
|
||||||
|
loadItems={loadItems}
|
||||||
|
highlight={highlight}
|
||||||
|
select={select}
|
||||||
|
close={() => dialog.close()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandPaletteView(props: {
|
||||||
|
placeholder: string
|
||||||
|
loadItems: (text: string) => CommandPaletteEntry[] | Promise<CommandPaletteEntry[]>
|
||||||
|
highlight: (item: CommandPaletteEntry | undefined) => void
|
||||||
|
select: (item: CommandPaletteEntry | undefined) => void
|
||||||
|
close: () => void
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const tabs = useTabs()
|
||||||
|
const [query, setQuery] = createSignal("")
|
||||||
|
const [active, setActive] = createSignal(0)
|
||||||
|
|
||||||
|
const [entries] = createResource(query, props.loadItems, { initialValue: [] as CommandPaletteEntry[] })
|
||||||
|
// Render stale results while a new query loads to avoid flashing "Loading" per keystroke.
|
||||||
|
const visibleEntries = createMemo(() => uniqueCommandPaletteEntries(entries.latest ?? []))
|
||||||
|
const groupedEntries = createMemo(() => groups(visibleEntries()))
|
||||||
|
const activeEntry = createMemo(() => visibleEntries()[active()])
|
||||||
|
const openSessions = createMemo(
|
||||||
|
() => new Set(tabs.store.flatMap((tab) => (tab.type === "session" ? [`${tab.server}\0${tab.sessionId}`] : []))),
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
query()
|
||||||
|
visibleEntries()
|
||||||
|
setActive(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
props.highlight(activeEntry())
|
||||||
|
})
|
||||||
|
|
||||||
|
let resultsRef: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
const move = (delta: -1 | 1) => {
|
||||||
|
const count = visibleEntries().length
|
||||||
|
if (count === 0) return
|
||||||
|
setActive((index) => (index + delta + count) % count)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
resultsRef?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault()
|
||||||
|
move(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault()
|
||||||
|
move(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault()
|
||||||
|
props.select(activeEntry())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault()
|
||||||
|
props.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog class="command-palette-v2" size="large">
|
||||||
|
<DialogBody class="command-palette-v2-body">
|
||||||
|
<div class="command-palette-v2-search">
|
||||||
|
<TextInputV2
|
||||||
|
value={query()}
|
||||||
|
autofocus
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck={false}
|
||||||
|
appearance="large"
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
leadingIcon={<Icon name="magnifying-glass" />}
|
||||||
|
onInput={(event) => setQuery(event.currentTarget.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ScrollView class="command-palette-v2-scroll" viewportRef={(el) => (resultsRef = el)}>
|
||||||
|
<div class="command-palette-v2-results" role="listbox">
|
||||||
|
<Show
|
||||||
|
when={visibleEntries().length > 0}
|
||||||
|
fallback={
|
||||||
|
<div class="command-palette-v2-state">
|
||||||
|
{entries.loading ? language.t("common.loading") : language.t("palette.empty")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<For each={groupedEntries()}>
|
||||||
|
{(group) => (
|
||||||
|
<div class="command-palette-v2-group">
|
||||||
|
<Show when={group.category}>
|
||||||
|
<div class="command-palette-v2-group-title">{group.category}</div>
|
||||||
|
</Show>
|
||||||
|
<For each={group.entries}>
|
||||||
|
{(item) => (
|
||||||
|
<PaletteRow
|
||||||
|
item={item}
|
||||||
|
active={activeEntry()?.id === item.id}
|
||||||
|
language={language}
|
||||||
|
sessionOpen={
|
||||||
|
item.server && item.sessionID
|
||||||
|
? openSessions().has(`${item.server}\0${item.sessionID}`)
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
onActive={() => setActive(visibleEntries().findIndex((entry) => entry.id === item.id))}
|
||||||
|
onSelect={() => props.select(item)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</ScrollView>
|
||||||
|
</DialogBody>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaletteRow(props: {
|
||||||
|
item: CommandPaletteEntry
|
||||||
|
active: boolean
|
||||||
|
language: ReturnType<typeof useLanguage>
|
||||||
|
sessionOpen: boolean
|
||||||
|
onActive: () => void
|
||||||
|
onSelect: () => void
|
||||||
|
}) {
|
||||||
|
const session = () =>
|
||||||
|
props.item.server && props.item.directory && props.item.sessionID
|
||||||
|
? { server: props.item.server, directory: props.item.directory, sessionID: props.item.sessionID }
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="command-palette-v2-row group"
|
||||||
|
role="option"
|
||||||
|
aria-selected={props.active}
|
||||||
|
data-active={props.active ? "" : undefined}
|
||||||
|
onMouseMove={(event) => {
|
||||||
|
// Ignore hover from a static cursor when keyboard scrolling moves rows underneath it.
|
||||||
|
if (event.movementX === 0 && event.movementY === 0) return
|
||||||
|
props.onActive()
|
||||||
|
}}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={props.onSelect}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
fallback={
|
||||||
|
<div class="command-palette-v2-row-main">
|
||||||
|
<FileIcon node={{ path: props.item.path ?? "", type: "file" }} class="command-palette-v2-row-icon size-4" />
|
||||||
|
<div class="command-palette-v2-file-path">
|
||||||
|
<span class="command-palette-v2-file-dir">{getDirectory(props.item.path ?? "")}</span>
|
||||||
|
<span class="command-palette-v2-file-name">{getFilename(props.item.path ?? "")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Match when={props.item.type === "command"}>
|
||||||
|
<div class="command-palette-v2-row-main">
|
||||||
|
<div class="command-palette-v2-row-text">
|
||||||
|
<span class="command-palette-v2-title">{props.item.title}</span>
|
||||||
|
<Show when={props.item.description}>
|
||||||
|
<span class="command-palette-v2-description">{props.item.description}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={props.item.keybind}>
|
||||||
|
<KeybindV2 keys={formatKeybindParts(props.item.keybind ?? "", props.language.t)} variant="neutral" />
|
||||||
|
</Show>
|
||||||
|
</Match>
|
||||||
|
<Match when={props.item.type === "session"}>
|
||||||
|
<div class="command-palette-v2-row-main">
|
||||||
|
<div class="relative shrink-0">
|
||||||
|
<Show when={props.sessionOpen}>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="pointer-events-none absolute top-1/2 h-3 w-0.5 -translate-y-1/2 rounded-[2px] bg-v2-background-bg-layer-04"
|
||||||
|
style={{ right: "calc(100% + 4px)" }}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<Show when={session()}>
|
||||||
|
{(session) => (
|
||||||
|
<SessionTabAvatar
|
||||||
|
project={props.item.project}
|
||||||
|
directory={session().directory}
|
||||||
|
sessionId={session().sessionID}
|
||||||
|
server={session().server}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<div class="command-palette-v2-row-text">
|
||||||
|
<span class="command-palette-v2-title" classList={{ "opacity-70": !!props.item.archived }}>
|
||||||
|
{props.item.title}
|
||||||
|
</span>
|
||||||
|
<Show when={props.item.description}>
|
||||||
|
<span class="command-palette-v2-description" classList={{ "opacity-70": !!props.item.archived }}>
|
||||||
|
{props.item.description}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={props.item.updated}>
|
||||||
|
<span class="command-palette-v2-meta">
|
||||||
|
{getRelativeTime(new Date(props.item.updated!).toISOString(), props.language.t)}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
// @ts-nocheck
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||||
|
import { mockProviderAuth } from "@/context/server-sync"
|
||||||
|
import { onCleanup, onMount } from "solid-js"
|
||||||
|
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
||||||
|
|
||||||
|
function ConnectProviderDialogStory() {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const open = () => dialog.show(() => <DialogConnectProvider />)
|
||||||
|
|
||||||
|
onMount(open)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="secondary" onClick={open}>
|
||||||
|
Open connect provider dialog
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProviderConnectionDialogStory(props) {
|
||||||
|
onCleanup(mockProviderAuth(props.provider, props.methods))
|
||||||
|
const dialog = useDialog()
|
||||||
|
const controller = useProviderConnectController()
|
||||||
|
controller.select(props.provider)
|
||||||
|
const open = () => dialog.show(() => <DialogConnectProvider controller={controller} />)
|
||||||
|
|
||||||
|
onMount(open)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="secondary" onClick={open}>
|
||||||
|
Open {props.provider} connection dialog
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConnection(provider, methods) {
|
||||||
|
return () => (
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<ProviderConnectionDialogStory provider={provider} methods={methods} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "App/Dialogs/Connect Provider",
|
||||||
|
id: "app-dialog-connect-provider",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const V2 = {
|
||||||
|
render: () => (
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<ConnectProviderDialogStory />
|
||||||
|
</QueryClientProvider>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ApiKey = {
|
||||||
|
render: renderConnection("openrouter", [{ type: "api", label: "API key" }]),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OpenCodeZen = {
|
||||||
|
render: renderConnection("opencode", [{ type: "api", label: "API key" }]),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoginMethods = {
|
||||||
|
render: renderConnection("openai", [
|
||||||
|
{ type: "oauth", label: "ChatGPT Pro/Plus (browser)" },
|
||||||
|
{ type: "oauth", label: "ChatGPT Pro/Plus (headless)" },
|
||||||
|
{ type: "api", label: "API key" },
|
||||||
|
]),
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -6,21 +6,41 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
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 "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { type Accessor, batch, For } from "solid-js"
|
import { batch, For } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { ExternalLink } from "@/components/external-link"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
back?: "providers" | "close"
|
onBack: () => void
|
||||||
directory?: Accessor<string | undefined>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DialogCustomProvider(props: Props) {
|
export function DialogCustomProvider(props: Props) {
|
||||||
|
const language = useLanguage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
class="h-full"
|
||||||
|
title={
|
||||||
|
<IconButton
|
||||||
|
tabIndex={-1}
|
||||||
|
icon="arrow-left"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={props.onBack}
|
||||||
|
aria-label={language.t("common.goBack")}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
transition
|
||||||
|
>
|
||||||
|
<CustomProviderForm />
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
|
|
@ -36,14 +56,6 @@ export function DialogCustomProvider(props: Props) {
|
||||||
err: {},
|
err: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
const goBack = () => {
|
|
||||||
if (props.back === "close") {
|
|
||||||
dialog.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dialog.show(() => <DialogSelectProvider directory={props.directory} />)
|
|
||||||
}
|
|
||||||
|
|
||||||
const addModel = () => {
|
const addModel = () => {
|
||||||
setForm(
|
setForm(
|
||||||
"models",
|
"models",
|
||||||
|
|
@ -119,6 +131,7 @@ export function DialogCustomProvider(props: Props) {
|
||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
const saveMutation = useMutation(() => ({
|
||||||
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||||
|
if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server")
|
||||||
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
const disabledProviders = serverSync().data.config.disabled_providers ?? []
|
||||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||||
|
|
||||||
|
|
@ -163,168 +176,155 @@ export function DialogCustomProvider(props: Props) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
||||||
title={
|
<div class="px-2.5 flex gap-4 items-center">
|
||||||
<IconButton
|
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||||
tabIndex={-1}
|
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
||||||
icon="arrow-left"
|
</div>
|
||||||
variant="ghost"
|
|
||||||
onClick={goBack}
|
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
||||||
aria-label={language.t("common.goBack")}
|
<p class="text-14-regular text-text-base">
|
||||||
/>
|
{language.t("provider.custom.description.prefix")}
|
||||||
}
|
<ExternalLink href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
|
||||||
transition
|
{language.t("provider.custom.description.link")}
|
||||||
>
|
</ExternalLink>
|
||||||
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
{language.t("provider.custom.description.suffix")}
|
||||||
<div class="px-2.5 flex gap-4 items-center">
|
</p>
|
||||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
|
||||||
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
<div class="flex flex-col gap-4">
|
||||||
|
<TextField
|
||||||
|
autofocus={props.autofocus ?? true}
|
||||||
|
label={language.t("provider.custom.field.providerID.label")}
|
||||||
|
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||||
|
description={language.t("provider.custom.field.providerID.description")}
|
||||||
|
value={form.providerID}
|
||||||
|
onChange={(v) => setField("providerID", v)}
|
||||||
|
validationState={form.err.providerID ? "invalid" : undefined}
|
||||||
|
error={form.err.providerID}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={language.t("provider.custom.field.name.label")}
|
||||||
|
placeholder={language.t("provider.custom.field.name.placeholder")}
|
||||||
|
value={form.name}
|
||||||
|
onChange={(v) => setField("name", v)}
|
||||||
|
validationState={form.err.name ? "invalid" : undefined}
|
||||||
|
error={form.err.name}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={language.t("provider.custom.field.baseURL.label")}
|
||||||
|
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
||||||
|
value={form.baseURL}
|
||||||
|
onChange={(v) => setField("baseURL", v)}
|
||||||
|
validationState={form.err.baseURL ? "invalid" : undefined}
|
||||||
|
error={form.err.baseURL}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={language.t("provider.custom.field.apiKey.label")}
|
||||||
|
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
||||||
|
description={language.t("provider.custom.field.apiKey.description")}
|
||||||
|
value={form.apiKey}
|
||||||
|
onChange={(v) => setField("apiKey", v)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
<div class="flex flex-col gap-3">
|
||||||
<p class="text-14-regular text-text-base">
|
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
|
||||||
{language.t("provider.custom.description.prefix")}
|
<For each={form.models}>
|
||||||
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
|
{(m, i) => (
|
||||||
{language.t("provider.custom.description.link")}
|
<div class="flex gap-2 items-start" data-row={m.row}>
|
||||||
</Link>
|
<div class="flex-1">
|
||||||
{language.t("provider.custom.description.suffix")}
|
<TextField
|
||||||
</p>
|
label={language.t("provider.custom.models.id.label")}
|
||||||
|
hideLabel
|
||||||
<div class="flex flex-col gap-4">
|
placeholder={language.t("provider.custom.models.id.placeholder")}
|
||||||
<TextField
|
value={m.id}
|
||||||
autofocus
|
onChange={(v) => setModel(i(), "id", v)}
|
||||||
label={language.t("provider.custom.field.providerID.label")}
|
validationState={m.err.id ? "invalid" : undefined}
|
||||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
error={m.err.id}
|
||||||
description={language.t("provider.custom.field.providerID.description")}
|
|
||||||
value={form.providerID}
|
|
||||||
onChange={(v) => setField("providerID", v)}
|
|
||||||
validationState={form.err.providerID ? "invalid" : undefined}
|
|
||||||
error={form.err.providerID}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label={language.t("provider.custom.field.name.label")}
|
|
||||||
placeholder={language.t("provider.custom.field.name.placeholder")}
|
|
||||||
value={form.name}
|
|
||||||
onChange={(v) => setField("name", v)}
|
|
||||||
validationState={form.err.name ? "invalid" : undefined}
|
|
||||||
error={form.err.name}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label={language.t("provider.custom.field.baseURL.label")}
|
|
||||||
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
|
||||||
value={form.baseURL}
|
|
||||||
onChange={(v) => setField("baseURL", v)}
|
|
||||||
validationState={form.err.baseURL ? "invalid" : undefined}
|
|
||||||
error={form.err.baseURL}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label={language.t("provider.custom.field.apiKey.label")}
|
|
||||||
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
|
||||||
description={language.t("provider.custom.field.apiKey.description")}
|
|
||||||
value={form.apiKey}
|
|
||||||
onChange={(v) => setField("apiKey", v)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
|
|
||||||
<For each={form.models}>
|
|
||||||
{(m, i) => (
|
|
||||||
<div class="flex gap-2 items-start" data-row={m.row}>
|
|
||||||
<div class="flex-1">
|
|
||||||
<TextField
|
|
||||||
label={language.t("provider.custom.models.id.label")}
|
|
||||||
hideLabel
|
|
||||||
placeholder={language.t("provider.custom.models.id.placeholder")}
|
|
||||||
value={m.id}
|
|
||||||
onChange={(v) => setModel(i(), "id", v)}
|
|
||||||
validationState={m.err.id ? "invalid" : undefined}
|
|
||||||
error={m.err.id}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<TextField
|
|
||||||
label={language.t("provider.custom.models.name.label")}
|
|
||||||
hideLabel
|
|
||||||
placeholder={language.t("provider.custom.models.name.placeholder")}
|
|
||||||
value={m.name}
|
|
||||||
onChange={(v) => setModel(i(), "name", v)}
|
|
||||||
validationState={m.err.name ? "invalid" : undefined}
|
|
||||||
error={m.err.name}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<IconButton
|
|
||||||
type="button"
|
|
||||||
icon="trash"
|
|
||||||
variant="ghost"
|
|
||||||
class="mt-1.5"
|
|
||||||
onClick={() => removeModel(i())}
|
|
||||||
disabled={form.models.length <= 1}
|
|
||||||
aria-label={language.t("provider.custom.models.remove")}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div class="flex-1">
|
||||||
</For>
|
<TextField
|
||||||
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
|
label={language.t("provider.custom.models.name.label")}
|
||||||
{language.t("provider.custom.models.add")}
|
hideLabel
|
||||||
</Button>
|
placeholder={language.t("provider.custom.models.name.placeholder")}
|
||||||
</div>
|
value={m.name}
|
||||||
|
onChange={(v) => setModel(i(), "name", v)}
|
||||||
<div class="flex flex-col gap-3">
|
validationState={m.err.name ? "invalid" : undefined}
|
||||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
|
error={m.err.name}
|
||||||
<For each={form.headers}>
|
|
||||||
{(h, i) => (
|
|
||||||
<div class="flex gap-2 items-start" data-row={h.row}>
|
|
||||||
<div class="flex-1">
|
|
||||||
<TextField
|
|
||||||
label={language.t("provider.custom.headers.key.label")}
|
|
||||||
hideLabel
|
|
||||||
placeholder={language.t("provider.custom.headers.key.placeholder")}
|
|
||||||
value={h.key}
|
|
||||||
onChange={(v) => setHeader(i(), "key", v)}
|
|
||||||
validationState={h.err.key ? "invalid" : undefined}
|
|
||||||
error={h.err.key}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<TextField
|
|
||||||
label={language.t("provider.custom.headers.value.label")}
|
|
||||||
hideLabel
|
|
||||||
placeholder={language.t("provider.custom.headers.value.placeholder")}
|
|
||||||
value={h.value}
|
|
||||||
onChange={(v) => setHeader(i(), "value", v)}
|
|
||||||
validationState={h.err.value ? "invalid" : undefined}
|
|
||||||
error={h.err.value}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<IconButton
|
|
||||||
type="button"
|
|
||||||
icon="trash"
|
|
||||||
variant="ghost"
|
|
||||||
class="mt-1.5"
|
|
||||||
onClick={() => removeHeader(i())}
|
|
||||||
disabled={form.headers.length <= 1}
|
|
||||||
aria-label={language.t("provider.custom.headers.remove")}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<IconButton
|
||||||
</For>
|
type="button"
|
||||||
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
|
icon="trash"
|
||||||
{language.t("provider.custom.headers.add")}
|
variant="ghost"
|
||||||
</Button>
|
class="mt-1.5"
|
||||||
</div>
|
onClick={() => removeModel(i())}
|
||||||
|
disabled={form.models.length <= 1}
|
||||||
<Button
|
aria-label={language.t("provider.custom.models.remove")}
|
||||||
class="w-auto self-start"
|
/>
|
||||||
type="submit"
|
</div>
|
||||||
size="large"
|
)}
|
||||||
variant="primary"
|
</For>
|
||||||
disabled={saveMutation.isPending}
|
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
|
||||||
>
|
{language.t("provider.custom.models.add")}
|
||||||
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
|
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
|
||||||
</Dialog>
|
<div class="flex flex-col gap-3">
|
||||||
|
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
|
||||||
|
<For each={form.headers}>
|
||||||
|
{(h, i) => (
|
||||||
|
<div class="flex gap-2 items-start" data-row={h.row}>
|
||||||
|
<div class="flex-1">
|
||||||
|
<TextField
|
||||||
|
label={language.t("provider.custom.headers.key.label")}
|
||||||
|
hideLabel
|
||||||
|
placeholder={language.t("provider.custom.headers.key.placeholder")}
|
||||||
|
value={h.key}
|
||||||
|
onChange={(v) => setHeader(i(), "key", v)}
|
||||||
|
validationState={h.err.key ? "invalid" : undefined}
|
||||||
|
error={h.err.key}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<TextField
|
||||||
|
label={language.t("provider.custom.headers.value.label")}
|
||||||
|
hideLabel
|
||||||
|
placeholder={language.t("provider.custom.headers.value.placeholder")}
|
||||||
|
value={h.value}
|
||||||
|
onChange={(v) => setHeader(i(), "value", v)}
|
||||||
|
validationState={h.err.value ? "invalid" : undefined}
|
||||||
|
error={h.err.value}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
icon="trash"
|
||||||
|
variant="ghost"
|
||||||
|
class="mt-1.5"
|
||||||
|
onClick={() => removeHeader(i())}
|
||||||
|
disabled={form.headers.length <= 1}
|
||||||
|
aria-label={language.t("provider.custom.headers.remove")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
|
||||||
|
{language.t("provider.custom.headers.add")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
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>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
156
packages/app/src/components/dialog-edit-project-v2.tsx
Normal file
156
packages/app/src/components/dialog-edit-project-v2.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||||
|
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
|
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||||
|
import { Field } from "@opencode-ai/ui/v2/field-v2"
|
||||||
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { ProjectAvatar, PROJECT_AVATAR_VARIANTS } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||||
|
import { TextareaV2 } from "@opencode-ai/ui/v2/textarea-v2"
|
||||||
|
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||||
|
import { For, Show } from "solid-js"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
|
import { createEditProjectModel } from "./edit-project"
|
||||||
|
|
||||||
|
export function DialogEditProjectV2(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const model = createEditProjectModel(props)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog fit>
|
||||||
|
<form onSubmit={model.submit} class="contents">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{language.t("dialog.project.edit.title")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DividerV2 />
|
||||||
|
<DialogBody class="flex max-h-[min(560px,calc(100vh-160px))] w-full flex-col gap-6 overflow-y-auto px-4 pt-4 pb-1">
|
||||||
|
<Field>
|
||||||
|
<Field.Label>{language.t("dialog.project.edit.name")}</Field.Label>
|
||||||
|
<TextInputV2
|
||||||
|
autofocus
|
||||||
|
appearance="large"
|
||||||
|
class="!w-full"
|
||||||
|
value={model.store.name}
|
||||||
|
placeholder={model.folderName()}
|
||||||
|
onInput={(event) => model.setStore("name", event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col gap-2">
|
||||||
|
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||||
|
{language.t("dialog.project.edit.icon")}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={language.t("dialog.project.edit.icon.alt")}
|
||||||
|
class="relative size-16 shrink-0 cursor-pointer overflow-hidden rounded-[6px] outline outline-1 outline-transparent transition-[background-color,outline-color] focus-visible:outline-v2-border-border-focus"
|
||||||
|
classList={{
|
||||||
|
"bg-v2-overlay-simple-overlay-hover outline-v2-border-border-focus": model.store.dragOver,
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||||
|
onMouseLeave={() => model.setStore("iconHover", false)}
|
||||||
|
onDrop={model.drop}
|
||||||
|
onDragOver={model.dragOver}
|
||||||
|
onDragLeave={model.dragLeave}
|
||||||
|
onClick={model.iconClick}
|
||||||
|
>
|
||||||
|
<ProjectAvatar
|
||||||
|
fallback={model.store.name || model.defaultName()}
|
||||||
|
src={getProjectAvatarSource(props.project.id, {
|
||||||
|
color: model.store.color,
|
||||||
|
url: props.project.icon?.url,
|
||||||
|
override: model.store.iconOverride,
|
||||||
|
})}
|
||||||
|
variant={getProjectAvatarVariant(model.store.color)}
|
||||||
|
class="!size-16 [&_[data-slot=project-avatar-surface]]:!rounded-[6px] [&_[data-slot=project-avatar-surface]]:!text-[32px]"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[6px] bg-v2-background-bg-contrast/80 text-v2-icon-icon-contrast backdrop-blur-[2px] transition-opacity"
|
||||||
|
classList={{
|
||||||
|
"opacity-100": model.store.iconHover,
|
||||||
|
"opacity-0": !model.store.iconHover,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name={model.store.iconOverride ? "close" : "outline-share"} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={(element) => {
|
||||||
|
model.setIconInput(element)
|
||||||
|
}}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
onChange={model.inputChange}
|
||||||
|
/>
|
||||||
|
<div class="flex select-none flex-col gap-[6px] text-[11px] font-[440] leading-none tracking-[0.05px] text-v2-text-text-muted">
|
||||||
|
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||||
|
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Show when={!model.store.iconOverride}>
|
||||||
|
<div class="flex w-full flex-col gap-2">
|
||||||
|
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
|
||||||
|
{language.t("dialog.project.edit.color")}
|
||||||
|
</div>
|
||||||
|
<div class="-ml-1 flex gap-1.5">
|
||||||
|
<For each={PROJECT_AVATAR_VARIANTS}>
|
||||||
|
{(color) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||||
|
aria-pressed={getProjectAvatarVariant(model.store.color) === color}
|
||||||
|
class="flex size-8 items-center justify-center rounded-[10px] p-1 outline outline-1 outline-transparent transition-[background-color,outline-color] hover:bg-v2-overlay-simple-overlay-hover focus-visible:outline-v2-border-border-focus"
|
||||||
|
classList={{
|
||||||
|
"bg-v2-overlay-simple-overlay-hover [box-shadow:inset_0_0_0_2px_var(--v2-border-border-focus)]":
|
||||||
|
getProjectAvatarVariant(model.store.color) === color,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (getProjectAvatarVariant(model.store.color) === color && !props.project.icon?.url) return
|
||||||
|
model.setStore(
|
||||||
|
"color",
|
||||||
|
getProjectAvatarVariant(model.store.color) === color ? undefined : color,
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProjectAvatar
|
||||||
|
fallback={model.store.name || model.defaultName()}
|
||||||
|
variant={getProjectAvatarVariant(color)}
|
||||||
|
class="!size-6 [&_[data-slot=project-avatar-surface]]:!rounded-[6px]"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<Field.Label>{language.t("dialog.project.edit.worktree.startup")}</Field.Label>
|
||||||
|
<Field.Prefix>{language.t("dialog.project.edit.worktree.startup.description")}</Field.Prefix>
|
||||||
|
<TextareaV2
|
||||||
|
class="!w-full [&_[data-slot=textarea-v2-textarea]]:font-mono"
|
||||||
|
rows={3}
|
||||||
|
value={model.store.startup}
|
||||||
|
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||||
|
spellcheck={false}
|
||||||
|
onInput={(event) => model.setStore("startup", event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</DialogBody>
|
||||||
|
<DialogFooter>
|
||||||
|
<ButtonV2 type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
|
||||||
|
{language.t("common.cancel")}
|
||||||
|
</ButtonV2>
|
||||||
|
<ButtonV2 type="submit" variant="contrast" disabled={model.save.isPending}>
|
||||||
|
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||||
|
</ButtonV2>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,123 +1,32 @@
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
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 { For, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
import { type LocalProject, getAvatarColors } from "@/context/layout"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
|
||||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
import { getProjectAvatarSource } from "@/pages/layout/helpers"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { useGlobal } from "@/context/global"
|
import { createEditProjectModel } from "./edit-project"
|
||||||
|
|
||||||
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
|
||||||
|
|
||||||
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
|
export function DialogEditProject(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||||
const dialog = useDialog()
|
|
||||||
const global = useGlobal()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
const model = createEditProjectModel(props)
|
||||||
const serverSDK = () => serverCtx().sdk
|
|
||||||
const serverSync = () => serverCtx().sync
|
|
||||||
|
|
||||||
const folderName = createMemo(() => getFilename(props.project.worktree))
|
|
||||||
const defaultName = createMemo(() => props.project.name || folderName())
|
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
|
||||||
name: defaultName(),
|
|
||||||
color: props.project.icon?.color,
|
|
||||||
iconOverride: props.project.icon?.override,
|
|
||||||
startup: props.project.commands?.start ?? "",
|
|
||||||
dragOver: false,
|
|
||||||
iconHover: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
let iconInput: HTMLInputElement | undefined
|
|
||||||
|
|
||||||
function handleFileSelect(file: File) {
|
|
||||||
if (!file.type.startsWith("image/")) return
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = (e) => {
|
|
||||||
setStore("iconOverride", e.target?.result as string)
|
|
||||||
setStore("iconHover", false)
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDrop(e: DragEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setStore("dragOver", false)
|
|
||||||
const file = e.dataTransfer?.files[0]
|
|
||||||
if (file) handleFileSelect(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragOver(e: DragEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setStore("dragOver", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragLeave() {
|
|
||||||
setStore("dragOver", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInputChange(e: Event) {
|
|
||||||
const input = e.target as HTMLInputElement
|
|
||||||
const file = input.files?.[0]
|
|
||||||
if (file) handleFileSelect(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearIcon() {
|
|
||||||
setStore("iconOverride", "")
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveMutation = useMutation(() => ({
|
|
||||||
mutationFn: async () => {
|
|
||||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
|
||||||
const start = store.startup.trim()
|
|
||||||
|
|
||||||
if (props.project.id && props.project.id !== "global") {
|
|
||||||
await serverSDK().client.project.update({
|
|
||||||
projectID: props.project.id,
|
|
||||||
directory: props.project.worktree,
|
|
||||||
name,
|
|
||||||
icon: { color: store.color || "", override: store.iconOverride || "" },
|
|
||||||
commands: { start },
|
|
||||||
})
|
|
||||||
serverSync().project.icon(props.project.worktree, store.iconOverride || undefined)
|
|
||||||
dialog.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
serverSync().project.meta(props.project.worktree, {
|
|
||||||
name,
|
|
||||||
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
|
|
||||||
commands: { start: start || undefined },
|
|
||||||
})
|
|
||||||
dialog.close()
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
function handleSubmit(e: SubmitEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (saveMutation.isPending) return
|
|
||||||
saveMutation.mutate()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
|
<Dialog title={language.t("dialog.project.edit.title")} class="w-full max-w-[480px] mx-auto">
|
||||||
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6 pt-0">
|
<form onSubmit={model.submit} class="flex flex-col gap-6 p-6 pt-0">
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<TextField
|
<TextField
|
||||||
autofocus
|
autofocus
|
||||||
type="text"
|
type="text"
|
||||||
label={language.t("dialog.project.edit.name")}
|
label={language.t("dialog.project.edit.name")}
|
||||||
placeholder={folderName()}
|
placeholder={model.folderName()}
|
||||||
value={store.name}
|
value={model.store.name}
|
||||||
onChange={(v) => setStore("name", v)}
|
onChange={(v) => model.setStore("name", v)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
|
|
@ -125,38 +34,32 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||||
<div class="flex gap-3 items-start">
|
<div class="flex gap-3 items-start">
|
||||||
<div
|
<div
|
||||||
class="relative"
|
class="relative"
|
||||||
onMouseEnter={() => setStore("iconHover", true)}
|
onMouseEnter={() => model.setStore("iconHover", true)}
|
||||||
onMouseLeave={() => setStore("iconHover", false)}
|
onMouseLeave={() => model.setStore("iconHover", false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="relative size-16 rounded-md transition-colors cursor-pointer"
|
class="relative size-16 rounded-md transition-colors cursor-pointer"
|
||||||
classList={{
|
classList={{
|
||||||
"border-text-interactive-base bg-surface-info-base/20": store.dragOver,
|
"border-text-interactive-base bg-surface-info-base/20": model.store.dragOver,
|
||||||
"border-border-base hover:border-border-strong": !store.dragOver,
|
"border-border-base hover:border-border-strong": !model.store.dragOver,
|
||||||
"overflow-hidden": !!store.iconOverride,
|
"overflow-hidden": !!model.store.iconOverride,
|
||||||
}}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onClick={() => {
|
|
||||||
if (store.iconOverride && store.iconHover) {
|
|
||||||
clearIcon()
|
|
||||||
} else {
|
|
||||||
iconInput?.click()
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
|
onDrop={model.drop}
|
||||||
|
onDragOver={model.dragOver}
|
||||||
|
onDragLeave={model.dragLeave}
|
||||||
|
onClick={model.iconClick}
|
||||||
>
|
>
|
||||||
<Show
|
<Show
|
||||||
when={getProjectAvatarSource(props.project.id, {
|
when={getProjectAvatarSource(props.project.id, {
|
||||||
color: store.color,
|
color: model.store.color,
|
||||||
url: props.project.icon?.url,
|
url: props.project.icon?.url,
|
||||||
override: store.iconOverride,
|
override: model.store.iconOverride,
|
||||||
})}
|
})}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="size-full flex items-center justify-center">
|
<div class="size-full flex items-center justify-center">
|
||||||
<Avatar
|
<Avatar
|
||||||
fallback={store.name || defaultName()}
|
fallback={model.store.name || model.defaultName()}
|
||||||
{...getAvatarColors(store.color)}
|
{...getAvatarColors(model.store.color)}
|
||||||
class="size-full text-[32px]"
|
class="size-full text-[32px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -174,8 +77,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
||||||
classList={{
|
classList={{
|
||||||
"opacity-100": store.iconHover && !store.iconOverride,
|
"opacity-100": model.store.iconHover && !model.store.iconOverride,
|
||||||
"opacity-0": !(store.iconHover && !store.iconOverride),
|
"opacity-0": !(model.store.iconHover && !model.store.iconOverride),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||||
|
|
@ -183,8 +86,8 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
|
||||||
classList={{
|
classList={{
|
||||||
"opacity-100": store.iconHover && !!store.iconOverride,
|
"opacity-100": model.store.iconHover && !!model.store.iconOverride,
|
||||||
"opacity-0": !(store.iconHover && !!store.iconOverride),
|
"opacity-0": !(model.store.iconHover && !!model.store.iconOverride),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
|
||||||
|
|
@ -193,12 +96,12 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||||
<input
|
<input
|
||||||
id="icon-upload"
|
id="icon-upload"
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
iconInput = el
|
model.setIconInput(el)
|
||||||
}}
|
}}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
onChange={handleInputChange}
|
onChange={model.inputChange}
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
|
||||||
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
<span>{language.t("dialog.project.edit.icon.hint")}</span>
|
||||||
|
|
@ -207,7 +110,7 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={!store.iconOverride}>
|
<Show when={!model.store.iconOverride}>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
|
<label class="text-12-medium text-text-weak">{language.t("dialog.project.edit.color")}</label>
|
||||||
<div class="flex gap-1.5">
|
<div class="flex gap-1.5">
|
||||||
|
|
@ -216,21 +119,21 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
aria-label={language.t("dialog.project.edit.color.select", { color })}
|
||||||
aria-pressed={store.color === color}
|
aria-pressed={model.store.color === color}
|
||||||
classList={{
|
classList={{
|
||||||
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
"flex items-center justify-center size-10 p-0.5 rounded-lg overflow-hidden transition-colors cursor-default": true,
|
||||||
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
|
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover":
|
||||||
store.color === color,
|
model.store.color === color,
|
||||||
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
|
||||||
store.color !== color,
|
model.store.color !== color,
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (store.color === color && !props.project.icon?.url) return
|
if (model.store.color === color && !props.project.icon?.url) return
|
||||||
setStore("color", store.color === color ? undefined : color)
|
model.setStore("color", model.store.color === color ? undefined : color)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
fallback={store.name || defaultName()}
|
fallback={model.store.name || model.defaultName()}
|
||||||
{...getAvatarColors(color)}
|
{...getAvatarColors(color)}
|
||||||
class="size-full rounded"
|
class="size-full rounded"
|
||||||
/>
|
/>
|
||||||
|
|
@ -246,19 +149,19 @@ export function DialogEditProject(props: { project: LocalProject; server: Server
|
||||||
label={language.t("dialog.project.edit.worktree.startup")}
|
label={language.t("dialog.project.edit.worktree.startup")}
|
||||||
description={language.t("dialog.project.edit.worktree.startup.description")}
|
description={language.t("dialog.project.edit.worktree.startup.description")}
|
||||||
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
placeholder={language.t("dialog.project.edit.worktree.startup.placeholder")}
|
||||||
value={store.startup}
|
value={model.store.startup}
|
||||||
onChange={(v) => setStore("startup", v)}
|
onChange={(v) => model.setStore("startup", v)}
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
|
class="max-h-14 w-full overflow-y-auto font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
<Button type="button" variant="ghost" size="large" onClick={model.close}>
|
||||||
{language.t("common.cancel")}
|
{language.t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
|
<Button type="submit" variant="primary" size="large" disabled={model.save.isPending}>
|
||||||
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
|
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -69,15 +69,11 @@ export const DialogFork: Component = () => {
|
||||||
const dir = base64Encode(sdk().directory)
|
const dir = base64Encode(sdk().directory)
|
||||||
|
|
||||||
sdk()
|
sdk()
|
||||||
.client.session.fork({ sessionID, messageID: item.id })
|
.api.session.fork({ sessionID, messageID: item.id })
|
||||||
.then((forked) => {
|
.then((forked) => {
|
||||||
if (!forked.data) {
|
|
||||||
showToast({ title: language.t("common.requestFailed") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dialog.close()
|
dialog.close()
|
||||||
prompt.set(restored, undefined, { dir, id: forked.data.id })
|
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||||
navigate(`/${dir}/session/${forked.data.id}`)
|
navigate(`/${dir}/session/${forked.id}`)
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import { useLocal } from "@/context/local"
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { SettingsListV2 } from "./settings-v2/parts/list"
|
import { SettingsListV2 } from "./settings-v2/parts/list"
|
||||||
import { SettingsRowV2 } from "./settings-v2/parts/row"
|
import { SettingsRowV2 } from "./settings-v2/parts/row"
|
||||||
|
|
@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => {
|
||||||
const directory = () => decode64(local.slug())
|
const directory = () => decode64(local.slug())
|
||||||
|
|
||||||
const handleConnectProvider = () => {
|
const handleConnectProvider = () => {
|
||||||
dialog.show(() => <DialogSelectProvider directory={directory} />)
|
void dialog.show(() => <DialogConnectProvider directory={directory} />)
|
||||||
}
|
}
|
||||||
const providerRank = (id: string) => popularProviders.indexOf(id)
|
const providerRank = (id: string) => popularProviders.indexOf(id)
|
||||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||||
|
|
@ -123,7 +123,7 @@ export const DialogManageModelsV2: Component = () => {
|
||||||
const directory = () => decode64(local.slug())
|
const directory = () => decode64(local.slug())
|
||||||
|
|
||||||
const handleConnectProvider = () => {
|
const handleConnectProvider = () => {
|
||||||
dialog.show(() => <DialogSelectProvider directory={directory} />)
|
void dialog.show(() => <DialogConnectProvider directory={directory} />)
|
||||||
}
|
}
|
||||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||||
const providerVisible = (providerID: string) =>
|
const providerVisible = (providerID: string) =>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import type { Path } from "@opencode-ai/sdk/v2/client"
|
||||||
import {
|
import {
|
||||||
absoluteTreePath,
|
absoluteTreePath,
|
||||||
activeTreeNavigation,
|
activeTreeNavigation,
|
||||||
|
|
@ -28,6 +29,7 @@ import {
|
||||||
} from "./directory-picker-domain"
|
} from "./directory-picker-domain"
|
||||||
import "./dialog-select-directory-v2.css"
|
import "./dialog-select-directory-v2.css"
|
||||||
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
|
||||||
interface DialogSelectDirectoryV2Props {
|
interface DialogSelectDirectoryV2Props {
|
||||||
title?: string
|
title?: string
|
||||||
|
|
@ -67,11 +69,13 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
||||||
const [fallbackPath] = createResource(
|
const [fallbackPath] = createResource(
|
||||||
() => (missingBase() ? true : undefined),
|
() => (missingBase() ? true : undefined),
|
||||||
() =>
|
async (): Promise<Path | undefined> => {
|
||||||
sdk.client.path
|
if ((await sdk.protocol) !== "v1") return
|
||||||
|
return sdk.client.path
|
||||||
.get()
|
.get()
|
||||||
.then((result) => result.data)
|
.then((result) => result.data)
|
||||||
.catch(() => undefined),
|
.catch(() => undefined)
|
||||||
|
},
|
||||||
{ initialValue: undefined },
|
{ initialValue: undefined },
|
||||||
)
|
)
|
||||||
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
|
||||||
|
|
@ -85,18 +89,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||||
)
|
)
|
||||||
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
const search = createDirectorySearch({ sdk, home, base: () => root() || start() })
|
||||||
const [suggestions] = createResource(input, async (value) => {
|
const [suggestions] = createResource(input, async (value) => {
|
||||||
const typed = cleanPickerInput(value).replace(/\/+$/, "")
|
const cleaned = cleanPickerInput(value)
|
||||||
|
const typed = cleaned.replace(/\/+$/, "")
|
||||||
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "")
|
||||||
if (!typed || typed === current) return { query: value, items: [] }
|
if (!cleaned || (root() && typed === current)) return { query: value, items: [] }
|
||||||
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const }))
|
||||||
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) }
|
||||||
const files = await sdk.client.find
|
const base = pickerRoot(cleaned) || root() || start()
|
||||||
.files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 })
|
if (!base) return { query: value, items: directories.slice(0, 5) }
|
||||||
.then((result) => result.data ?? [])
|
const files = await sdk.api.file
|
||||||
|
.find({
|
||||||
|
location: { directory: base },
|
||||||
|
query: pickerFileSearchQuery(base, value, home()),
|
||||||
|
type: "file",
|
||||||
|
limit: 20,
|
||||||
|
})
|
||||||
|
.then((result) => result.data)
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
const results = [
|
const results = [
|
||||||
...directories,
|
...directories,
|
||||||
...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })),
|
...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })),
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
query: value,
|
query: value,
|
||||||
|
|
@ -115,9 +127,14 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||||
existing ??
|
existing ??
|
||||||
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => {
|
||||||
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined)
|
||||||
return sdk.client.file
|
return sdk.api.file
|
||||||
.list({ directory: absolute, path: "" })
|
.list({ location: { directory: absolute } })
|
||||||
.then((result) => result.data ?? [])
|
.then((result) =>
|
||||||
|
result.data.map((entry) => ({
|
||||||
|
name: getFilename(entry.path.replace(/[\\/]+$/, "")),
|
||||||
|
type: entry.type,
|
||||||
|
})),
|
||||||
|
)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
})
|
})
|
||||||
listings.set(key, request)
|
listings.set(key, request)
|
||||||
|
|
@ -312,6 +329,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) {
|
||||||
{(suggestion, index) => (
|
{(suggestion, index) => (
|
||||||
<button
|
<button
|
||||||
id={`directory-picker-v2-suggestion-${index()}`}
|
id={`directory-picker-v2-suggestion-${index()}`}
|
||||||
|
data-directory-path={suggestion.absolute}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={index() === activeSuggestion()}
|
aria-selected={index() === activeSuggestion()}
|
||||||
data-active={index() === activeSuggestion() ? "" : undefined}
|
data-active={index() === activeSuggestion() ? "" : undefined}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { useLanguage } from "@/context/language"
|
||||||
import { ServerConnection } from "@/context/server"
|
import { ServerConnection } from "@/context/server"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
|
||||||
|
import type { Path } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
interface DialogSelectDirectoryProps {
|
interface DialogSelectDirectoryProps {
|
||||||
title?: string
|
title?: string
|
||||||
|
|
@ -17,6 +18,8 @@ interface DialogSelectDirectoryProps {
|
||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RECENT_PROJECT_LIMIT = 5
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
absolute: string
|
absolute: string
|
||||||
search: string
|
search: string
|
||||||
|
|
@ -56,13 +59,14 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
const [filter, setFilter] = createSignal("")
|
const [filter, setFilter] = createSignal("")
|
||||||
let list: ListRef | undefined
|
let list: ListRef | undefined
|
||||||
|
|
||||||
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
|
const missingHome = createMemo(() => !sync.data.path.home)
|
||||||
const [fallbackPath] = createResource(
|
const [fallbackPath] = createResource(
|
||||||
() => (missingBase() ? true : undefined),
|
() => (missingHome() ? true : undefined),
|
||||||
async () => {
|
async (): Promise<Path | undefined> => {
|
||||||
|
if ((await sdk.protocol) !== "v1") return
|
||||||
return sdk.client.path
|
return sdk.client.path
|
||||||
.get()
|
.get()
|
||||||
.then((x) => x.data)
|
.then((result) => result.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
},
|
},
|
||||||
{ initialValue: undefined },
|
{ initialValue: undefined },
|
||||||
|
|
@ -100,7 +104,6 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
return projects
|
return projects
|
||||||
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
|
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
|
||||||
.sort((a, b) => b.at - a.at || a.index - b.index)
|
.sort((a, b) => b.at - a.at || a.index - b.index)
|
||||||
.slice(0, 5)
|
|
||||||
.map(({ project }) => {
|
.map(({ project }) => {
|
||||||
const row = toRow(project.worktree, home(), "recent")
|
const row = toRow(project.worktree, home(), "recent")
|
||||||
const name = project.name || getFilename(project.worktree)
|
const name = project.name || getFilename(project.worktree)
|
||||||
|
|
@ -114,7 +117,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
const items = async (value: string) => {
|
const items = async (value: string) => {
|
||||||
const results = await directories(value)
|
const results = await directories(value)
|
||||||
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
|
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
|
||||||
return uniqueRows([...recentProjects(), ...directoryRows])
|
// Cap the idle list only. Once a query narrows the results, every project stays searchable.
|
||||||
|
const recent = recentProjects()
|
||||||
|
const visible = value ? recent : recent.slice(0, RECENT_PROJECT_LIMIT)
|
||||||
|
return uniqueRows([...visible, ...directoryRows])
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolve(absolute: string) {
|
function resolve(absolute: string) {
|
||||||
|
|
@ -162,7 +168,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
const path = displayPickerPath(item.absolute, filter(), home())
|
const path = displayPickerPath(item.absolute, filter(), home())
|
||||||
if (path === "~") {
|
if (path === "~") {
|
||||||
return (
|
return (
|
||||||
<div class="w-full flex items-center justify-between rounded-md">
|
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
|
||||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||||
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
||||||
<div class="flex items-center text-14-regular min-w-0">
|
<div class="flex items-center text-14-regular min-w-0">
|
||||||
|
|
@ -174,7 +180,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div class="w-full flex items-center justify-between rounded-md">
|
<div data-directory-path={item.absolute} class="w-full flex items-center justify-between rounded-md">
|
||||||
<div class="flex items-center gap-x-3 grow min-w-0">
|
<div class="flex items-center gap-x-3 grow min-w-0">
|
||||||
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
|
||||||
<div class="flex items-center text-14-regular min-w-0">
|
<div class="flex items-center text-14-regular min-w-0">
|
||||||
|
|
|
||||||
|
|
@ -1,329 +1,81 @@
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
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/core/util/encode"
|
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { createMemo, createSignal, lazy, Match, Show, Switch } from "solid-js"
|
||||||
import { createMemo, createSignal, lazy, Match, onCleanup, Show, Switch } from "solid-js"
|
import { formatKeybind } from "@/context/command"
|
||||||
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSDK, type ServerSDK } from "@/context/server-sdk"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { useLayout } from "@/context/layout"
|
|
||||||
import { useFile } from "@/context/file"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
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"
|
||||||
|
import {
|
||||||
|
createCommandPaletteFileEntry,
|
||||||
|
createCommandPaletteFileOpener,
|
||||||
|
createCommandPaletteModel,
|
||||||
|
uniqueCommandPaletteEntries,
|
||||||
|
type CommandPaletteEntry,
|
||||||
|
} from "./command-palette"
|
||||||
|
import { DialogCommandPaletteV2 } from "./dialog-command-palette-v2"
|
||||||
|
|
||||||
const DialogSelectFileV2 = lazy(() =>
|
const DialogSelectFileV2 = lazy(() =>
|
||||||
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
|
import("./dialog-select-directory-v2").then((module) => ({ default: module.DialogSelectDirectoryV2 })),
|
||||||
)
|
)
|
||||||
|
|
||||||
type EntryType = "command" | "file" | "session"
|
|
||||||
|
|
||||||
type Entry = {
|
|
||||||
id: string
|
|
||||||
type: EntryType
|
|
||||||
title: string
|
|
||||||
description?: string
|
|
||||||
keybind?: string
|
|
||||||
category: string
|
|
||||||
option?: CommandOption
|
|
||||||
path?: string
|
|
||||||
directory?: string
|
|
||||||
sessionID?: string
|
|
||||||
archived?: number
|
|
||||||
updated?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type DialogSelectFileMode = "all" | "files"
|
type DialogSelectFileMode = "all" | "files"
|
||||||
|
|
||||||
const ENTRY_LIMIT = 5
|
|
||||||
const COMMON_COMMAND_IDS = [
|
|
||||||
"session.new",
|
|
||||||
"workspace.new",
|
|
||||||
"session.previous",
|
|
||||||
"session.next",
|
|
||||||
"terminal.toggle",
|
|
||||||
"review.toggle",
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const uniqueEntries = (items: Entry[]) => {
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const out: Entry[] = []
|
|
||||||
for (const item of items) {
|
|
||||||
if (seen.has(item.id)) continue
|
|
||||||
seen.add(item.id)
|
|
||||||
out.push(item)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
const createCommandEntry = (option: CommandOption, category: string): Entry => ({
|
|
||||||
id: "command:" + option.id,
|
|
||||||
type: "command",
|
|
||||||
title: option.title,
|
|
||||||
description: option.description,
|
|
||||||
keybind: option.keybind,
|
|
||||||
category,
|
|
||||||
option,
|
|
||||||
})
|
|
||||||
|
|
||||||
const createFileEntry = (path: string, category: string): Entry => ({
|
|
||||||
id: "file:" + path,
|
|
||||||
type: "file",
|
|
||||||
title: path,
|
|
||||||
category,
|
|
||||||
path,
|
|
||||||
})
|
|
||||||
|
|
||||||
const createSessionEntry = (
|
|
||||||
input: {
|
|
||||||
directory: string
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
archived?: number
|
|
||||||
updated?: number
|
|
||||||
},
|
|
||||||
category: string,
|
|
||||||
): Entry => ({
|
|
||||||
id: `session:${input.directory}:${input.id}`,
|
|
||||||
type: "session",
|
|
||||||
title: input.title,
|
|
||||||
description: input.description,
|
|
||||||
category,
|
|
||||||
directory: input.directory,
|
|
||||||
sessionID: input.id,
|
|
||||||
archived: input.archived,
|
|
||||||
updated: input.updated,
|
|
||||||
})
|
|
||||||
|
|
||||||
function createCommandEntries(props: {
|
|
||||||
filesOnly: () => boolean
|
|
||||||
command: ReturnType<typeof useCommand>
|
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
}) {
|
|
||||||
const allowed = createMemo(() => {
|
|
||||||
if (props.filesOnly()) return []
|
|
||||||
return props.command.options.filter(
|
|
||||||
(option) =>
|
|
||||||
!option.disabled && !option.hidden && !option.id.startsWith("suggested.") && option.id !== "file.open",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const list = createMemo(() => {
|
|
||||||
const category = props.language.t("palette.group.commands")
|
|
||||||
return allowed().map((option) => createCommandEntry(option, category))
|
|
||||||
})
|
|
||||||
|
|
||||||
const picks = createMemo(() => {
|
|
||||||
const all = allowed()
|
|
||||||
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
|
|
||||||
const picked = all.filter((option) => order.has(option.id))
|
|
||||||
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
|
|
||||||
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
|
|
||||||
const category = props.language.t("palette.group.commands")
|
|
||||||
return sorted.map((option) => createCommandEntry(option, category))
|
|
||||||
})
|
|
||||||
|
|
||||||
return { allowed, list, picks }
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFileEntries(props: {
|
|
||||||
file: ReturnType<typeof useFile>
|
|
||||||
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
|
||||||
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 all = tabState.openedTabs()
|
|
||||||
const active = tabState.activeFileTab()
|
|
||||||
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const category = props.language.t("palette.group.files")
|
|
||||||
const items: Entry[] = []
|
|
||||||
|
|
||||||
for (const item of order) {
|
|
||||||
const path = props.file.pathFromTab(item)
|
|
||||||
if (!path) continue
|
|
||||||
if (seen.has(path)) continue
|
|
||||||
seen.add(path)
|
|
||||||
items.push(createFileEntry(path, category))
|
|
||||||
}
|
|
||||||
|
|
||||||
return items.slice(0, ENTRY_LIMIT)
|
|
||||||
})
|
|
||||||
|
|
||||||
const root = createMemo(() => {
|
|
||||||
const category = props.language.t("palette.group.files")
|
|
||||||
const nodes = props.file.tree.children("")
|
|
||||||
const paths = nodes
|
|
||||||
.filter((node) => node.type === "file")
|
|
||||||
.map((node) => node.path)
|
|
||||||
.sort((a, b) => a.localeCompare(b))
|
|
||||||
return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
|
|
||||||
})
|
|
||||||
|
|
||||||
return { recent, root }
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSessionEntries(props: {
|
|
||||||
workspaces: () => string[]
|
|
||||||
label: (directory: string) => string
|
|
||||||
serverSDK: ServerSDK
|
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
}) {
|
|
||||||
const state: {
|
|
||||||
token: number
|
|
||||||
inflight: Promise<Entry[]> | undefined
|
|
||||||
cached: Entry[] | undefined
|
|
||||||
} = {
|
|
||||||
token: 0,
|
|
||||||
inflight: undefined,
|
|
||||||
cached: undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessions = (text: string) => {
|
|
||||||
const query = text.trim()
|
|
||||||
if (!query) {
|
|
||||||
state.token += 1
|
|
||||||
state.inflight = undefined
|
|
||||||
state.cached = undefined
|
|
||||||
return [] as Entry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.cached) return state.cached
|
|
||||||
if (state.inflight) return state.inflight
|
|
||||||
|
|
||||||
const current = state.token
|
|
||||||
const dirs = props.workspaces()
|
|
||||||
if (dirs.length === 0) return [] as Entry[]
|
|
||||||
|
|
||||||
state.inflight = Promise.all(
|
|
||||||
dirs.map((directory) => {
|
|
||||||
const description = props.label(directory)
|
|
||||||
return props.serverSDK.client.session
|
|
||||||
.list({ directory, roots: true })
|
|
||||||
.then((x) =>
|
|
||||||
(x.data ?? [])
|
|
||||||
.filter((s) => !!s?.id)
|
|
||||||
.map((s) => ({
|
|
||||||
id: s.id,
|
|
||||||
title: s.title ?? props.language.t("command.session.new"),
|
|
||||||
description,
|
|
||||||
directory,
|
|
||||||
archived: s.time?.archived,
|
|
||||||
updated: s.time?.updated,
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.catch(
|
|
||||||
() =>
|
|
||||||
[] as {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
directory: string
|
|
||||||
archived?: number
|
|
||||||
updated?: number
|
|
||||||
}[],
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.then((results) => {
|
|
||||||
if (state.token !== current) return [] as Entry[]
|
|
||||||
const seen = new Set<string>()
|
|
||||||
const category = props.language.t("command.category.session")
|
|
||||||
const next = results
|
|
||||||
.flat()
|
|
||||||
.filter((item) => {
|
|
||||||
const key = `${item.directory}:${item.id}`
|
|
||||||
if (seen.has(key)) return false
|
|
||||||
seen.add(key)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
.map((item) => createSessionEntry(item, category))
|
|
||||||
state.cached = next
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
.catch(() => [] as Entry[])
|
|
||||||
.finally(() => {
|
|
||||||
state.inflight = undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
return state.inflight
|
|
||||||
}
|
|
||||||
|
|
||||||
return { sessions }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
|
||||||
const command = useCommand()
|
|
||||||
const language = useLanguage()
|
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const layout = useLayout()
|
|
||||||
const file = useFile()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const serverSDK = useServerSDK()
|
|
||||||
const serverSync = useServerSync()
|
|
||||||
const { params, tabs, view } = useSessionLayout()
|
|
||||||
const filesOnly = () => props.mode === "files"
|
const filesOnly = () => props.mode === "files"
|
||||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
|
||||||
const [grouped, setGrouped] = createSignal(false)
|
|
||||||
const commandEntries = createCommandEntries({ filesOnly, command, language })
|
|
||||||
const fileEntries = createFileEntries({ file, tabs, language })
|
|
||||||
|
|
||||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
if (!filesOnly() && settings.general.newLayoutDesigns()) {
|
||||||
const project = createMemo(() => {
|
return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
|
||||||
const directory = projectDirectory()
|
|
||||||
if (!directory) return
|
|
||||||
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
|
|
||||||
})
|
|
||||||
const workspaces = createMemo(() => {
|
|
||||||
const directory = projectDirectory()
|
|
||||||
const current = project()
|
|
||||||
if (!current) return directory ? [directory] : []
|
|
||||||
|
|
||||||
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
|
|
||||||
if (directory && !dirs.includes(directory)) return [...dirs, directory]
|
|
||||||
return dirs
|
|
||||||
})
|
|
||||||
const homedir = createMemo(() => serverSync().data.path.home)
|
|
||||||
const label = (directory: string) => {
|
|
||||||
const current = project()
|
|
||||||
const kind =
|
|
||||||
current && directory === current.worktree
|
|
||||||
? language.t("workspace.type.local")
|
|
||||||
: language.t("workspace.type.sandbox")
|
|
||||||
const [store] = serverSync().child(directory, { bootstrap: false })
|
|
||||||
const home = homedir()
|
|
||||||
const path = home ? directory.replace(home, "~") : directory
|
|
||||||
const name = store.vcs?.branch ?? getFilename(directory)
|
|
||||||
return `${kind} : ${name || path}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sessions } = createSessionEntries({ workspaces, label, serverSDK: serverSDK(), language })
|
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
|
||||||
|
return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <DialogSelectFileLegacy filesOnly={filesOnly} onOpenFile={props.onOpenFile} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void }) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
|
const { params } = useSessionLayout()
|
||||||
|
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||||
|
const openFile = createCommandPaletteFileOpener(props.onOpenFile)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogSelectFileV2
|
||||||
|
server={serverSDK().server}
|
||||||
|
mode="file"
|
||||||
|
start={projectDirectory()}
|
||||||
|
title={language.t("session.header.searchFiles")}
|
||||||
|
onSelect={(result) => {
|
||||||
|
if (typeof result !== "string") return
|
||||||
|
openFile(result)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogSelectFileLegacy(props: { filesOnly: () => boolean; onOpenFile?: (path: string) => void }) {
|
||||||
|
const palette = createCommandPaletteModel(props)
|
||||||
|
const [grouped, setGrouped] = createSignal(false)
|
||||||
|
|
||||||
const items = async (text: string) => {
|
const items = async (text: string) => {
|
||||||
const query = text.trim()
|
const query = text.trim()
|
||||||
setGrouped(query.length > 0)
|
setGrouped(query.length > 0)
|
||||||
|
|
||||||
if (!query && filesOnly()) {
|
if (!query && props.filesOnly()) {
|
||||||
const loaded = file.tree.state("")?.loaded
|
const loaded = palette.file.tree.state("")?.loaded
|
||||||
const pending = loaded ? Promise.resolve() : file.tree.list("")
|
const pending = loaded ? Promise.resolve() : palette.file.tree.list("")
|
||||||
const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
|
const next = uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
|
||||||
|
|
||||||
if (loaded || next.length > 0) {
|
if (loaded || next.length > 0) {
|
||||||
void pending
|
void pending
|
||||||
|
|
@ -331,79 +83,24 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||||
}
|
}
|
||||||
|
|
||||||
await pending
|
await pending
|
||||||
return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
|
return uniqueCommandPaletteEntries([...palette.recentFileEntries(), ...palette.rootFileEntries()])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!query) return [...commandEntries.picks(), ...fileEntries.recent()]
|
if (!query) return [...palette.preferredCommandEntries(), ...palette.recentFileEntries()]
|
||||||
|
|
||||||
if (filesOnly()) {
|
if (props.filesOnly()) {
|
||||||
const files = await file.searchFiles(query)
|
const files = await palette.file.searchFiles(query)
|
||||||
const category = language.t("palette.group.files")
|
const category = palette.language.t("palette.group.files")
|
||||||
return files.map((path) => createFileEntry(path, category))
|
return files.map((path) => createCommandPaletteFileEntry(path, category))
|
||||||
}
|
}
|
||||||
|
|
||||||
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
|
const [files, nextSessions] = await Promise.all([
|
||||||
const category = language.t("palette.group.files")
|
palette.file.searchFiles(query),
|
||||||
const entries = files.map((path) => createFileEntry(path, category))
|
Promise.resolve(palette.sessions(query)),
|
||||||
return [...commandEntries.list(), ...nextSessions, ...entries]
|
])
|
||||||
}
|
const category = palette.language.t("palette.group.files")
|
||||||
|
const entries = files.map((path) => createCommandPaletteFileEntry(path, category))
|
||||||
const handleMove = (item: Entry | undefined) => {
|
return [...palette.commandEntries(), ...nextSessions, ...entries]
|
||||||
state.cleanup?.()
|
|
||||||
if (!item) return
|
|
||||||
if (item.type !== "command") return
|
|
||||||
state.cleanup = item.option?.onHighlight?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
const open = (path: string) => {
|
|
||||||
const value = file.tab(path)
|
|
||||||
void tabs().open(value)
|
|
||||||
void file.load(path)
|
|
||||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
|
||||||
layout.fileTree.setTab("all")
|
|
||||||
props.onOpenFile?.(path)
|
|
||||||
tabs().setActive(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSelect = (item: Entry | undefined) => {
|
|
||||||
if (!item) return
|
|
||||||
state.committed = true
|
|
||||||
state.cleanup = undefined
|
|
||||||
dialog.close()
|
|
||||||
|
|
||||||
if (item.type === "command") {
|
|
||||||
item.option?.onSelect?.("palette")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.type === "session") {
|
|
||||||
if (!item.directory || !item.sessionID) return
|
|
||||||
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item.path) return
|
|
||||||
open(item.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (state.committed) return
|
|
||||||
state.cleanup?.()
|
|
||||||
})
|
|
||||||
|
|
||||||
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
|
|
||||||
return (
|
|
||||||
<DialogSelectFileV2
|
|
||||||
server={serverSDK().server}
|
|
||||||
mode="file"
|
|
||||||
start={projectDirectory()}
|
|
||||||
title={language.t("session.header.searchFiles")}
|
|
||||||
onSelect={(result) => {
|
|
||||||
if (typeof result !== "string") return
|
|
||||||
open(result)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -411,21 +108,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||||
<List
|
<List
|
||||||
class="px-3"
|
class="px-3"
|
||||||
search={{
|
search={{
|
||||||
placeholder: filesOnly()
|
placeholder: props.filesOnly()
|
||||||
? language.t("session.header.searchFiles")
|
? palette.language.t("session.header.searchFiles")
|
||||||
: language.t("palette.search.placeholder"),
|
: palette.language.t("palette.search.placeholder"),
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
hideIcon: true,
|
hideIcon: true,
|
||||||
}}
|
}}
|
||||||
emptyMessage={language.t("palette.empty")}
|
emptyMessage={palette.language.t("palette.empty")}
|
||||||
loadingMessage={language.t("common.loading")}
|
loadingMessage={palette.language.t("common.loading")}
|
||||||
items={items}
|
items={items}
|
||||||
key={(item) => item.id}
|
key={(item) => item.id}
|
||||||
filterKeys={["title", "description", "category"]}
|
filterKeys={["title", "description", "category"]}
|
||||||
skipFilter={(item) => item.type === "file"}
|
skipFilter={(item) => item.type === "file"}
|
||||||
groupBy={grouped() ? (item) => item.category : () => ""}
|
groupBy={grouped() ? (item) => item.category : () => ""}
|
||||||
onMove={handleMove}
|
onMove={(item: CommandPaletteEntry | undefined) => palette.highlight(item)}
|
||||||
onSelect={handleSelect}
|
onSelect={(item: CommandPaletteEntry | undefined) => palette.select(item)}
|
||||||
>
|
>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<Switch
|
<Switch
|
||||||
|
|
@ -452,7 +149,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 ?? "", language.t)}</Keybind>
|
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", palette.language.t)}</Keybind>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
|
|
@ -479,7 +176,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||||
</div>
|
</div>
|
||||||
<Show when={item.updated}>
|
<Show when={item.updated}>
|
||||||
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
|
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
|
||||||
{getRelativeTime(new Date(item.updated!).toISOString(), language.t)}
|
{getRelativeTime(new Date(item.updated!).toISOString(), palette.language.t)}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ 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.isPending) return
|
if (!x || x.status === "pending" || toggle.isPending) return
|
||||||
toggle.mutate(x.name)
|
toggle.mutate(x.name)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -76,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled()}
|
checked={enabled()}
|
||||||
disabled={toggle.isPending && toggle.variables === i.name}
|
disabled={status() === "pending" || (toggle.isPending && toggle.variables === i.name)}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
if (toggle.isPending) return
|
if (toggle.isPending) return
|
||||||
toggle.mutate(i.name)
|
toggle.mutate(i.name)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
// @ts-nocheck
|
||||||
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { createSignal, onMount } from "solid-js"
|
||||||
|
import { DialogSelectModelUnpaidV2 } from "./dialog-select-model-unpaid-v2"
|
||||||
|
|
||||||
|
const names = [
|
||||||
|
"MiMo V2.5 Free",
|
||||||
|
"Nemotron 3 Ultra Free",
|
||||||
|
"Deepseek V4 Flash Free",
|
||||||
|
"North Mini Code Free",
|
||||||
|
"Hy3 Free",
|
||||||
|
"Big Pickle",
|
||||||
|
]
|
||||||
|
|
||||||
|
function SelectModelWithoutProviders() {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const models = names.map((name, index) => ({
|
||||||
|
id: name.toLowerCase().replaceAll(" ", "-"),
|
||||||
|
name,
|
||||||
|
provider: { id: "opencode", name: "OpenCode" },
|
||||||
|
cost: { input: 0, output: 0 },
|
||||||
|
limit: { context: 128_000 },
|
||||||
|
capabilities: {
|
||||||
|
reasoning: index !== 5,
|
||||||
|
input: { text: true, image: false, audio: false, video: false, pdf: false },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
const [current, setCurrent] = createSignal(models[2])
|
||||||
|
const model = {
|
||||||
|
list: () => models,
|
||||||
|
current,
|
||||||
|
set(value) {
|
||||||
|
setCurrent(models.find((item) => item.id === value?.modelID))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const open = () => dialog.show(() => <DialogSelectModelUnpaidV2 model={model} />)
|
||||||
|
|
||||||
|
onMount(open)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="secondary" onClick={open}>
|
||||||
|
Open select model dialog
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "App/Dialogs/Select Model",
|
||||||
|
id: "app-dialog-select-model",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WithoutProviders = {
|
||||||
|
render: () => <SelectModelWithoutProviders />,
|
||||||
|
}
|
||||||
176
packages/app/src/components/dialog-select-model-unpaid-v2.tsx
Normal file
176
packages/app/src/components/dialog-select-model-unpaid-v2.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
|
||||||
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
|
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
|
||||||
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useTheme } from "@opencode-ai/ui/theme"
|
||||||
|
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
|
||||||
|
import { useLocal } from "@/context/local"
|
||||||
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
|
|
||||||
|
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||||
|
const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
|
||||||
|
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
|
||||||
|
|
||||||
|
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
|
||||||
|
const local = useLocal()
|
||||||
|
const model = props.model ?? local.model
|
||||||
|
const dialog = useDialog()
|
||||||
|
const theme = useTheme()
|
||||||
|
const directory = () => decode64(local.slug())
|
||||||
|
const providers = useProviders(directory)
|
||||||
|
const language = useLanguage()
|
||||||
|
const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
|
||||||
|
const currentKey = createMemo(() => {
|
||||||
|
const c = model.current()
|
||||||
|
return c ? `${c.provider.id}:${c.id}` : undefined
|
||||||
|
})
|
||||||
|
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
|
||||||
|
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
|
||||||
|
const freeModels = createMemo(() => model.list().filter(isFree))
|
||||||
|
|
||||||
|
const openProviders = (provider?: string) => {
|
||||||
|
void import("./dialog-connect-provider").then((x) => {
|
||||||
|
const controller = x.useProviderConnectController()
|
||||||
|
controller.select(provider)
|
||||||
|
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectModel = (item: ReturnType<ModelState["list"]>[number]) => {
|
||||||
|
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
||||||
|
dialog.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus starts on the dialog's close button, outside the list, so listen at the
|
||||||
|
// document level while the dialog is mounted instead of on the list container.
|
||||||
|
let listEl: HTMLDivElement | undefined
|
||||||
|
onMount(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return
|
||||||
|
if (!listEl) return
|
||||||
|
const buttons = Array.from(listEl.querySelectorAll<HTMLButtonElement>("button"))
|
||||||
|
if (buttons.length === 0) return
|
||||||
|
const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
|
||||||
|
const next =
|
||||||
|
index < 0 ? (e.key === "ArrowDown" ? 0 : buttons.length - 1) : index + (e.key === "ArrowDown" ? 1 : -1)
|
||||||
|
buttons[(next + buttons.length) % buttons.length]?.focus()
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", handleKeyDown)
|
||||||
|
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogV2
|
||||||
|
fit
|
||||||
|
containerClass="!h-auto max-h-[calc(100vh_-_16px)] !w-[min(calc(100vw_-_16px),640px)]"
|
||||||
|
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
|
||||||
|
>
|
||||||
|
<DialogHeader closeLabel={language.t("common.close")}>
|
||||||
|
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogBody class="max-h-[calc(100vh_-_68px)] min-h-0 flex-none gap-0 overflow-y-auto px-2 pb-2">
|
||||||
|
<div ref={listEl} class="flex min-h-0 flex-col">
|
||||||
|
<div data-section="free-models" class="flex w-full flex-col items-start pb-3">
|
||||||
|
<div class="flex h-8 w-full flex-none select-none flex-row items-center px-3 pb-2">
|
||||||
|
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||||
|
{language.t("dialog.model.unpaid.freeModels.title")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<For each={freeModels()}>
|
||||||
|
{(item) => (
|
||||||
|
<TooltipV2
|
||||||
|
class="w-full"
|
||||||
|
placement="right-start"
|
||||||
|
gutter={6}
|
||||||
|
openDelay={0}
|
||||||
|
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
|
||||||
|
value={
|
||||||
|
<ModelTooltip
|
||||||
|
model={{ ...item, name: displayModelName(item.name) }}
|
||||||
|
latest={item.latest}
|
||||||
|
free={isFree(item)}
|
||||||
|
v2
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full scroll-my-3.5 flex-row items-center gap-1.5 rounded-md px-3 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||||
|
onClick={() => selectModel(item)}
|
||||||
|
>
|
||||||
|
<span class="min-w-0 truncate">{displayModelName(item.name)}</span>
|
||||||
|
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
|
||||||
|
<Show when={item.latest}>
|
||||||
|
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
|
||||||
|
</Show>
|
||||||
|
<Show when={currentKey() === modelKey(item)}>
|
||||||
|
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
|
||||||
|
</Show>
|
||||||
|
</button>
|
||||||
|
</TooltipV2>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col">
|
||||||
|
<div class="flex w-full flex-col items-start rounded-lg border-[0.5px] border-v2-border-border-muted bg-v2-background-bg-layer-02 p-2.5 pt-2">
|
||||||
|
<div class="flex h-8 w-full select-none items-center px-0.5 pb-2">
|
||||||
|
<div class="flex h-5 items-center text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
|
||||||
|
{language.t("dialog.model.unpaid.addMore.title")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid w-full grid-cols-1 gap-y-1.5 gap-x-2 sm:grid-cols-2">
|
||||||
|
<For
|
||||||
|
each={[...providers.popular()]
|
||||||
|
.filter((provider) => featuredProviders.includes(provider.id))
|
||||||
|
.sort((a, b) => featuredProviders.indexOf(a.id) - featuredProviders.indexOf(b.id))}
|
||||||
|
>
|
||||||
|
{(provider) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-provider-id={provider.id}
|
||||||
|
class="flex min-h-11 w-full scroll-my-3.5 flex-row items-start gap-2 rounded-md bg-v2-background-bg-base px-3 py-2.5 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-background-bg-layer-01 focus:bg-v2-background-bg-layer-01 focus:outline-none"
|
||||||
|
classList={{
|
||||||
|
"border-[0.5px] border-transparent shadow-[var(--v2-elevation-raised)]":
|
||||||
|
theme.mode() !== "dark",
|
||||||
|
"border-[0.5px] border-v2-border-border-strong": theme.mode() === "dark",
|
||||||
|
}}
|
||||||
|
onClick={() => openProviders(provider.id)}
|
||||||
|
>
|
||||||
|
<ProviderIcon id={provider.id} class="mt-0.5 size-4 shrink-0 text-v2-icon-icon-base" />
|
||||||
|
<span class="flex min-w-0 flex-col">
|
||||||
|
<span class="truncate">{provider.name}</span>
|
||||||
|
<Show when={provider.id === "opencode" || provider.id === "opencode-go"}>
|
||||||
|
<span class="truncate font-[440] text-v2-text-text-muted">
|
||||||
|
{language.t(
|
||||||
|
provider.id === "opencode"
|
||||||
|
? "dialog.provider.opencode.tagline"
|
||||||
|
: "dialog.provider.opencodeGo.tagline",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="col-span-full flex h-8 w-full scroll-my-3.5 items-center justify-start rounded-md px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:var(--v2-font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
|
||||||
|
onClick={() => openProviders()}
|
||||||
|
>
|
||||||
|
{language.t("dialog.model.unpaid.viewMoreProviders")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogBody>
|
||||||
|
</DialogV2>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -22,17 +22,16 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
||||||
const providers = useProviders(directory)
|
const providers = useProviders(directory)
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const connect = (provider: string) => {
|
const openProviders = (provider?: string) => {
|
||||||
void import("./dialog-connect-provider").then((x) => {
|
void import("./dialog-connect-provider").then((x) => {
|
||||||
dialog.show(() => <x.DialogConnectProvider provider={provider} directory={directory} />)
|
const controller = x.useProviderConnectController()
|
||||||
|
controller.select(provider)
|
||||||
|
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const all = () => {
|
const connect = (provider: string) => openProviders(provider)
|
||||||
void import("./dialog-select-provider").then((x) => {
|
const all = () => openProviders()
|
||||||
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
let listRef: ListRef | undefined
|
let listRef: ListRef | undefined
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,5 @@
|
||||||
import { Popover as Kobalte } from "@kobalte/core/popover"
|
import { Popover as Kobalte } from "@kobalte/core/popover"
|
||||||
import {
|
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
|
||||||
Component,
|
|
||||||
ComponentProps,
|
|
||||||
createEffect,
|
|
||||||
createMemo,
|
|
||||||
For,
|
|
||||||
JSX,
|
|
||||||
onCleanup,
|
|
||||||
Show,
|
|
||||||
ValidComponent,
|
|
||||||
} from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
|
@ -24,10 +14,12 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||||
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
|
import { Tag as TagV2 } from "@opencode-ai/ui/v2/badge-v2"
|
||||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||||
|
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
import { handleDocumentSearchKeydown } from "@/utils/search-keydown"
|
||||||
|
import { createMenuDismissController } from "@/utils/menu-dismiss-controller"
|
||||||
import { createEventListener } from "@solid-primitives/event-listener"
|
import { createEventListener } from "@solid-primitives/event-listener"
|
||||||
import { matchesModelSearch } from "./dialog-select-model-search"
|
import { matchesModelSearch } from "./dialog-select-model-search"
|
||||||
|
|
||||||
|
|
@ -92,6 +84,7 @@ const ModelList: Component<{
|
||||||
class="w-full"
|
class="w-full"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
gutter={12}
|
gutter={12}
|
||||||
|
openDelay={0}
|
||||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
||||||
>
|
>
|
||||||
{node}
|
{node}
|
||||||
|
|
@ -120,14 +113,13 @@ const ModelList: Component<{
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
||||||
|
type ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => JSX.Element
|
||||||
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
||||||
|
|
||||||
export function ModelSelectorPopover(props: {
|
export function ModelSelectorPopover(props: {
|
||||||
provider?: string
|
provider?: string
|
||||||
model?: ModelState
|
model?: ModelState
|
||||||
children?: JSX.Element
|
trigger: ModelSelectorTrigger
|
||||||
triggerAs?: ValidComponent
|
|
||||||
triggerProps?: ModelSelectorTriggerProps
|
|
||||||
onClose?: (cause: "escape" | "select") => void
|
onClose?: (cause: "escape" | "select") => void
|
||||||
}) {
|
}) {
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
|
|
@ -155,8 +147,8 @@ export function ModelSelectorPopover(props: {
|
||||||
|
|
||||||
const handleConnectProvider = () => {
|
const handleConnectProvider = () => {
|
||||||
close("provider")
|
close("provider")
|
||||||
void import("./dialog-select-provider").then((x) => {
|
void import("./dialog-connect-provider").then((x) => {
|
||||||
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
|
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
@ -172,9 +164,7 @@ export function ModelSelectorPopover(props: {
|
||||||
placement="top-start"
|
placement="top-start"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
>
|
>
|
||||||
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
<Kobalte.Trigger as={props.trigger} />
|
||||||
{props.children}
|
|
||||||
</Kobalte.Trigger>
|
|
||||||
<Kobalte.Portal>
|
<Kobalte.Portal>
|
||||||
<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"
|
||||||
|
|
@ -235,66 +225,101 @@ export function ModelSelectorPopover(props: {
|
||||||
export function ModelSelectorPopoverV2(props: {
|
export function ModelSelectorPopoverV2(props: {
|
||||||
provider?: string
|
provider?: string
|
||||||
model?: ModelState
|
model?: ModelState
|
||||||
children?: JSX.Element
|
trigger: ModelSelectorTrigger
|
||||||
triggerAs?: ValidComponent
|
|
||||||
triggerProps?: ModelSelectorTriggerProps
|
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
}) {
|
}) {
|
||||||
const model = props.model ?? useLocal().model
|
|
||||||
const language = useLanguage()
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
const controller = createModelSelectorController({
|
||||||
let searchRef: HTMLInputElement | undefined
|
model: props.model,
|
||||||
let contentRef: HTMLDivElement | undefined
|
provider: () => props.provider,
|
||||||
let restoreTrigger = true
|
onSelect: () => props.onClose?.(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModelSelectorPopoverV2View
|
||||||
|
trigger={props.trigger}
|
||||||
|
models={controller.models}
|
||||||
|
groups={controller.groups}
|
||||||
|
current={controller.current}
|
||||||
|
select={controller.select}
|
||||||
|
onManage={() => {
|
||||||
|
void import("./dialog-manage-models").then((module) => {
|
||||||
|
void dialog.show(() => <module.DialogManageModelsV2 />)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onClose={() => props.onClose?.()}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createModelSelectorController(input: {
|
||||||
|
provider: () => string | undefined
|
||||||
|
model?: ModelState
|
||||||
|
onSelect: () => void
|
||||||
|
}) {
|
||||||
|
const model = input.model ?? useLocal().model
|
||||||
const allModels = createMemo(() =>
|
const allModels = createMemo(() =>
|
||||||
model
|
model
|
||||||
.list()
|
.list()
|
||||||
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
.filter((item) => model.visible({ modelID: item.id, providerID: item.provider.id }))
|
||||||
.filter((item) => (props.provider ? item.provider.id === props.provider : true)),
|
.filter((item) => (input.provider() ? item.provider.id === input.provider() : true)),
|
||||||
)
|
)
|
||||||
const models = createMemo(() => {
|
|
||||||
const search = store.search.trim()
|
|
||||||
const filtered = search
|
|
||||||
? allModels().filter((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
|
||||||
: allModels()
|
|
||||||
|
|
||||||
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
return {
|
||||||
})
|
models: (search: string) => {
|
||||||
const groups = createMemo(() => {
|
const query = search.trim()
|
||||||
const byProvider = new Map<string, ModelItem[]>()
|
const filtered = query
|
||||||
for (const item of models()) {
|
? allModels().filter((item) => matchesModelSearch(query, [item.name, item.id, item.provider.name]))
|
||||||
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
: allModels()
|
||||||
}
|
return [...filtered].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
},
|
||||||
})
|
groups: (models: ModelItem[]) => {
|
||||||
const keys = () => [...models().map(modelKey), manageKey]
|
const byProvider = new Map<string, ModelItem[]>()
|
||||||
const current = () => {
|
for (const item of models) {
|
||||||
const value = model.current()
|
byProvider.set(item.provider.id, [...(byProvider.get(item.provider.id) ?? []), item])
|
||||||
return value ? `${value.provider.id}:${value.id}` : undefined
|
}
|
||||||
|
return Array.from(byProvider, ([category, items]) => ({ category, items })).sort(sortModelGroups)
|
||||||
|
},
|
||||||
|
current: () => {
|
||||||
|
const value = model.current()
|
||||||
|
return value ? modelKey(value) : undefined
|
||||||
|
},
|
||||||
|
select: (item: ModelItem) => {
|
||||||
|
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
||||||
|
input.onSelect()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelSelectorPopoverV2View(props: {
|
||||||
|
trigger: ModelSelectorTrigger
|
||||||
|
models: (search: string) => ModelItem[]
|
||||||
|
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
||||||
|
current: () => string | undefined
|
||||||
|
select: (item: ModelItem) => void
|
||||||
|
onManage: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const [store, setStore] = createStore({ open: false, search: "", active: "" })
|
||||||
|
let searchRef: HTMLInputElement | undefined
|
||||||
|
let contentRef: HTMLDivElement | undefined
|
||||||
|
const dismiss = createMenuDismissController(() => contentRef)
|
||||||
|
|
||||||
|
const models = createMemo(() => props.models(store.search))
|
||||||
|
const groups = createMemo(() => props.groups(models()))
|
||||||
|
const keys = () => [...models().map(modelKey), manageKey]
|
||||||
const initialActive = () => {
|
const initialActive = () => {
|
||||||
const selected = current()
|
const selected = props.current()
|
||||||
const options = keys()
|
const options = keys()
|
||||||
if (selected && options.includes(selected)) return selected
|
if (selected && options.includes(selected)) return selected
|
||||||
return options[0] ?? ""
|
return options[0] ?? ""
|
||||||
}
|
}
|
||||||
const activeItem = () =>
|
const activeItem = () =>
|
||||||
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
|
store.active ? contentRef?.querySelector<HTMLElement>(`[data-option-key="${CSS.escape(store.active)}"]`) : undefined
|
||||||
const afterClose = (callback: () => void) => {
|
|
||||||
const complete = () => {
|
|
||||||
if (contentRef?.isConnected) {
|
|
||||||
requestAnimationFrame(complete)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requestAnimationFrame(() => requestAnimationFrame(callback))
|
|
||||||
}
|
|
||||||
requestAnimationFrame(complete)
|
|
||||||
}
|
|
||||||
const setOpen = (open: boolean) => {
|
const setOpen = (open: boolean) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
restoreTrigger = true
|
dismiss.allowTriggerRestore()
|
||||||
setStore({ open: true, active: initialActive() })
|
setStore({ open: true, active: initialActive() })
|
||||||
setTimeout(() =>
|
setTimeout(() =>
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
|
@ -306,23 +331,15 @@ export function ModelSelectorPopoverV2(props: {
|
||||||
}
|
}
|
||||||
setStore({ open: false, search: "", active: "" })
|
setStore({ open: false, search: "", active: "" })
|
||||||
}
|
}
|
||||||
const select = (item: ModelItem) => {
|
|
||||||
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
|
|
||||||
props.onClose?.()
|
|
||||||
}
|
|
||||||
const selectModel = (item: ModelItem) => {
|
const selectModel = (item: ModelItem) => {
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
afterClose(() => select(item))
|
dismiss.afterClose(() => props.select(item))
|
||||||
}
|
}
|
||||||
const manage = () => {
|
const manage = () => {
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
afterClose(() => {
|
dismiss.afterClose(props.onManage)
|
||||||
void import("./dialog-manage-models").then((x) => {
|
|
||||||
dialog.show(() => <x.DialogManageModelsV2 />)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
const selectActive = () => {
|
const selectActive = () => {
|
||||||
const item = models().find((item) => modelKey(item) === store.active)
|
const item = models().find((item) => modelKey(item) === store.active)
|
||||||
|
|
@ -341,10 +358,7 @@ export function ModelSelectorPopoverV2(props: {
|
||||||
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
queueMicrotask(() => activeItem()?.scrollIntoView({ block: "nearest" }))
|
||||||
}
|
}
|
||||||
const setSearch = (value: string) => {
|
const setSearch = (value: string) => {
|
||||||
const search = value.trim()
|
const first = props.models(value)[0]
|
||||||
const first = [...allModels()]
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
.find((item) => matchesModelSearch(search, [item.name, item.id, item.provider.name]))
|
|
||||||
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
setStore({ search: value, active: first ? modelKey(first) : manageKey })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -360,18 +374,14 @@ export function ModelSelectorPopoverV2(props: {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
<MenuV2 open={store.open} modal={false} placement="top-start" gutter={6} onOpenChange={setOpen}>
|
||||||
<MenuV2.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
|
<MenuV2.Trigger as={props.trigger} />
|
||||||
{props.children}
|
|
||||||
</MenuV2.Trigger>
|
|
||||||
<MenuV2.Portal>
|
<MenuV2.Portal>
|
||||||
<MenuV2.Content
|
<MenuV2.Content
|
||||||
ref={(el: HTMLDivElement) => (contentRef = el)}
|
ref={(element: HTMLDivElement) => (contentRef = element)}
|
||||||
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||||
onPointerDownOutside={() => (restoreTrigger = false)}
|
onPointerDownOutside={dismiss.preventTriggerRestore}
|
||||||
onFocusOutside={() => (restoreTrigger = false)}
|
onFocusOutside={dismiss.preventTriggerRestore}
|
||||||
onCloseAutoFocus={(event) => {
|
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
||||||
if (!restoreTrigger) event.preventDefault()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-col p-0.5">
|
<div class="flex flex-col p-0.5">
|
||||||
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
|
||||||
|
|
@ -391,9 +401,9 @@ export function ModelSelectorPopoverV2(props: {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
restoreTrigger = false
|
dismiss.preventTriggerRestore()
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
afterClose(() => props.onClose?.())
|
dismiss.afterClose(props.onClose)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.altKey || event.metaKey) return
|
if (event.altKey || event.metaKey) return
|
||||||
|
|
@ -443,29 +453,44 @@ export function ModelSelectorPopoverV2(props: {
|
||||||
<MenuV2.GroupLabel class="gap-2 px-3">
|
<MenuV2.GroupLabel class="gap-2 px-3">
|
||||||
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
||||||
</MenuV2.GroupLabel>
|
</MenuV2.GroupLabel>
|
||||||
<MenuV2.RadioGroup value={current()}>
|
<MenuV2.RadioGroup value={props.current()}>
|
||||||
<For each={group.items}>
|
<For each={group.items}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<MenuV2.RadioItem
|
<TooltipV2
|
||||||
value={modelKey(item)}
|
class="w-full"
|
||||||
data-option-key={modelKey(item)}
|
placement="right-start"
|
||||||
data-selected-model={current() === modelKey(item) ? true : undefined}
|
gutter={6}
|
||||||
class="scroll-my-6"
|
openDelay={0}
|
||||||
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
value={
|
||||||
onMouseEnter={() => {
|
<ModelTooltip
|
||||||
setStore("active", modelKey(item))
|
model={item}
|
||||||
setTimeout(() => searchRef?.focus())
|
latest={item.latest}
|
||||||
}}
|
free={isFree(item.provider.id, item.cost)}
|
||||||
onSelect={() => selectModel(item)}
|
v2
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span class="min-w-0 truncate">{item.name}</span>
|
<MenuV2.RadioItem
|
||||||
<Show when={isFree(item.provider.id, item.cost)}>
|
value={modelKey(item)}
|
||||||
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
|
data-option-key={modelKey(item)}
|
||||||
</Show>
|
data-selected-model={props.current() === modelKey(item) ? true : undefined}
|
||||||
<Show when={item.latest}>
|
class="scroll-my-6 w-full"
|
||||||
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
|
classList={{ "!bg-v2-overlay-simple-overlay-hover": store.active === modelKey(item) }}
|
||||||
</Show>
|
onMouseEnter={() => {
|
||||||
</MenuV2.RadioItem>
|
setStore("active", modelKey(item))
|
||||||
|
setTimeout(() => searchRef?.focus())
|
||||||
|
}}
|
||||||
|
onSelect={() => selectModel(item)}
|
||||||
|
>
|
||||||
|
<span class="min-w-0 truncate leading-5">{item.name}</span>
|
||||||
|
<Show when={isFree(item.provider.id, item.cost)}>
|
||||||
|
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
|
||||||
|
</Show>
|
||||||
|
<Show when={item.latest}>
|
||||||
|
<TagV2 class="shrink-0">{language.t("model.tag.latest")}</TagV2>
|
||||||
|
</Show>
|
||||||
|
</MenuV2.RadioItem>
|
||||||
|
</TooltipV2>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</MenuV2.RadioGroup>
|
</MenuV2.RadioGroup>
|
||||||
|
|
@ -503,8 +528,8 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
|
||||||
const directory = () => decode64(local.slug())
|
const directory = () => decode64(local.slug())
|
||||||
|
|
||||||
const provider = () => {
|
const provider = () => {
|
||||||
void import("./dialog-select-provider").then((x) => {
|
void import("./dialog-connect-provider").then((x) => {
|
||||||
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
|
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
import { type Accessor, Component, Show } from "solid-js"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
|
||||||
import { List } from "@opencode-ai/ui/list"
|
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|
||||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
|
||||||
|
|
||||||
const CUSTOM_ID = "_custom"
|
|
||||||
|
|
||||||
export const DialogSelectProvider: Component<{ directory?: Accessor<string | undefined> }> = (props) => {
|
|
||||||
const dialog = useDialog()
|
|
||||||
const providers = useProviders(props.directory)
|
|
||||||
const language = useLanguage()
|
|
||||||
|
|
||||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
|
||||||
const otherGroup = () => language.t("dialog.provider.group.other")
|
|
||||||
const customLabel = () => language.t("settings.providers.tag.custom")
|
|
||||||
const note = (id: string) => {
|
|
||||||
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
|
|
||||||
if (id === "openai") return language.t("dialog.provider.openai.note")
|
|
||||||
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
|
|
||||||
if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline")
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog title={language.t("command.provider.connect")} transition>
|
|
||||||
<List
|
|
||||||
class="px-3"
|
|
||||||
search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
|
|
||||||
emptyMessage={language.t("dialog.provider.empty")}
|
|
||||||
activeIcon="plus-small"
|
|
||||||
key={(x) => x?.id}
|
|
||||||
items={() => {
|
|
||||||
language.locale()
|
|
||||||
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
|
|
||||||
}}
|
|
||||||
filterKeys={["id", "name"]}
|
|
||||||
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
|
||||||
sortBy={(a, b) => {
|
|
||||||
if (a.id === CUSTOM_ID) return -1
|
|
||||||
if (b.id === CUSTOM_ID) return 1
|
|
||||||
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
|
|
||||||
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
|
||||||
return a.name.localeCompare(b.name)
|
|
||||||
}}
|
|
||||||
sortGroupsBy={(a, b) => {
|
|
||||||
const popular = popularGroup()
|
|
||||||
if (a.category === popular && b.category !== popular) return -1
|
|
||||||
if (b.category === popular && a.category !== popular) return 1
|
|
||||||
return 0
|
|
||||||
}}
|
|
||||||
onSelect={(x) => {
|
|
||||||
if (!x) return
|
|
||||||
if (x.id === CUSTOM_ID) {
|
|
||||||
dialog.show(() => <DialogCustomProvider back="providers" directory={props.directory} />)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dialog.show(() => <DialogConnectProvider provider={x.id} directory={props.directory} />)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(i) => (
|
|
||||||
<div class="px-1.25 w-full flex items-center gap-x-3">
|
|
||||||
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
|
|
||||||
<span>{i.name}</span>
|
|
||||||
<Show when={i.id === "opencode"}>
|
|
||||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
|
|
||||||
</Show>
|
|
||||||
<Show when={i.id === CUSTOM_ID}>
|
|
||||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
|
||||||
</Show>
|
|
||||||
<Show when={i.id === "opencode"}>
|
|
||||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
|
||||||
</Show>
|
|
||||||
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
|
|
||||||
<Show when={i.id === "opencode-go"}>
|
|
||||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</List>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { useGlobal } from "@/context/global"
|
||||||
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 { detectServerProtocol } from "@/utils/server-protocol"
|
||||||
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
|
|
@ -263,6 +264,13 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
||||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!settings.general.newLayoutDesigns() &&
|
||||||
|
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
||||||
|
) {
|
||||||
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
resetAdd()
|
resetAdd()
|
||||||
if (options.navigateOnAdd === false) {
|
if (options.navigateOnAdd === false) {
|
||||||
|
|
@ -307,6 +315,13 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
||||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!settings.general.newLayoutDesigns() &&
|
||||||
|
(await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2"
|
||||||
|
) {
|
||||||
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (normalized === input.original.http.url) {
|
if (normalized === input.original.http.url) {
|
||||||
server.add(conn)
|
server.add(conn)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -344,7 +359,10 @@ export function useServerManagementController(options: { onSelect?: () => void;
|
||||||
)
|
)
|
||||||
|
|
||||||
const sortedItems = createMemo(() => {
|
const sortedItems = createMemo(() => {
|
||||||
const list = items()
|
const raw = items()
|
||||||
|
const list = settings.general.newLayoutDesigns()
|
||||||
|
? raw
|
||||||
|
: raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2")
|
||||||
if (!list.length) return list
|
if (!list.length) return list
|
||||||
const active = current()
|
const active = current()
|
||||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,35 @@
|
||||||
import { Component } from "solid-js"
|
import { Component, createSignal, startTransition } from "solid-js"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { SettingsGeneral } from "./settings-general"
|
import { SettingsGeneral } from "./settings-general"
|
||||||
import { SettingsKeybinds } from "./settings-keybinds"
|
import { SettingsKeybinds } from "./settings-keybinds"
|
||||||
import { SettingsProviders } from "./settings-providers"
|
import { SettingsProviders } from "./settings-providers"
|
||||||
import { SettingsModels } from "./settings-models"
|
import { SettingsModels } from "./settings-models"
|
||||||
import { SettingsServers } from "./settings-servers"
|
import { SettingsServers } from "./settings-servers"
|
||||||
|
|
||||||
export const DialogSettings: Component = () => {
|
export const DialogSettings: Component<{ defaultValue?: string }> = (props) => {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const dialog = useDialog()
|
||||||
|
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
||||||
|
|
||||||
|
const showProviders = () => {
|
||||||
|
void dialog.show(() => <DialogSettings defaultValue="providers" />)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog size="x-large" transition>
|
<Dialog size="x-large" transition>
|
||||||
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
|
<Tabs
|
||||||
|
orientation="vertical"
|
||||||
|
variant="settings"
|
||||||
|
value={tab()}
|
||||||
|
onChange={(value) => void startTransition(() => setTab(value))}
|
||||||
|
class="h-full settings-dialog"
|
||||||
|
>
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<div class="flex flex-col justify-between h-full w-full gap-4">
|
<div class="flex flex-col justify-between h-full w-full gap-4">
|
||||||
<div class="flex flex-col gap-3 w-full pt-3">
|
<div class="flex flex-col gap-3 w-full pt-3">
|
||||||
|
|
@ -70,7 +83,7 @@ export const DialogSettings: Component = () => {
|
||||||
<SettingsServers />
|
<SettingsServers />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value="providers" class="no-scrollbar">
|
<Tabs.Content value="providers" class="no-scrollbar">
|
||||||
<SettingsProviders />
|
<SettingsProviders onBack={showProviders} />
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value="models" class="no-scrollbar">
|
<Tabs.Content value="models" class="no-scrollbar">
|
||||||
<SettingsModels />
|
<SettingsModels />
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
|
||||||
const runAction = () => {
|
const runAction = () => {
|
||||||
if (props.link) platform.openLink(props.link)
|
if (props.link) platform.openExternal(props.link)
|
||||||
props.onClose?.()
|
props.onClose?.()
|
||||||
dialog.close()
|
dialog.close()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -133,10 +133,10 @@ test("scopes file autocomplete to the current browser root", () => {
|
||||||
test("resolves directory autocomplete from the current browser root", async () => {
|
test("resolves directory autocomplete from the current browser root", async () => {
|
||||||
const directories: string[] = []
|
const directories: string[] = []
|
||||||
const sdk = {
|
const sdk = {
|
||||||
client: {
|
api: {
|
||||||
find: {
|
file: {
|
||||||
files: (input: { directory: string }) => {
|
find: (input: { location?: { directory?: string } }) => {
|
||||||
directories.push(input.directory)
|
directories.push(input.location?.directory ?? "")
|
||||||
return Promise.resolve({ data: [] })
|
return Promise.resolve({ data: [] })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -152,6 +152,29 @@ test("resolves directory autocomplete from the current browser root", async () =
|
||||||
expect(directories).toEqual(["/repo", "/repo/src"])
|
expect(directories).toEqual(["/repo", "/repo/src"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("searches from an absolute root without a default base", async () => {
|
||||||
|
const directories: string[] = []
|
||||||
|
const sdk = {
|
||||||
|
api: {
|
||||||
|
file: {
|
||||||
|
list: (input: { location?: { directory?: string } }) => {
|
||||||
|
directories.push(input.location?.directory ?? "")
|
||||||
|
return Promise.resolve({
|
||||||
|
data: [
|
||||||
|
{ path: "Users/", type: "directory" },
|
||||||
|
{ path: "tmp/", type: "directory" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
|
||||||
|
const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined })
|
||||||
|
|
||||||
|
expect(await search("/")).toEqual(["/Users", "/tmp"])
|
||||||
|
expect(directories).toEqual(["/"])
|
||||||
|
})
|
||||||
|
|
||||||
test("identifies the next directory level to preload", () => {
|
test("identifies the next directory level to preload", () => {
|
||||||
expect(
|
expect(
|
||||||
preloadTreeDirectories("src/", [
|
preloadTreeDirectories("src/", [
|
||||||
|
|
|
||||||
|
|
@ -326,15 +326,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||||
let current = 0
|
let current = 0
|
||||||
|
|
||||||
const scoped = (value: string) => {
|
const scoped = (value: string) => {
|
||||||
|
const raw = normalizePickerDrive(value)
|
||||||
|
const root = pickerRoot(raw)
|
||||||
|
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
|
||||||
const base = args.base()
|
const base = args.base()
|
||||||
if (!base) return
|
if (!base) return
|
||||||
const raw = normalizePickerDrive(value)
|
|
||||||
if (!raw) return { directory: trimPickerPath(base), path: "" }
|
if (!raw) return { directory: trimPickerPath(base), path: "" }
|
||||||
const home = args.home()
|
const home = args.home()
|
||||||
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
|
if (raw === "~") return { directory: trimPickerPath(home || base), path: "" }
|
||||||
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
|
if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) }
|
||||||
const root = pickerRoot(raw)
|
|
||||||
if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) }
|
|
||||||
return { directory: trimPickerPath(base), path: raw }
|
return { directory: trimPickerPath(base), path: raw }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -342,14 +342,17 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||||
const key = trimPickerPath(directory)
|
const key = trimPickerPath(directory)
|
||||||
const existing = cache.get(key)
|
const existing = cache.get(key)
|
||||||
if (existing) return existing
|
if (existing) return existing
|
||||||
const request = args.sdk.client.file
|
const request = args.sdk.api.file
|
||||||
.list({ directory: key, path: "" })
|
.list({ location: { directory: key } })
|
||||||
.then((result) => result.data ?? [])
|
.then((result) => result.data)
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
.then((nodes) =>
|
.then((nodes) =>
|
||||||
nodes
|
nodes
|
||||||
.filter((node) => node.type === "directory")
|
.filter((node) => node.type === "directory")
|
||||||
.map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })),
|
.map((node) => {
|
||||||
|
const relative = trimPickerPath(normalizePickerDrive(node.path))
|
||||||
|
return { name: getFilename(relative), absolute: joinPickerPath(key, relative) }
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
cache.set(key, request)
|
cache.set(key, request)
|
||||||
return request
|
return request
|
||||||
|
|
@ -371,9 +374,9 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
|
||||||
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/")
|
||||||
const query = normalizePickerDrive(input.path)
|
const query = normalizePickerDrive(input.path)
|
||||||
if (!pathInput) {
|
if (!pathInput) {
|
||||||
const results = await args.sdk.client.find
|
const results = await args.sdk.api.file
|
||||||
.files({ directory: input.directory, query, type: "directory", limit: 50 })
|
.find({ location: { directory: input.directory }, query, type: "directory", limit: 50 })
|
||||||
.then((result) => result.data ?? [])
|
.then((result) => result.data.map((entry) => entry.path))
|
||||||
.catch(() => [])
|
.catch(() => [])
|
||||||
if (!active()) return []
|
if (!active()) return []
|
||||||
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
|
||||||
|
|
|
||||||
133
packages/app/src/components/edit-project.ts
Normal file
133
packages/app/src/components/edit-project.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
|
import { normalizeProjectInfo } from "@/context/global-sync/utils"
|
||||||
|
import { createMemo } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { useGlobal } from "@/context/global"
|
||||||
|
import { type LocalProject } from "@/context/layout"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
|
||||||
|
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const global = useGlobal()
|
||||||
|
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
|
||||||
|
const folderName = createMemo(() => getFilename(props.project.worktree))
|
||||||
|
const defaultName = createMemo(() => props.project.name || folderName())
|
||||||
|
const [store, setStore] = createStore({
|
||||||
|
name: defaultName(),
|
||||||
|
color: props.project.icon?.color,
|
||||||
|
iconOverride: props.project.icon?.override,
|
||||||
|
startup: props.project.commands?.start ?? "",
|
||||||
|
dragOver: false,
|
||||||
|
iconHover: false,
|
||||||
|
})
|
||||||
|
let iconInput: HTMLInputElement | undefined
|
||||||
|
|
||||||
|
function selectFile(file: File) {
|
||||||
|
if (!file.type.startsWith("image/")) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (event) => {
|
||||||
|
const result = event.target?.result
|
||||||
|
if (typeof result !== "string") return
|
||||||
|
setStore("iconOverride", result)
|
||||||
|
setStore("iconHover", false)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function drop(event: DragEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setStore("dragOver", false)
|
||||||
|
const file = event.dataTransfer?.files[0]
|
||||||
|
if (file) selectFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragOver(event: DragEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
setStore("dragOver", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragLeave() {
|
||||||
|
setStore("dragOver", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputChange(event: Event) {
|
||||||
|
const file = (event.currentTarget as HTMLInputElement).files?.[0]
|
||||||
|
if (file) selectFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function iconClick() {
|
||||||
|
if (store.iconOverride && store.iconHover) {
|
||||||
|
setStore("iconOverride", "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
iconInput?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = useMutation(() => ({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||||
|
const start = store.startup.trim()
|
||||||
|
|
||||||
|
if (props.project.id && props.project.id !== "global") {
|
||||||
|
if ((await serverCtx().sdk.protocol) !== "v1") return
|
||||||
|
const project = await serverCtx()
|
||||||
|
.sdk.client.project.update({
|
||||||
|
projectID: props.project.id,
|
||||||
|
directory: props.project.worktree,
|
||||||
|
name,
|
||||||
|
icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||||
|
commands: { start },
|
||||||
|
})
|
||||||
|
.then((result) => result.data)
|
||||||
|
if (!project) return
|
||||||
|
// const project = await serverCtx().sdk.api.project.update({
|
||||||
|
// projectID: props.project.id,
|
||||||
|
// name,
|
||||||
|
// icon: { color: store.color || "", override: store.iconOverride || "" },
|
||||||
|
// commands: { start },
|
||||||
|
// })
|
||||||
|
serverCtx().sync.set("project", (items) =>
|
||||||
|
items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)),
|
||||||
|
)
|
||||||
|
serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined)
|
||||||
|
dialog.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
serverCtx().sync.project.meta(props.project.worktree, {
|
||||||
|
name,
|
||||||
|
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
|
||||||
|
commands: { start: start || undefined },
|
||||||
|
})
|
||||||
|
dialog.close()
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (save.isPending) return
|
||||||
|
save.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
setStore,
|
||||||
|
folderName,
|
||||||
|
defaultName,
|
||||||
|
save,
|
||||||
|
submit,
|
||||||
|
drop,
|
||||||
|
dragOver,
|
||||||
|
dragLeave,
|
||||||
|
inputChange,
|
||||||
|
iconClick,
|
||||||
|
close() {
|
||||||
|
dialog.close()
|
||||||
|
},
|
||||||
|
setIconInput(input: HTMLInputElement) {
|
||||||
|
iconInput = input
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
21
packages/app/src/components/external-link.tsx
Normal file
21
packages/app/src/components/external-link.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { ComponentProps, splitProps } from "solid-js"
|
||||||
|
|
||||||
|
export interface ExternalLinkProps extends Omit<ComponentProps<"a">, "href"> {
|
||||||
|
href: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExternalLink(props: ExternalLinkProps) {
|
||||||
|
const [local, rest] = splitProps(props, ["href", "children", "class", "target", "rel"])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={local.href}
|
||||||
|
class={`text-text-strong underline ${local.class ?? ""}`}
|
||||||
|
target={local.target ?? "_blank"}
|
||||||
|
rel={local.rel ?? "noopener noreferrer"}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{local.children}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
packages/app/src/components/file-tree-v2-model.test.ts
Normal file
74
packages/app/src/components/file-tree-v2-model.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { buildFileTreeV2Model, flattenFileTreeV2, flattenLiveFileTreeV2 } from "./file-tree-v2-model"
|
||||||
|
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
|
describe("buildFileTreeV2Model", () => {
|
||||||
|
test("builds a sorted tree and flattens expanded directories", () => {
|
||||||
|
const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"])
|
||||||
|
|
||||||
|
expect(model.total).toBe(8)
|
||||||
|
expect(flattenFileTreeV2(model, () => true).map((row) => [row.node.path, row.node.type, row.level])).toEqual([
|
||||||
|
["docs", "directory", 0],
|
||||||
|
["docs/guide.md", "file", 1],
|
||||||
|
["src", "directory", 0],
|
||||||
|
["src/lib", "directory", 1],
|
||||||
|
["src/lib/a.ts", "file", 2],
|
||||||
|
["src/lib/b.ts", "file", 2],
|
||||||
|
["src/z.ts", "file", 1],
|
||||||
|
["README.md", "file", 0],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("skips children of collapsed directories", () => {
|
||||||
|
const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"])
|
||||||
|
|
||||||
|
expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([
|
||||||
|
"src",
|
||||||
|
"src/lib",
|
||||||
|
"src/z.ts",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("normalizes duplicate and messy paths", () => {
|
||||||
|
const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"])
|
||||||
|
const rows = flattenFileTreeV2(model, () => true)
|
||||||
|
|
||||||
|
expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"])
|
||||||
|
expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles deeply nested paths", () => {
|
||||||
|
const file = Array.from({ length: 130 }, (_, index) => `d${index}`).join("/") + "/leaf.ts"
|
||||||
|
const model = buildFileTreeV2Model([file])
|
||||||
|
|
||||||
|
expect(flattenFileTreeV2(model, () => true)).toHaveLength(131)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("flattenLiveFileTreeV2", () => {
|
||||||
|
test("flattens live children using original paths for nested lookups", () => {
|
||||||
|
const nodes: Record<string, FileNode[]> = {
|
||||||
|
"": [
|
||||||
|
{ name: "src", path: "src", absolute: "/repo/src", type: "directory", ignored: false },
|
||||||
|
{ name: "README.md", path: "README.md", absolute: "/repo/README.md", type: "file", ignored: false },
|
||||||
|
],
|
||||||
|
src: [
|
||||||
|
{ name: "a.ts", path: "src/a.ts", absolute: "/repo/src/a.ts", type: "file", ignored: false },
|
||||||
|
{ name: "lib", path: "src/lib", absolute: "/repo/src/lib", type: "directory", ignored: false },
|
||||||
|
],
|
||||||
|
"src/lib": [{ name: "b.ts", path: "src/lib/b.ts", absolute: "/repo/src/lib/b.ts", type: "file", ignored: false }],
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
flattenLiveFileTreeV2(
|
||||||
|
(path) => nodes[path] ?? [],
|
||||||
|
(path) => path === "src",
|
||||||
|
).map((row) => [row.node.path, row.node.originalPath, row.level]),
|
||||||
|
).toEqual([
|
||||||
|
["src", "src", 0],
|
||||||
|
["src/a.ts", "src/a.ts", 1],
|
||||||
|
["src/lib", "src/lib", 1],
|
||||||
|
["README.md", "README.md", 0],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
107
packages/app/src/components/file-tree-v2-model.ts
Normal file
107
packages/app/src/components/file-tree-v2-model.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
|
export type FileTreeV2Model = {
|
||||||
|
children: ReadonlyMap<string, readonly FileTreeV2Node[]>
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileTreeV2Node = FileNode & { originalPath: string }
|
||||||
|
|
||||||
|
export type FileTreeV2Row = {
|
||||||
|
node: FileTreeV2Node
|
||||||
|
level: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFileTreeV2Path(value: string) {
|
||||||
|
return value
|
||||||
|
.replaceAll("\\", "/")
|
||||||
|
.replace(/^\/+|\/+$/g, "")
|
||||||
|
.replace(/\/{2,}/g, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFileTreeV2Model(paths: readonly string[]): FileTreeV2Model {
|
||||||
|
const nodes = new Map<string, FileTreeV2Node>()
|
||||||
|
|
||||||
|
paths.forEach((value) => {
|
||||||
|
const file = normalizeFileTreeV2Path(value)
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
const parts = file.split("/")
|
||||||
|
parts.forEach((name, index) => {
|
||||||
|
const path = parts.slice(0, index + 1).join("/")
|
||||||
|
if (nodes.has(path)) return
|
||||||
|
nodes.set(path, {
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
absolute: path,
|
||||||
|
type: index === parts.length - 1 ? "file" : "directory",
|
||||||
|
ignored: false,
|
||||||
|
originalPath: index === parts.length - 1 ? value : path,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const children = new Map<string, FileTreeV2Node[]>()
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
const index = node.path.lastIndexOf("/")
|
||||||
|
const parent = index === -1 ? "" : node.path.slice(0, index)
|
||||||
|
const list = children.get(parent)
|
||||||
|
if (list) list.push(node)
|
||||||
|
else children.set(parent, [node])
|
||||||
|
})
|
||||||
|
children.forEach((nodes) =>
|
||||||
|
nodes.sort((a, b) => {
|
||||||
|
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
||||||
|
return a.name.localeCompare(b.name)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { children, total: nodes.size }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: string) => boolean) {
|
||||||
|
const rows: FileTreeV2Row[] = []
|
||||||
|
const stack = (model.children.get("") ?? []).toReversed().map((node) => ({ node, level: 0 }))
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const row = stack.pop()!
|
||||||
|
rows.push(row)
|
||||||
|
if (row.node.type !== "directory" || !expanded(row.node.path)) continue
|
||||||
|
const children = model.children.get(row.node.path) ?? []
|
||||||
|
for (let index = children.length - 1; index >= 0; index--) {
|
||||||
|
stack.push({ node: children[index]!, level: row.level + 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenLiveFileTreeV2(
|
||||||
|
children: (path: string) => readonly FileNode[],
|
||||||
|
expanded: (path: string) => boolean,
|
||||||
|
) {
|
||||||
|
const rows: FileTreeV2Row[] = []
|
||||||
|
const stack = children("")
|
||||||
|
.toReversed()
|
||||||
|
.map((node) => ({ node: toLiveNode(node), level: 0 }))
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const row = stack.pop()!
|
||||||
|
rows.push(row)
|
||||||
|
if (row.node.type !== "directory" || !expanded(row.node.path)) continue
|
||||||
|
const nested = children(row.node.originalPath)
|
||||||
|
for (let index = nested.length - 1; index >= 0; index--) {
|
||||||
|
stack.push({ node: toLiveNode(nested[index]!), level: row.level + 1 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLiveNode(node: FileNode): FileTreeV2Node {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
path: normalizeFileTreeV2Path(node.path),
|
||||||
|
originalPath: node.path,
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue