Compare commits
489
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a855229052 | ||
|
|
ba244a6e62 | ||
|
|
a638728201 | ||
|
|
7cb690d7e5 | ||
|
|
31ad6e85ba | ||
|
|
ea04b23745 | ||
|
|
05c3cfb2aa | ||
|
|
f54e4b60cc | ||
|
|
97c15a087d | ||
|
|
b90de755f9 | ||
|
|
8864fdce2f | ||
|
|
5179b87aef | ||
|
|
66a56551be | ||
|
|
7123aad5a8 | ||
|
|
d6fc5f414b | ||
|
|
77fc88c8ad | ||
|
|
cafc2b204b | ||
|
|
36709aae5f | ||
|
|
fac0dd8862 | ||
|
|
73e107250d | ||
|
|
b746aec493 | ||
|
|
ad40b65b0b | ||
|
|
971383661a | ||
|
|
b0017bf1b9 | ||
|
|
0c0c6f3bdb | ||
|
|
b480a38d31 | ||
|
|
4167e25c7e | ||
|
|
1041ae91d1 | ||
|
|
898456a25c | ||
|
|
53d0b58ebf | ||
|
|
2b0baf97bd | ||
|
|
0dbfefa080 | ||
|
|
d1c49ba210 | ||
|
|
3ea72aec21 | ||
|
|
9717383823 | ||
|
|
5d9e780029 | ||
|
|
aa11fa865d | ||
|
|
9a64bdb539 | ||
|
|
71693cc24b | ||
|
|
28cb8efa4b | ||
|
|
b0bd728275 | ||
|
|
700f57112a | ||
|
|
0a80ef4278 | ||
|
|
4f9667c4bb | ||
|
|
be142b00bd | ||
|
|
45c2573979 | ||
|
|
79e9d19019 | ||
|
|
958a80cc05 | ||
|
|
4647aa80ac | ||
|
|
a379eb3867 | ||
|
|
cbe1337f24 | ||
|
|
50f6aa3763 | ||
|
|
0dcdf5f529 | ||
|
|
4586b41ffd | ||
|
|
35884defd8 | ||
|
|
15dc33d1a3 | ||
|
|
1398674e53 | ||
|
|
afc4c831eb | ||
|
|
ec64ceabec | ||
|
|
56644be95a | ||
|
|
00d3b831fc | ||
|
|
b848b7ebae | ||
|
|
e837dcc1c5 | ||
|
|
024979f3fd | ||
|
|
bc608fb081 | ||
|
|
9838f56a6f | ||
|
|
98b3340cee | ||
|
|
5e684c6e80 | ||
|
|
2c1d8a90d5 | ||
|
|
8994cbfc0f | ||
|
|
42a773481e | ||
|
|
539b01f20f | ||
|
|
814a515a8a | ||
|
|
235a82aea9 | ||
|
|
9330bc5339 | ||
|
|
1238d1f61a | ||
|
|
1d3232b388 | ||
|
|
5c1bb5de86 | ||
|
|
7c5ed771c3 | ||
|
|
31c4a4fb47 | ||
|
|
037077285a | ||
|
|
41c77ccb33 | ||
|
|
546748a461 | ||
|
|
c9c93eac00 | ||
|
|
3f1a4abe6d | ||
|
|
431e0586ad | ||
|
|
fde201c286 | ||
|
|
d3debc191f | ||
|
|
34f43fff89 | ||
|
|
49623aa519 | ||
|
|
f1340472ec | ||
|
|
a8b28826a0 | ||
|
|
a03a2b6eab | ||
|
|
ad78b79b8a | ||
|
|
9a006d8700 | ||
|
|
3a0bf2f39f | ||
|
|
b556979634 | ||
|
|
691644eeeb | ||
|
|
4aebaaf067 | ||
|
|
77b3b46788 | ||
|
|
36dfe1646b | ||
|
|
6926dc26d1 | ||
|
|
eb74e4a6d2 | ||
|
|
85d8e143bf | ||
|
|
8e1b53b32c | ||
|
|
0a7dfc03ee | ||
|
|
4c27e7fc64 | ||
|
|
0f5626d2e4 | ||
|
|
5ea95451dd | ||
|
|
9239d877b9 | ||
|
|
fc68c24433 | ||
|
|
db9619dad6 | ||
|
|
84d9b38873 | ||
|
|
8035c3435b | ||
|
|
71e7603d71 | ||
|
|
40e49c5b49 | ||
|
|
afe9b97274 | ||
|
|
3b3549902d | ||
|
|
e9a9c75c1f | ||
|
|
2b171828b0 | ||
|
|
8dd817023a | ||
|
|
0d6c601365 | ||
|
|
5460bf9989 | ||
|
|
eb3bfffad4 | ||
|
|
e2d03ce38c | ||
|
|
32f9dc6383 | ||
|
|
c529529f84 | ||
|
|
13bac9c91a | ||
|
|
fe53af4819 | ||
|
|
e82c5a9a28 | ||
|
|
3236f228fb | ||
|
|
0e0e7a4a4b | ||
|
|
10a3d6c54e | ||
|
|
832b8e252e | ||
|
|
040f551c57 | ||
|
|
cc818f8032 | ||
|
|
d5337b41f4 | ||
|
|
9f7a76d6c0 | ||
|
|
6a16db4b92 | ||
|
|
9ad6588f3e | ||
|
|
fb6bf0b35e | ||
|
|
f80343b875 | ||
|
|
9b805e1cc4 | ||
|
|
2e0d5d2308 | ||
|
|
38e0dc9ccd | ||
|
|
40aeaa120d | ||
|
|
6a64177589 | ||
|
|
5dc47905a9 | ||
|
|
dc0044882c | ||
|
|
45ae7dc653 | ||
|
|
129fe1e350 | ||
|
|
214a6c6cf1 | ||
|
|
3f249aba6d | ||
|
|
5c6ec1caac | ||
|
|
24f9df5463 | ||
|
|
12b8e1c2be | ||
|
|
d70099b059 | ||
|
|
ce845a0b1b | ||
|
|
05d3e65f76 | ||
|
|
51618e9cef | ||
|
|
e78944e9a4 | ||
|
|
bfdc38e421 | ||
|
|
83023e4f0f | ||
|
|
d0a57305ef | ||
|
|
27a70ad70f | ||
|
|
0bbf26a1ce | ||
|
|
83cdb4de64 | ||
|
|
4989632245 | ||
|
|
d460614cd7 | ||
|
|
7866dbcfcc | ||
|
|
e71a21e0a8 | ||
|
|
1071aca91f | ||
|
|
b3d0446d13 | ||
|
|
949191ab74 | ||
|
|
92cd908fb5 | ||
|
|
6fcc970def | ||
|
|
52a7a04ad8 | ||
|
|
37b8662a9d | ||
|
|
ddcb32ae0b | ||
|
|
2c056c90da | ||
|
|
812d1bb32a | ||
|
|
9a58c43ef4 | ||
|
|
63585db6a7 | ||
|
|
bd44489ada | ||
|
|
a6ef9e9206 | ||
|
|
6e09a1d904 | ||
|
|
4f21757e0d | ||
|
|
2dbcd79fd2 | ||
|
|
48a7f0fd93 | ||
|
|
d69962b0f7 | ||
|
|
a6f23cb08e | ||
|
|
0540751897 | ||
|
|
baa204193c | ||
|
|
aeece6166b | ||
|
|
0d7e62a532 | ||
|
|
41aa254db4 | ||
|
|
d178d8249f | ||
|
|
e6f5214779 | ||
|
|
84f60d97a0 | ||
|
|
cbf4b68fee | ||
|
|
bd4527b4f2 | ||
|
|
f4a9fe29a3 | ||
|
|
5a0bfa7061 | ||
|
|
1ac1a0287c | ||
|
|
8e09e8c612 | ||
|
|
84e62fc662 | ||
|
|
a7ea93528b | ||
|
|
d90e3a2833 | ||
|
|
1c74c2741a | ||
|
|
5d2f8d77f9 | ||
|
|
81be544981 | ||
|
|
773c1192dc | ||
|
|
5ddfe4ada5 | ||
|
|
a93d98bd94 | ||
|
|
54ed87d53c | ||
|
|
8ee939c741 | ||
|
|
1b0096bf61 | ||
|
|
8006c29db3 | ||
|
|
3f1c96a0bb | ||
|
|
3558deba4a | ||
|
|
c3ddc85cca | ||
|
|
a800583aea | ||
|
|
171e69c2fc | ||
|
|
822bb7b336 | ||
|
|
47cf267c23 | ||
|
|
976aae7e42 | ||
|
|
0ca51eebcf | ||
|
|
3256886e25 | ||
|
|
d2194f6dde | ||
|
|
bfd4787fcd | ||
|
|
58dce0148a | ||
|
|
79635b8b41 | ||
|
|
331dacf9db | ||
|
|
4ba7d3b406 | ||
|
|
a43783a6d4 | ||
|
|
37c5295111 | ||
|
|
56102ff642 | ||
|
|
1b86c27fb8 | ||
|
|
fe43bdb699 | ||
|
|
a849a17e93 | ||
|
|
0292f1b559 | ||
|
|
5dfe86dcb1 | ||
|
|
4b4dd2b882 | ||
|
|
bc949af623 | ||
|
|
9e7c136de7 | ||
|
|
fee3c196c5 | ||
|
|
6c047391bb | ||
|
|
350df0b261 | ||
|
|
fbabc97c4c | ||
|
|
7daea69e13 | ||
|
|
0772a95918 | ||
|
|
dadddc9c8c | ||
|
|
6708c3f6cf | ||
|
|
ba22976568 | ||
|
|
0afeaea21f | ||
|
|
b07b5a9b7f | ||
|
|
dbbe931a18 | ||
|
|
e14e874e51 | ||
|
|
544315dff7 | ||
|
|
f13da808ff | ||
|
|
e416e59ea6 | ||
|
|
cb69501098 | ||
|
|
a64f604d54 | ||
|
|
d7093abf61 | ||
|
|
60af447908 | ||
|
|
1cdc558ac0 | ||
|
|
3849822769 | ||
|
|
e9a17e4480 | ||
|
|
68809365df | ||
|
|
8da511dfa8 | ||
|
|
69381f6aea | ||
|
|
df6508530f | ||
|
|
335356280c | ||
|
|
03d84f49c2 | ||
|
|
2cbdf04ec9 | ||
|
|
410fbd8a00 | ||
|
|
e5cbecf17c | ||
|
|
ca3af5dc6a | ||
|
|
9e740d9947 | ||
|
|
d4694d058c | ||
|
|
469c3a4204 | ||
|
|
4cb29967f6 | ||
|
|
e718db624f | ||
|
|
15b27e0d18 | ||
|
|
c523aac586 | ||
|
|
51fcd04a70 | ||
|
|
4d7cbdcbef | ||
|
|
59c530cc6c | ||
|
|
c2ca1494e5 | ||
|
|
4ee426ba54 | ||
|
|
510374207d | ||
|
|
aedbecedf7 | ||
|
|
9c00669927 | ||
|
|
b9f6b40e3a | ||
|
|
ad06d8f496 | ||
|
|
2fc06c5a17 | ||
|
|
52877d8765 | ||
|
|
8f957b8f90 | ||
|
|
0befa1e57e | ||
|
|
f015154314 | ||
|
|
689d9e14ea | ||
|
|
66e8c57ed1 | ||
|
|
b698f14e55 | ||
|
|
cec1255b36 | ||
|
|
88226f3061 | ||
|
|
8c53b2b470 | ||
|
|
f2d3a4c70f | ||
|
|
4b9b86b544 | ||
|
|
f54abe58cf | ||
|
|
d954026dd8 | ||
|
|
4ad8116ce3 | ||
|
|
5c7088338c | ||
|
|
389daa03df | ||
|
|
1cbe7b0854 | ||
|
|
050d71bcf9 | ||
|
|
ffde837e83 | ||
|
|
536abea2e2 | ||
|
|
c7a52b6a2d | ||
|
|
c4ccb50c37 | ||
|
|
5aaf1ddfb7 | ||
|
|
f5f07310e0 | ||
|
|
c9e9dbeee1 | ||
|
|
b88b323049 | ||
|
|
6653f868ae | ||
|
|
af29d91dca | ||
|
|
1a3735b619 | ||
|
|
d4ae13f2a0 | ||
|
|
f4804dac85 | ||
|
|
843f188aaa | ||
|
|
05cb3c87ca | ||
|
|
270cb0b8b4 | ||
|
|
46ba9c8170 | ||
|
|
80f91d3fd9 | ||
|
|
a564231caf | ||
|
|
9457493696 | ||
|
|
ff748b82ca | ||
|
|
9fafa57562 | ||
|
|
f8475649da | ||
|
|
b94e110a4c | ||
|
|
f0bba10b12 | ||
|
|
d961981e25 | ||
|
|
5576662200 | ||
|
|
4a2a046d79 | ||
|
|
8f8c74cfb8 | ||
|
|
092f654f63 | ||
|
|
96b1d8f639 | ||
|
|
dcb17c6a67 | ||
|
|
dd68b85f58 | ||
|
|
84df96eaef | ||
|
|
d9dd33aeeb | ||
|
|
0a281c7390 | ||
|
|
3016efba47 | ||
|
|
3998df8112 | ||
|
|
7066e2a25e | ||
|
|
c173988aaa | ||
|
|
268855dc5a | ||
|
|
bfb736e94a | ||
|
|
df8464f89c | ||
|
|
3ea387f364 | ||
|
|
9d3c42c8c4 | ||
|
|
f2cad046e6 | ||
|
|
d722026a8d | ||
|
|
42a5af6c8f | ||
|
|
f0542fae7a | ||
|
|
02c75821a8 | ||
|
|
3ba9ab2c0a | ||
|
|
184732fc20 | ||
|
|
b66222baf7 | ||
|
|
dce7eceb28 | ||
|
|
0e077f7483 | ||
|
|
776e7a9c15 | ||
|
|
c455d41876 | ||
|
|
a776a3ee12 | ||
|
|
64fb9233bf | ||
|
|
3533f33ecb | ||
|
|
1cb7df7159 | ||
|
|
a4f8d66a9b | ||
|
|
12efbbfa4c | ||
|
|
13402529ce | ||
|
|
fc678ef36c | ||
|
|
03cd891ea9 | ||
|
|
6314d741e7 | ||
|
|
c45467964c | ||
|
|
2eeba53b07 | ||
|
|
d4107d51f1 | ||
|
|
d8fbe0af01 | ||
|
|
b76ead3fe8 | ||
|
|
51835ecf90 | ||
|
|
328c6de80d | ||
|
|
c9c0318e0e | ||
|
|
d481f64bde | ||
|
|
54e7baa6cf | ||
|
|
1d7fcd40b4 | ||
|
|
db7bafe917 | ||
|
|
b1ef501207 | ||
|
|
9fb12a906e | ||
|
|
fafbc29316 | ||
|
|
7b0def4b81 | ||
|
|
1d9c83b576 | ||
|
|
2c825c3223 | ||
|
|
2a4dedc210 | ||
|
|
b0bca6342e | ||
|
|
547eb7676d | ||
|
|
83f083ee0d | ||
|
|
090f636354 | ||
|
|
d26c6f80e1 | ||
|
|
16a6d6feba | ||
|
|
f1c3a44190 | ||
|
|
34fa5de9c5 | ||
|
|
cb67465675 | ||
|
|
4e73473119 | ||
|
|
cc18fa599c | ||
|
|
aa81c1c4cb | ||
|
|
8569fc1f0e | ||
|
|
78de287bcc | ||
|
|
bbc7052c7a | ||
|
|
502d6db6d0 | ||
|
|
0b0ad5de99 | ||
|
|
9e6c4a01aa | ||
|
|
4a81df190c | ||
|
|
75cae81f75 | ||
|
|
ed3bb3ea8f | ||
|
|
fac23a1afc | ||
|
|
f89696509e | ||
|
|
604ab1bde1 | ||
|
|
fbd9b7cf4f | ||
|
|
58f45ae22b | ||
|
|
440405dbdd | ||
|
|
a1cda29012 | ||
|
|
f96e2d4222 | ||
|
|
387ab78bf6 | ||
|
|
dbc00aa8e0 | ||
|
|
c37f7b9d99 | ||
|
|
cf7ca9b2f7 | ||
|
|
981c7b9e37 | ||
|
|
2aae0d3493 | ||
|
|
bcc0d19867 | ||
|
|
9c585bb58b | ||
|
|
0f6bc8ae71 | ||
|
|
7291e28273 | ||
|
|
db57fe6193 | ||
|
|
802416639b | ||
|
|
7ec398d855 | ||
|
|
4ab35d2c5c | ||
|
|
b4ae030fc2 | ||
|
|
0843964eb3 | ||
|
|
a1b06d63c9 | ||
|
|
1b6820bab5 | ||
|
|
89bf199c07 | ||
|
|
5acfdd1c5d | ||
|
|
556703f8ab | ||
|
|
6b9f8fb9b3 | ||
|
|
f77e5cf8fb | ||
|
|
e6cdc21f2d | ||
|
|
1fe8d4d7ad | ||
|
|
e44320980d | ||
|
|
f5d7fe3072 | ||
|
|
835a27cf51 | ||
|
|
85afaaa13d | ||
|
|
490615169e | ||
|
|
bb232247d0 | ||
|
|
94c128f73b | ||
|
|
613562f504 | ||
|
|
9c4325bcf8 | ||
|
|
ad08fd57df | ||
|
|
54ba59d3e1 | ||
|
|
a4330a225d | ||
|
|
69ddc91c35 | ||
|
|
4c4aed5a87 | ||
|
|
5a40158abf | ||
|
|
4dce485854 | ||
|
|
5ec5d1dace | ||
|
|
d2c765e2b3 | ||
|
|
d036c57d59 | ||
|
|
e7493e2204 | ||
|
|
3500bf64b8 | ||
|
|
4f982ddb94 | ||
|
|
ff3bb7424d | ||
|
|
89d6f60d25 | ||
|
|
ee18c9976e | ||
|
|
794532928f | ||
|
|
7b773c65ec | ||
|
|
e53aa79dc6 | ||
|
|
d9a97249c0 | ||
|
|
86cef16940 | ||
|
|
ce38997c76 | ||
|
|
7e10c728d4 | ||
|
|
3627c67cf2 | ||
|
|
2518fd81f6 |
@@ -10,6 +10,9 @@
|
|||||||
adamdotdevin
|
adamdotdevin
|
||||||
-agusbasari29 AI PR slop
|
-agusbasari29 AI PR slop
|
||||||
ariane-emory
|
ariane-emory
|
||||||
|
-atharvau AI review spamming literally every PR
|
||||||
|
-danieljoshuanazareth
|
||||||
|
-danieljoshuanazareth
|
||||||
edemaine
|
edemaine
|
||||||
-florianleibert
|
-florianleibert
|
||||||
fwang
|
fwang
|
||||||
@@ -17,7 +20,9 @@ iamdavidhill
|
|||||||
jayair
|
jayair
|
||||||
kitlangton
|
kitlangton
|
||||||
kommander
|
kommander
|
||||||
|
-opencode2026
|
||||||
r44vc0rp
|
r44vc0rp
|
||||||
rekram1-node
|
rekram1-node
|
||||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||||
thdxr
|
thdxr
|
||||||
|
-OpenCodeEngineer bot that spams issues
|
||||||
|
|||||||
@@ -3,14 +3,6 @@ description: "Setup Bun with caching and install dependencies"
|
|||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
- name: Cache Bun dependencies
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.bun/install/cache
|
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-bun-
|
|
||||||
|
|
||||||
- name: Get baseline download URL
|
- name: Get baseline download URL
|
||||||
id: bun-url
|
id: bun-url
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -31,6 +23,31 @@ runs:
|
|||||||
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
|
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
|
||||||
bun-download-url: ${{ steps.bun-url.outputs.url }}
|
bun-download-url: ${{ steps.bun-url.outputs.url }}
|
||||||
|
|
||||||
|
- name: Get cache directory
|
||||||
|
id: cache
|
||||||
|
shell: bash
|
||||||
|
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Cache Bun dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ steps.cache.outputs.dir }}
|
||||||
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
|
- name: Install setuptools for distutils compatibility
|
||||||
|
run: python3 -m pip install setuptools || pip install setuptools || true
|
||||||
|
shell: bash
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install
|
run: |
|
||||||
|
# Workaround for patched peer variants
|
||||||
|
# e.g. ./patches/ for standard-openapi
|
||||||
|
# https://github.com/oven-sh/bun/issues/28147
|
||||||
|
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||||
|
bun install --linker hoisted
|
||||||
|
else
|
||||||
|
bun install
|
||||||
|
fi
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
name: close-issues
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 2 * * *" # Daily at 2:00 AM
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
close:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Close stale issues
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
run: bun script/github/close-issues.ts
|
||||||
@@ -11,7 +11,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ jobs:
|
|||||||
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
|
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
|
||||||
|
|
||||||
# Extract hash from build log with portability
|
# Extract hash from build log with portability
|
||||||
HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
|
HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
|
||||||
|
|
||||||
if [ -z "$HASH" ]; then
|
if [ -z "$HASH" ]; then
|
||||||
echo "::error::Failed to compute hash for ${SYSTEM}"
|
echo "::error::Failed to compute hash for ${SYSTEM}"
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ jobs:
|
|||||||
tag: ${{ steps.version.outputs.tag }}
|
tag: ${{ steps.version.outputs.tag }}
|
||||||
repo: ${{ steps.version.outputs.repo }}
|
repo: ${{ steps.version.outputs.repo }}
|
||||||
|
|
||||||
build-cli:
|
build-cli-linux-win:
|
||||||
needs: version
|
needs: version
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
if: github.repository == 'anomalyco/opencode'
|
if: github.repository == 'anomalyco/opencode'
|
||||||
@@ -94,17 +94,74 @@ jobs:
|
|||||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
|
OPENCODE_BUILD_OS: linux,win32
|
||||||
|
OPENCODE_SKIP_RELEASE_UPLOAD: "1"
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: opencode-cli
|
name: opencode-cli-linux-win
|
||||||
|
path: packages/opencode/dist
|
||||||
|
|
||||||
|
build-cli-darwin:
|
||||||
|
needs: version
|
||||||
|
runs-on: macos-latest
|
||||||
|
if: github.repository == 'anomalyco/opencode'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-tags: true
|
||||||
|
|
||||||
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Setup git committer
|
||||||
|
id: committer
|
||||||
|
uses: ./.github/actions/setup-git-committer
|
||||||
|
with:
|
||||||
|
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||||
|
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||||
|
|
||||||
|
- uses: apple-actions/import-codesign-certs@v2
|
||||||
|
with:
|
||||||
|
keychain: build
|
||||||
|
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Resolve signing identity
|
||||||
|
run: |
|
||||||
|
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application")
|
||||||
|
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
|
||||||
|
if [ -z "$CERT_ID" ]; then
|
||||||
|
echo "Developer ID Application identity not found"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
id: build
|
||||||
|
run: |
|
||||||
|
./packages/opencode/script/build.ts
|
||||||
|
env:
|
||||||
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||||
|
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||||
|
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
|
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
|
||||||
|
OPENCODE_BUILD_OS: darwin
|
||||||
|
OPENCODE_SKIP_RELEASE_UPLOAD: "1"
|
||||||
|
|
||||||
|
- name: Verify darwin signatures
|
||||||
|
run: |
|
||||||
|
for file in packages/opencode/dist/opencode-darwin-*/bin/opencode; do
|
||||||
|
codesign -vvv --verify "$file"
|
||||||
|
done
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: opencode-cli-darwin
|
||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
outputs:
|
|
||||||
version: ${{ needs.version.outputs.version }}
|
|
||||||
|
|
||||||
build-tauri:
|
build-tauri:
|
||||||
needs:
|
needs:
|
||||||
- build-cli
|
|
||||||
- version
|
- version
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
strategy:
|
strategy:
|
||||||
@@ -115,6 +172,9 @@ jobs:
|
|||||||
target: x86_64-apple-darwin
|
target: x86_64-apple-darwin
|
||||||
- host: macos-latest
|
- host: macos-latest
|
||||||
target: aarch64-apple-darwin
|
target: aarch64-apple-darwin
|
||||||
|
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
|
||||||
|
- host: windows-2025
|
||||||
|
target: aarch64-pc-windows-msvc
|
||||||
- host: blacksmith-4vcpu-windows-2025
|
- host: blacksmith-4vcpu-windows-2025
|
||||||
target: x86_64-pc-windows-msvc
|
target: x86_64-pc-windows-msvc
|
||||||
- host: blacksmith-4vcpu-ubuntu-2404
|
- host: blacksmith-4vcpu-ubuntu-2404
|
||||||
@@ -149,6 +209,10 @@ jobs:
|
|||||||
|
|
||||||
- uses: ./.github/actions/setup-bun
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
|
||||||
- name: Cache apt packages
|
- name: Cache apt packages
|
||||||
if: contains(matrix.settings.host, 'ubuntu')
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -241,7 +305,6 @@ jobs:
|
|||||||
|
|
||||||
build-electron:
|
build-electron:
|
||||||
needs:
|
needs:
|
||||||
- build-cli
|
|
||||||
- version
|
- version
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
strategy:
|
strategy:
|
||||||
@@ -254,6 +317,10 @@ jobs:
|
|||||||
- host: macos-latest
|
- host: macos-latest
|
||||||
target: aarch64-apple-darwin
|
target: aarch64-apple-darwin
|
||||||
platform_flag: --mac --arm64
|
platform_flag: --mac --arm64
|
||||||
|
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
|
||||||
|
- host: "windows-2025"
|
||||||
|
target: aarch64-pc-windows-msvc
|
||||||
|
platform_flag: --win --arm64
|
||||||
- host: "blacksmith-4vcpu-windows-2025"
|
- host: "blacksmith-4vcpu-windows-2025"
|
||||||
target: x86_64-pc-windows-msvc
|
target: x86_64-pc-windows-msvc
|
||||||
platform_flag: --win
|
platform_flag: --win
|
||||||
@@ -361,7 +428,8 @@ jobs:
|
|||||||
publish:
|
publish:
|
||||||
needs:
|
needs:
|
||||||
- version
|
- version
|
||||||
- build-cli
|
- build-cli-darwin
|
||||||
|
- build-cli-linux-win
|
||||||
- build-tauri
|
- build-tauri
|
||||||
- build-electron
|
- build-electron
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
@@ -397,8 +465,9 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: opencode-cli
|
pattern: opencode-cli-*
|
||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
- uses: actions/download-artifact@v4
|
- uses: actions/download-artifact@v4
|
||||||
if: needs.version.outputs.release
|
if: needs.version.outputs.release
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
name: stale-issues
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "30 1 * * *" # Daily at 1:30 AM
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
env:
|
|
||||||
DAYS_BEFORE_STALE: 90
|
|
||||||
DAYS_BEFORE_CLOSE: 7
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
stale:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/stale@v10
|
|
||||||
with:
|
|
||||||
days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
|
|
||||||
days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
|
|
||||||
stale-issue-label: "stale"
|
|
||||||
close-issue-message: |
|
|
||||||
[automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity.
|
|
||||||
|
|
||||||
Feel free to reopen if you still need this!
|
|
||||||
stale-issue-message: |
|
|
||||||
[automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days.
|
|
||||||
|
|
||||||
It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity.
|
|
||||||
remove-stale-when-updated: true
|
|
||||||
exempt-issue-labels: "pinned,security,feature-request,on-hold"
|
|
||||||
start-date: "2025-12-27"
|
|
||||||
+32
-21
@@ -6,6 +6,16 @@ on:
|
|||||||
- dev
|
- dev
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
# Keep every run on dev so cancelled checks do not pollute the default branch
|
||||||
|
# commit history. PRs and other branches still share a group and cancel stale runs.
|
||||||
|
group: ${{ case(github.ref == 'refs/heads/dev', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
unit:
|
unit:
|
||||||
name: unit (${{ matrix.settings.name }})
|
name: unit (${{ matrix.settings.name }})
|
||||||
@@ -40,20 +50,17 @@ jobs:
|
|||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: e2e (${{ matrix.settings.name }})
|
name: e2e (${{ matrix.settings.name }})
|
||||||
needs: unit
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
settings:
|
settings:
|
||||||
- name: linux
|
- name: linux
|
||||||
host: blacksmith-4vcpu-ubuntu-2404
|
host: blacksmith-4vcpu-ubuntu-2404
|
||||||
playwright: bunx playwright install --with-deps
|
|
||||||
- name: windows
|
- name: windows
|
||||||
host: blacksmith-4vcpu-windows-2025
|
host: blacksmith-4vcpu-windows-2025
|
||||||
playwright: bunx playwright install
|
|
||||||
runs-on: ${{ matrix.settings.host }}
|
runs-on: ${{ matrix.settings.host }}
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_BROWSERS_PATH: 0
|
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -66,9 +73,28 @@ jobs:
|
|||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: ./.github/actions/setup-bun
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Read Playwright version
|
||||||
|
id: playwright-version
|
||||||
|
run: |
|
||||||
|
version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Cache Playwright browsers
|
||||||
|
id: playwright-cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ github.workspace }}/.playwright-browsers
|
||||||
|
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
|
||||||
|
|
||||||
|
- name: Install Playwright system dependencies
|
||||||
|
if: runner.os == 'Linux'
|
||||||
working-directory: packages/app
|
working-directory: packages/app
|
||||||
run: ${{ matrix.settings.playwright }}
|
run: bunx playwright install-deps chromium
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
|
working-directory: packages/app
|
||||||
|
run: bunx playwright install chromium
|
||||||
|
|
||||||
- name: Run app e2e tests
|
- name: Run app e2e tests
|
||||||
run: bun --cwd packages/app test:e2e:local
|
run: bun --cwd packages/app test:e2e:local
|
||||||
@@ -86,18 +112,3 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
packages/app/e2e/test-results
|
packages/app/e2e/test-results
|
||||||
packages/app/e2e/playwright-report
|
packages/app/e2e/playwright-report
|
||||||
|
|
||||||
required:
|
|
||||||
name: test (linux)
|
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
|
||||||
needs:
|
|
||||||
- unit
|
|
||||||
- e2e
|
|
||||||
if: always()
|
|
||||||
steps:
|
|
||||||
- name: Verify upstream test jobs passed
|
|
||||||
run: |
|
|
||||||
echo "unit=${{ needs.unit.result }}"
|
|
||||||
echo "e2e=${{ needs.e2e.result }}"
|
|
||||||
test "${{ needs.unit.result }}" = "success"
|
|
||||||
test "${{ needs.e2e.result }}" = "success"
|
|
||||||
|
|||||||
@@ -33,6 +33,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
issue-id: ${{ github.event.issue.number }}
|
issue-id: ${{ github.event.issue.number }}
|
||||||
comment-id: ${{ github.event.comment.id }}
|
comment-id: ${{ github.event.comment.id }}
|
||||||
roles: admin,maintain
|
roles: admin,maintain,write
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ ts-dist
|
|||||||
/result
|
/result
|
||||||
refs
|
refs
|
||||||
Session.vim
|
Session.vim
|
||||||
opencode.json
|
/opencode.json
|
||||||
a.out
|
a.out
|
||||||
target
|
target
|
||||||
.scripts
|
.scripts
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
plans/
|
node_modules
|
||||||
bun.lock
|
plans
|
||||||
package.json
|
package.json
|
||||||
|
bun.lock
|
||||||
|
.gitignore
|
||||||
|
package-lock.json
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
description: Translate content for a specified locale while preserving technical terms
|
description: Translate content for a specified locale while preserving technical terms
|
||||||
mode: subagent
|
mode: subagent
|
||||||
model: opencode/gemini-3-pro
|
model: opencode/gpt-5.4
|
||||||
---
|
---
|
||||||
|
|
||||||
You are a professional translator and localization specialist.
|
You are a professional translator and localization specialist.
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
model: opencode/kimi-k2.5
|
||||||
|
---
|
||||||
|
|
||||||
|
create UPCOMING_CHANGELOG.md
|
||||||
|
|
||||||
|
it should have sections
|
||||||
|
|
||||||
|
```
|
||||||
|
# TUI
|
||||||
|
|
||||||
|
# Desktop
|
||||||
|
|
||||||
|
# Core
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
```
|
||||||
|
|
||||||
|
go through each PR merged since the last tag
|
||||||
|
|
||||||
|
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md into the appropriate section.
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
/// <reference path="../env.d.ts" />
|
/// <reference path="../env.d.ts" />
|
||||||
import { tool } from "@opencode-ai/plugin"
|
import { tool } from "@opencode-ai/plugin"
|
||||||
import DESCRIPTION from "./github-pr-search.txt"
|
|
||||||
|
|
||||||
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
||||||
const response = await fetch(`https://api.github.com${endpoint}`, {
|
const response = await fetch(`https://api.github.com${endpoint}`, {
|
||||||
...options,
|
...options,
|
||||||
@@ -24,7 +22,16 @@ interface PR {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description: DESCRIPTION,
|
description: `Use this tool to search GitHub pull requests by title and description.
|
||||||
|
|
||||||
|
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
|
||||||
|
- PR number and title
|
||||||
|
- Author
|
||||||
|
- State (open/closed/merged)
|
||||||
|
- Labels
|
||||||
|
- Description snippet
|
||||||
|
|
||||||
|
Use the query parameter to search for keywords that might appear in PR titles or descriptions.`,
|
||||||
args: {
|
args: {
|
||||||
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
|
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
|
||||||
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
|
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
Use this tool to search GitHub pull requests by title and description.
|
|
||||||
|
|
||||||
This tool searches PRs in the anomalyco/opencode repository and returns LLM-friendly results including:
|
|
||||||
- PR number and title
|
|
||||||
- Author
|
|
||||||
- State (open/closed/merged)
|
|
||||||
- Labels
|
|
||||||
- Description snippet
|
|
||||||
|
|
||||||
Use the query parameter to search for keywords that might appear in PR titles or descriptions.
|
|
||||||
@@ -1,20 +1,10 @@
|
|||||||
/// <reference path="../env.d.ts" />
|
/// <reference path="../env.d.ts" />
|
||||||
import { tool } from "@opencode-ai/plugin"
|
import { tool } from "@opencode-ai/plugin"
|
||||||
import DESCRIPTION from "./github-triage.txt"
|
|
||||||
|
|
||||||
const TEAM = {
|
const TEAM = {
|
||||||
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
|
||||||
zen: ["fwang", "MrMushrooooom"],
|
zen: ["fwang", "MrMushrooooom"],
|
||||||
tui: [
|
tui: ["thdxr", "kommander", "rekram1-node"],
|
||||||
"thdxr",
|
core: ["thdxr", "rekram1-node", "jlongster"],
|
||||||
"kommander",
|
|
||||||
// "rekram1-node" (on vacation)
|
|
||||||
],
|
|
||||||
core: [
|
|
||||||
"thdxr",
|
|
||||||
// "rekram1-node", (on vacation)
|
|
||||||
"jlongster",
|
|
||||||
],
|
|
||||||
docs: ["R44VC0RP"],
|
docs: ["R44VC0RP"],
|
||||||
windows: ["Hona"],
|
windows: ["Hona"],
|
||||||
} as const
|
} as const
|
||||||
@@ -48,9 +38,17 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default tool({
|
export default tool({
|
||||||
description: DESCRIPTION,
|
description: `Use this tool to assign and/or label a GitHub issue.
|
||||||
|
|
||||||
|
Choose labels and assignee using the current triage policy and ownership rules.
|
||||||
|
Pick the most fitting labels for the issue and assign one owner.
|
||||||
|
|
||||||
|
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
|
||||||
args: {
|
args: {
|
||||||
assignee: tool.schema.enum(ASSIGNEES as [string, ...string[]]).describe("The username of the assignee"),
|
assignee: tool.schema
|
||||||
|
.enum(ASSIGNEES as [string, ...string[]])
|
||||||
|
.describe("The username of the assignee")
|
||||||
|
.default("rekram1-node"),
|
||||||
labels: tool.schema
|
labels: tool.schema
|
||||||
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
|
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
|
||||||
.describe("The labels(s) to add to the issue")
|
.describe("The labels(s) to add to the issue")
|
||||||
@@ -73,8 +71,7 @@ export default tool({
|
|||||||
results.push("Dropped label: nix (issue does not mention nix)")
|
results.push("Dropped label: nix (issue does not mention nix)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
|
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
|
||||||
const assignee = web ? pick(TEAM.desktop) : args.assignee
|
|
||||||
|
|
||||||
if (labels.includes("zen") && !zen) {
|
if (labels.includes("zen") && !zen) {
|
||||||
throw new Error("Only add the zen label when issue title/body contains 'zen'")
|
throw new Error("Only add the zen label when issue title/body contains 'zen'")
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
Use this tool to assign and/or label a GitHub issue.
|
|
||||||
|
|
||||||
Choose labels and assignee using the current triage policy and ownership rules.
|
|
||||||
Pick the most fitting labels for the issue and assign one owner.
|
|
||||||
|
|
||||||
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.
|
|
||||||
|
|
||||||
(Note: rekram1-node is on vacation, do not assign issues to him.)
|
|
||||||
@@ -122,3 +122,7 @@ const table = sqliteTable("session", {
|
|||||||
- Avoid mocks as much as possible
|
- Avoid mocks as much as possible
|
||||||
- Test actual implementation, do not duplicate logic into tests
|
- Test actual implementation, do not duplicate logic into tests
|
||||||
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.
|
||||||
|
|
||||||
|
## Type Checking
|
||||||
|
|
||||||
|
- Always run `bun typecheck` from package directories (e.g., `packages/opencode`), never `tsc` directly.
|
||||||
|
|||||||
+1
-1
@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**加入我们的社区** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
|
||||||
|
|||||||
+1
-1
@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**加入我們的社群** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
|
||||||
|
|||||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1772091128,
|
"lastModified": 1773909469,
|
||||||
"narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=",
|
"narHash": "sha256-vglVrLfHjFIzIdV9A27Ugul6rh3I1qHbbitGW7dk420=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "3f0336406035444b4a24b942788334af5f906259",
|
"rev": "7149c06513f335be57f26fcbbbe34afda923882b",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -496,7 +496,6 @@ async function subscribeSessionEvents() {
|
|||||||
|
|
||||||
const TOOL: Record<string, [string, string]> = {
|
const TOOL: Record<string, [string, string]> = {
|
||||||
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
|
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
|
||||||
todoread: ["Todo", "\x1b[33m\x1b[1m"],
|
|
||||||
bash: ["Bash", "\x1b[31m\x1b[1m"],
|
bash: ["Bash", "\x1b[31m\x1b[1m"],
|
||||||
edit: ["Edit", "\x1b[32m\x1b[1m"],
|
edit: ["Edit", "\x1b[32m\x1b[1m"],
|
||||||
glob: ["Glob", "\x1b[34m\x1b[1m"],
|
glob: ["Glob", "\x1b[34m\x1b[1m"],
|
||||||
|
|||||||
@@ -103,6 +103,12 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
|
|||||||
const zenLiteProduct = new stripe.Product("ZenLite", {
|
const zenLiteProduct = new stripe.Product("ZenLite", {
|
||||||
name: "OpenCode Go",
|
name: "OpenCode Go",
|
||||||
})
|
})
|
||||||
|
const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50", {
|
||||||
|
name: "First month 50% off",
|
||||||
|
percentOff: 50,
|
||||||
|
appliesToProducts: [zenLiteProduct.id],
|
||||||
|
duration: "once",
|
||||||
|
})
|
||||||
const zenLitePrice = new stripe.Price("ZenLitePrice", {
|
const zenLitePrice = new stripe.Price("ZenLitePrice", {
|
||||||
product: zenLiteProduct.id,
|
product: zenLiteProduct.id,
|
||||||
currency: "usd",
|
currency: "usd",
|
||||||
@@ -116,6 +122,8 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
|
|||||||
properties: {
|
properties: {
|
||||||
product: zenLiteProduct.id,
|
product: zenLiteProduct.id,
|
||||||
price: zenLitePrice.id,
|
price: zenLitePrice.id,
|
||||||
|
priceInr: 92900,
|
||||||
|
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -194,6 +202,10 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
|
|||||||
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
||||||
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
|
||||||
|
|
||||||
|
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
|
||||||
|
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
|
||||||
|
const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
|
||||||
|
|
||||||
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
||||||
handler: "packages/console/function/src/log-processor.ts",
|
handler: "packages/console/function/src/log-processor.ts",
|
||||||
link: [new sst.Secret("HONEYCOMB_API_KEY")],
|
link: [new sst.Secret("HONEYCOMB_API_KEY")],
|
||||||
@@ -212,6 +224,9 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
EMAILOCTOPUS_API_KEY,
|
EMAILOCTOPUS_API_KEY,
|
||||||
AWS_SES_ACCESS_KEY_ID,
|
AWS_SES_ACCESS_KEY_ID,
|
||||||
AWS_SES_SECRET_ACCESS_KEY,
|
AWS_SES_SECRET_ACCESS_KEY,
|
||||||
|
SALESFORCE_CLIENT_ID,
|
||||||
|
SALESFORCE_CLIENT_SECRET,
|
||||||
|
SALESFORCE_INSTANCE_URL,
|
||||||
ZEN_BLACK_PRICE,
|
ZEN_BLACK_PRICE,
|
||||||
ZEN_LITE_PRICE,
|
ZEN_LITE_PRICE,
|
||||||
new sst.Secret("ZEN_LIMITS"),
|
new sst.Secret("ZEN_LIMITS"),
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-4kjoJ06VNvHltPHfzQRBG0bC6R39jao10ffGzrNZ230=",
|
"x86_64-linux": "sha256-l3k/1fRIAQkj7zdVj2Ad3QZWeTOf1CuIM6vgMHRaK1s=",
|
||||||
"aarch64-linux": "sha256-6Uio+S2rcyBWbBEeOZb9N1CCKgkbKi68lOIKi3Ws/pQ=",
|
"aarch64-linux": "sha256-iN3YtrKAUTK1GIwVMoVYkMXhtDZOiP7sSJ+Z8v4B5xw=",
|
||||||
"aarch64-darwin": "sha256-8ngN5KVN4vhdsk0QJ11BGgSVBrcaEbwSj23c77HBpgs=",
|
"aarch64-darwin": "sha256-FFedoiPyfHGdzQnITz1SRV7xv2XoT9vzxIDp4EcVdkU=",
|
||||||
"x86_64-darwin": "sha256-v/ueYGb9a0Nymzy+mkO4uQr78DAuJnES1qOT0onFgnQ="
|
"x86_64-darwin": "sha256-0HiHkhJiN73UixUq5CC6YP6DkZzLar8lKnEL1aoiiHg="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-5
@@ -4,11 +4,12 @@
|
|||||||
"description": "AI-powered development tool",
|
"description": "AI-powered development tool",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.10",
|
"packageManager": "bun@1.3.11",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
|
||||||
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
"dev:desktop": "bun --cwd packages/desktop tauri dev",
|
||||||
"dev:web": "bun --cwd packages/app dev",
|
"dev:web": "bun --cwd packages/app dev",
|
||||||
|
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||||
"typecheck": "bun turbo typecheck",
|
"typecheck": "bun turbo typecheck",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
@@ -24,7 +25,8 @@
|
|||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@types/bun": "1.3.9",
|
"@effect/platform-node": "4.0.0-beta.35",
|
||||||
|
"@types/bun": "1.3.11",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
@@ -41,8 +43,9 @@
|
|||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-beta.16-ea816b6",
|
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||||
"drizzle-orm": "1.0.0-beta.16-ea816b6",
|
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||||
|
"effect": "4.0.0-beta.35",
|
||||||
"ai": "5.0.124",
|
"ai": "5.0.124",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
@@ -109,6 +112,8 @@
|
|||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
|
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
|
||||||
|
"@ai-sdk/xai@2.0.51": "patches/@ai-sdk%2Fxai@2.0.51.patch",
|
||||||
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
|||||||
- `openSettings(page)` - Open settings dialog
|
- `openSettings(page)` - Open settings dialog
|
||||||
- `closeDialog(page, dialog)` - Close any dialog
|
- `closeDialog(page, dialog)` - Close any dialog
|
||||||
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
||||||
|
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
|
||||||
|
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
|
||||||
- `withSession(sdk, title, callback)` - Create temp session
|
- `withSession(sdk, title, callback)` - Create temp session
|
||||||
- `withProject(...)` - Create temp project/workspace
|
- `withProject(...)` - Create temp project/workspace
|
||||||
- `sessionIDFromUrl(url)` - Read session ID from URL
|
- `sessionIDFromUrl(url)` - Read session ID from URL
|
||||||
@@ -167,6 +169,42 @@ await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
|
|||||||
await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Terminal Tests
|
||||||
|
|
||||||
|
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
|
||||||
|
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
|
||||||
|
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
|
||||||
|
- After opening the terminal, use `waitTerminalFocusIdle(...)` before the next keyboard action when prompt focus or keyboard routing matters.
|
||||||
|
- This avoids racing terminal mount, focus handoff, and prompt readiness when the next step types or sends shortcuts.
|
||||||
|
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
|
||||||
|
|
||||||
|
### Wait on state
|
||||||
|
|
||||||
|
- Never use wall-clock waits like `page.waitForTimeout(...)` to make a test pass
|
||||||
|
- Avoid race-prone flows that assume work is finished after an action
|
||||||
|
- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
|
||||||
|
- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
|
||||||
|
- Prefer semantic app state over transient DOM visibility when behavior depends on active selection, focus ownership, or async retry loops
|
||||||
|
- Do not treat a visible element as proof that the app will route the next action to it
|
||||||
|
- When fixing a flake, validate with `--repeat-each` and multiple workers when practical
|
||||||
|
|
||||||
|
### Add hooks
|
||||||
|
|
||||||
|
- If required state is not observable from the UI, add a small test-only driver or probe in app code instead of sleeps or fragile DOM checks
|
||||||
|
- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
|
||||||
|
- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
|
||||||
|
- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
|
||||||
|
- Add minimal test-only probes for semantic state like the active list item or selected command when DOM intermediates are unstable
|
||||||
|
- Prefer probing committed app state over asserting on transient highlight, visibility, or animation states
|
||||||
|
|
||||||
|
### Prefer helpers
|
||||||
|
|
||||||
|
- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
|
||||||
|
- Use direct locators when the interaction is simple and a helper would not add clarity
|
||||||
|
- Prefer helpers that both perform an action and verify the app consumed it
|
||||||
|
- Avoid composing helpers redundantly when one already includes the other or already waits for the resulting state
|
||||||
|
- If a helper already covers the required wait or verification, use it directly instead of layering extra clicks, keypresses, or assertions
|
||||||
|
|
||||||
## Writing New Tests
|
## Writing New Tests
|
||||||
|
|
||||||
1. Choose appropriate folder or create new one
|
1. Choose appropriate folder or create new one
|
||||||
|
|||||||
+318
-13
@@ -1,12 +1,15 @@
|
|||||||
|
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { expect, type Locator, type Page } from "@playwright/test"
|
import { expect, type Locator, type Page } from "@playwright/test"
|
||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { execSync } from "node:child_process"
|
import { execSync } from "node:child_process"
|
||||||
|
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
|
||||||
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||||
import {
|
import {
|
||||||
dropdownMenuTriggerSelector,
|
dropdownMenuTriggerSelector,
|
||||||
dropdownMenuContentSelector,
|
dropdownMenuContentSelector,
|
||||||
|
projectSwitchSelector,
|
||||||
projectMenuTriggerSelector,
|
projectMenuTriggerSelector,
|
||||||
projectCloseMenuSelector,
|
projectCloseMenuSelector,
|
||||||
projectWorkspacesToggleSelector,
|
projectWorkspacesToggleSelector,
|
||||||
@@ -15,10 +18,22 @@ import {
|
|||||||
listItemSelector,
|
listItemSelector,
|
||||||
listItemKeySelector,
|
listItemKeySelector,
|
||||||
listItemKeyStartsWithSelector,
|
listItemKeyStartsWithSelector,
|
||||||
|
promptSelector,
|
||||||
|
terminalSelector,
|
||||||
workspaceItemSelector,
|
workspaceItemSelector,
|
||||||
workspaceMenuTriggerSelector,
|
workspaceMenuTriggerSelector,
|
||||||
} from "./selectors"
|
} from "./selectors"
|
||||||
|
|
||||||
|
const phase = new WeakMap<Page, "test" | "cleanup">()
|
||||||
|
|
||||||
|
export function setHealthPhase(page: Page, value: "test" | "cleanup") {
|
||||||
|
phase.set(page, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function healthPhase(page: Page) {
|
||||||
|
return phase.get(page) ?? "test"
|
||||||
|
}
|
||||||
|
|
||||||
export async function defocus(page: Page) {
|
export async function defocus(page: Page) {
|
||||||
await page
|
await page
|
||||||
.evaluate(() => {
|
.evaluate(() => {
|
||||||
@@ -28,9 +43,141 @@ export async function defocus(page: Page) {
|
|||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openPalette(page: Page) {
|
async function terminalID(term: Locator) {
|
||||||
|
const id = await term.getAttribute(terminalAttr)
|
||||||
|
if (id) return id
|
||||||
|
throw new Error(`Active terminal missing ${terminalAttr}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function terminalConnects(page: Page, input?: { term?: Locator }) {
|
||||||
|
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||||
|
const id = await terminalID(term)
|
||||||
|
return page.evaluate((id) => {
|
||||||
|
return (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]?.connects ?? 0
|
||||||
|
}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectTerminal(page: Page, input?: { term?: Locator }) {
|
||||||
|
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||||
|
const id = await terminalID(term)
|
||||||
|
await page.evaluate((id) => {
|
||||||
|
;(window as E2EWindow).__opencode_e2e?.terminal?.controls?.[id]?.disconnect?.()
|
||||||
|
}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminalReady(page: Page, term?: Locator) {
|
||||||
|
const next = term ?? page.locator(terminalSelector).first()
|
||||||
|
const id = await terminalID(next)
|
||||||
|
return page.evaluate((id) => {
|
||||||
|
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
|
||||||
|
return !!state?.connected && (state.settled ?? 0) > 0
|
||||||
|
}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminalFocusIdle(page: Page, term?: Locator) {
|
||||||
|
const next = term ?? page.locator(terminalSelector).first()
|
||||||
|
const id = await terminalID(next)
|
||||||
|
return page.evaluate((id) => {
|
||||||
|
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
|
||||||
|
return (state?.focusing ?? 0) === 0
|
||||||
|
}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
|
||||||
|
const next = input.term ?? page.locator(terminalSelector).first()
|
||||||
|
const id = await terminalID(next)
|
||||||
|
return page.evaluate(
|
||||||
|
(input) => {
|
||||||
|
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[input.id]
|
||||||
|
return state?.rendered.includes(input.token) ?? false
|
||||||
|
},
|
||||||
|
{ id, token: input.token },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptSlashActive(page: Page, id: string) {
|
||||||
|
return page.evaluate((id) => {
|
||||||
|
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
|
||||||
|
if (state?.popover !== "slash") return false
|
||||||
|
if (!state.slash.ids.includes(id)) return false
|
||||||
|
return state.slash.active === id
|
||||||
|
}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptSlashSelects(page: Page) {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
return (window as E2EWindow).__opencode_e2e?.prompt?.current?.selects ?? 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptSlashSelected(page: Page, input: { id: string; count: number }) {
|
||||||
|
return page.evaluate((input) => {
|
||||||
|
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
|
||||||
|
if (!state) return false
|
||||||
|
return state.selected === input.id && state.selects >= input.count
|
||||||
|
}, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
|
||||||
|
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||||
|
const timeout = input?.timeout ?? 10_000
|
||||||
|
await expect(term).toBeVisible()
|
||||||
|
await expect(term.locator("textarea")).toHaveCount(1)
|
||||||
|
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitTerminalFocusIdle(page: Page, input?: { term?: Locator; timeout?: number }) {
|
||||||
|
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||||
|
const timeout = input?.timeout ?? 10_000
|
||||||
|
await waitTerminalReady(page, { term, timeout })
|
||||||
|
await expect.poll(() => terminalFocusIdle(page, term), { timeout }).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function showPromptSlash(
|
||||||
|
page: Page,
|
||||||
|
input: { id: string; text: string; prompt?: Locator; timeout?: number },
|
||||||
|
) {
|
||||||
|
const prompt = input.prompt ?? page.locator(promptSelector)
|
||||||
|
const timeout = input.timeout ?? 10_000
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
await prompt.click().catch(() => false)
|
||||||
|
await prompt.fill(input.text).catch(() => false)
|
||||||
|
return promptSlashActive(page, input.id).catch(() => false)
|
||||||
|
},
|
||||||
|
{ timeout },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runPromptSlash(
|
||||||
|
page: Page,
|
||||||
|
input: { id: string; text: string; prompt?: Locator; timeout?: number },
|
||||||
|
) {
|
||||||
|
const prompt = input.prompt ?? page.locator(promptSelector)
|
||||||
|
const timeout = input.timeout ?? 10_000
|
||||||
|
const count = await promptSlashSelects(page)
|
||||||
|
await showPromptSlash(page, input)
|
||||||
|
await prompt.press("Enter")
|
||||||
|
await expect.poll(() => promptSlashSelected(page, { id: input.id, count: count + 1 }), { timeout }).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
|
||||||
|
const term = input.term ?? page.locator(terminalSelector).first()
|
||||||
|
const timeout = input.timeout ?? 10_000
|
||||||
|
await waitTerminalReady(page, { term, timeout })
|
||||||
|
const textarea = term.locator("textarea")
|
||||||
|
await term.click()
|
||||||
|
await expect(textarea).toBeFocused()
|
||||||
|
await page.keyboard.type(input.cmd)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPalette(page: Page, key = "K") {
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
await page.keyboard.press(`${modKey}+P`)
|
await page.keyboard.press(`${modKey}+${key}`)
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
const dialog = page.getByRole("dialog")
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
@@ -60,11 +207,51 @@ export async function closeDialog(page: Page, dialog: Locator) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function isSidebarClosed(page: Page) {
|
export async function isSidebarClosed(page: Page) {
|
||||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
const button = await waitSidebarButton(page, "isSidebarClosed")
|
||||||
await expect(button).toBeVisible()
|
|
||||||
return (await button.getAttribute("aria-expanded")) !== "true"
|
return (await button.getAttribute("aria-expanded")) !== "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function errorBoundaryText(page: Page) {
|
||||||
|
const title = page.getByRole("heading", { name: /something went wrong/i }).first()
|
||||||
|
if (!(await title.isVisible().catch(() => false))) return
|
||||||
|
|
||||||
|
const description = await page
|
||||||
|
.getByText(/an error occurred while loading the application\./i)
|
||||||
|
.first()
|
||||||
|
.textContent()
|
||||||
|
.catch(() => "")
|
||||||
|
const detail = await page
|
||||||
|
.getByRole("textbox", { name: /error details/i })
|
||||||
|
.first()
|
||||||
|
.inputValue()
|
||||||
|
.catch(async () =>
|
||||||
|
(
|
||||||
|
(await page
|
||||||
|
.getByRole("textbox", { name: /error details/i })
|
||||||
|
.first()
|
||||||
|
.textContent()
|
||||||
|
.catch(() => "")) ?? ""
|
||||||
|
).trim(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertHealthy(page: Page, context: string) {
|
||||||
|
const text = await errorBoundaryText(page)
|
||||||
|
if (!text) return
|
||||||
|
console.log(`[e2e:error-boundary][${context}]\n${text}`)
|
||||||
|
throw new Error(`Error boundary during ${context}\n${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitSidebarButton(page: Page, context: string) {
|
||||||
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
|
const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
|
||||||
|
await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
|
||||||
|
await assertHealthy(page, context)
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
export async function toggleSidebar(page: Page) {
|
export async function toggleSidebar(page: Page) {
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
await page.keyboard.press(`${modKey}+B`)
|
await page.keyboard.press(`${modKey}+B`)
|
||||||
@@ -73,7 +260,7 @@ export async function toggleSidebar(page: Page) {
|
|||||||
export async function openSidebar(page: Page) {
|
export async function openSidebar(page: Page) {
|
||||||
if (!(await isSidebarClosed(page))) return
|
if (!(await isSidebarClosed(page))) return
|
||||||
|
|
||||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
const button = await waitSidebarButton(page, "openSidebar")
|
||||||
await button.click()
|
await button.click()
|
||||||
|
|
||||||
const opened = await expect(button)
|
const opened = await expect(button)
|
||||||
@@ -90,7 +277,7 @@ export async function openSidebar(page: Page) {
|
|||||||
export async function closeSidebar(page: Page) {
|
export async function closeSidebar(page: Page) {
|
||||||
if (await isSidebarClosed(page)) return
|
if (await isSidebarClosed(page)) return
|
||||||
|
|
||||||
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
const button = await waitSidebarButton(page, "closeSidebar")
|
||||||
await button.click()
|
await button.click()
|
||||||
|
|
||||||
const closed = await expect(button)
|
const closed = await expect(button)
|
||||||
@@ -105,6 +292,7 @@ export async function closeSidebar(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openSettings(page: Page) {
|
export async function openSettings(page: Page) {
|
||||||
|
await assertHealthy(page, "openSettings")
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
const dialog = page.getByRole("dialog")
|
||||||
@@ -117,6 +305,8 @@ export async function openSettings(page: Page) {
|
|||||||
|
|
||||||
if (opened) return dialog
|
if (opened) return dialog
|
||||||
|
|
||||||
|
await assertHealthy(page, "openSettings")
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Settings" }).first().click()
|
await page.getByRole("button", { name: "Settings" }).first().click()
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
return dialog
|
return dialog
|
||||||
@@ -178,10 +368,12 @@ export async function seedProjects(page: Page, input: { directory: string; extra
|
|||||||
|
|
||||||
export async function createTestProject() {
|
export async function createTestProject() {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
||||||
|
const id = `e2e-${path.basename(root)}`
|
||||||
|
|
||||||
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
|
await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
|
||||||
|
|
||||||
execSync("git init", { cwd: root, stdio: "ignore" })
|
execSync("git init", { cwd: root, stdio: "ignore" })
|
||||||
|
await fs.writeFile(path.join(root, ".git", "opencode"), id)
|
||||||
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
||||||
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
||||||
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
||||||
@@ -203,12 +395,24 @@ export function slugFromUrl(url: string) {
|
|||||||
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
|
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function probeSession(page: Page) {
|
||||||
|
return page
|
||||||
|
.evaluate(() => {
|
||||||
|
const win = window as E2EWindow
|
||||||
|
const current = win.__opencode_e2e?.model?.current
|
||||||
|
if (!current) return null
|
||||||
|
return { dir: current.dir, sessionID: current.sessionID }
|
||||||
|
})
|
||||||
|
.catch(() => null as { dir?: string; sessionID?: string } | null)
|
||||||
|
}
|
||||||
|
|
||||||
export async function waitSlug(page: Page, skip: string[] = []) {
|
export async function waitSlug(page: Page, skip: string[] = []) {
|
||||||
let prev = ""
|
let prev = ""
|
||||||
let next = ""
|
let next = ""
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
() => {
|
async () => {
|
||||||
|
await assertHealthy(page, "waitSlug")
|
||||||
const slug = slugFromUrl(page.url())
|
const slug = slugFromUrl(page.url())
|
||||||
if (!slug) return ""
|
if (!slug) return ""
|
||||||
if (skip.includes(slug)) return ""
|
if (skip.includes(slug)) return ""
|
||||||
@@ -226,6 +430,94 @@ export async function waitSlug(page: Page, skip: string[] = []) {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveSlug(slug: string) {
|
||||||
|
const directory = base64Decode(slug)
|
||||||
|
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||||
|
const resolved = await resolveDirectory(directory)
|
||||||
|
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitDir(page: Page, directory: string) {
|
||||||
|
const target = await resolveDirectory(directory)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
await assertHealthy(page, "waitDir")
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
if (!slug) return ""
|
||||||
|
return resolveSlug(slug)
|
||||||
|
.then((item) => item.directory)
|
||||||
|
.catch(() => "")
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.toBe(target)
|
||||||
|
return { directory: target, slug: base64Encode(target) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
|
||||||
|
const target = await resolveDirectory(input.directory)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
await assertHealthy(page, "waitSession")
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
if (!slug) return false
|
||||||
|
const resolved = await resolveSlug(slug).catch(() => undefined)
|
||||||
|
if (!resolved || resolved.directory !== target) return false
|
||||||
|
if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
|
||||||
|
|
||||||
|
const state = await probeSession(page)
|
||||||
|
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
||||||
|
if (state?.dir) {
|
||||||
|
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
|
||||||
|
if (dir !== target) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return page
|
||||||
|
.locator(promptSelector)
|
||||||
|
.first()
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false)
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
return { directory: target, slug: base64Encode(target) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
|
||||||
|
const sdk = createSdk(directory)
|
||||||
|
const target = await resolveDirectory(directory)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const data = await sdk.session
|
||||||
|
.get({ sessionID })
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (!data?.directory) return ""
|
||||||
|
return resolveDirectory(data.directory).catch(() => data.directory)
|
||||||
|
},
|
||||||
|
{ timeout },
|
||||||
|
)
|
||||||
|
.toBe(target)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const items = await sdk.session
|
||||||
|
.messages({ sessionID, limit: 20 })
|
||||||
|
.then((x) => x.data ?? [])
|
||||||
|
.catch(() => [])
|
||||||
|
return items.some((item) => item.info.role === "user")
|
||||||
|
},
|
||||||
|
{ timeout },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
export function sessionIDFromUrl(url: string) {
|
export function sessionIDFromUrl(url: string) {
|
||||||
const match = /\/session\/([^/?#]+)/.exec(url)
|
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||||
return match?.[1]
|
return match?.[1]
|
||||||
@@ -539,12 +831,19 @@ export async function seedSessionTask(
|
|||||||
.flatMap((message) => message.parts)
|
.flatMap((message) => message.parts)
|
||||||
.find((part) => {
|
.find((part) => {
|
||||||
if (part.type !== "tool" || part.tool !== "task") return false
|
if (part.type !== "tool" || part.tool !== "task") return false
|
||||||
if (part.state.input?.description !== input.description) return false
|
if (!("state" in part) || !part.state || typeof part.state !== "object") return false
|
||||||
return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0
|
if (!("input" in part.state) || !part.state.input || typeof part.state.input !== "object") return false
|
||||||
|
if (!("description" in part.state.input) || part.state.input.description !== input.description) return false
|
||||||
|
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object")
|
||||||
|
return false
|
||||||
|
if (!("sessionId" in part.state.metadata)) return false
|
||||||
|
return typeof part.state.metadata.sessionId === "string" && part.state.metadata.sessionId.length > 0
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!part) return
|
if (!part || !("state" in part) || !part.state || typeof part.state !== "object") return
|
||||||
const id = part.state.metadata?.sessionId
|
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
|
||||||
|
if (!("sessionId" in part.state.metadata)) return
|
||||||
|
const id = part.state.metadata.sessionId
|
||||||
if (typeof id !== "string" || !id) return
|
if (typeof id !== "string" || !id) return
|
||||||
const child = await sdk.session
|
const child = await sdk.session
|
||||||
.get({ sessionID: id })
|
.get({ sessionID: id })
|
||||||
@@ -630,8 +929,14 @@ export async function openStatusPopover(page: Page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openProjectMenu(page: Page, projectSlug: string) {
|
export async function openProjectMenu(page: Page, projectSlug: string) {
|
||||||
|
await openSidebar(page)
|
||||||
|
const item = page.locator(projectSwitchSelector(projectSlug)).first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.hover()
|
||||||
|
|
||||||
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
||||||
await expect(trigger).toHaveCount(1)
|
await expect(trigger).toHaveCount(1)
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
|
||||||
const menu = page
|
const menu = page
|
||||||
.locator(dropdownMenuContentSelector)
|
.locator(dropdownMenuContentSelector)
|
||||||
@@ -640,7 +945,7 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
|
|||||||
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
|
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
|
||||||
|
|
||||||
const clicked = await trigger
|
const clicked = await trigger
|
||||||
.click({ timeout: 1500 })
|
.click({ force: true, timeout: 1500 })
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import { serverNamePattern } from "../utils"
|
|||||||
|
|
||||||
test("home renders and shows core entrypoints", async ({ page }) => {
|
test("home renders and shows core entrypoints", async ({ page }) => {
|
||||||
await page.goto("/")
|
await page.goto("/")
|
||||||
|
const nav = page.locator('[data-component="sidebar-nav-desktop"]')
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
|
||||||
|
await expect(nav.getByText("No projects open")).toBeVisible()
|
||||||
|
await expect(nav.getByText("Open a project to get started")).toBeVisible()
|
||||||
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
|
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openPalette } from "../actions"
|
import { closeDialog, openPalette } from "../actions"
|
||||||
|
|
||||||
test("search palette opens and closes", async ({ page, gotoSession }) => {
|
test("search palette opens and closes", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
@@ -9,3 +9,12 @@ test("search palette opens and closes", async ({ page, gotoSession }) => {
|
|||||||
await page.keyboard.press("Escape")
|
await page.keyboard.press("Escape")
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("search palette also opens with cmd+p", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openPalette(page, "P")
|
||||||
|
|
||||||
|
await closeDialog(page, dialog)
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { test as base, expect, type Page } from "@playwright/test"
|
import { test as base, expect, type Page } from "@playwright/test"
|
||||||
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
|
import type { E2EWindow } from "../src/testing/terminal"
|
||||||
import { promptSelector } from "./selectors"
|
import {
|
||||||
|
healthPhase,
|
||||||
|
cleanupSession,
|
||||||
|
cleanupTestProject,
|
||||||
|
createTestProject,
|
||||||
|
setHealthPhase,
|
||||||
|
seedProjects,
|
||||||
|
sessionIDFromUrl,
|
||||||
|
waitSlug,
|
||||||
|
waitSession,
|
||||||
|
} from "./actions"
|
||||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||||
|
|
||||||
export const settingsKey = "settings.v3"
|
export const settingsKey = "settings.v3"
|
||||||
@@ -26,6 +36,29 @@ type WorkerFixtures = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||||
|
page: async ({ page }, use) => {
|
||||||
|
let boundary: string | undefined
|
||||||
|
setHealthPhase(page, "test")
|
||||||
|
const consoleHandler = (msg: { text(): string }) => {
|
||||||
|
const text = msg.text()
|
||||||
|
if (!text.includes("[e2e:error-boundary]")) return
|
||||||
|
if (healthPhase(page) === "cleanup") {
|
||||||
|
console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
boundary ||= text
|
||||||
|
console.log(text)
|
||||||
|
}
|
||||||
|
const pageErrorHandler = (err: Error) => {
|
||||||
|
console.log(`[e2e:pageerror] ${err.stack || err.message}`)
|
||||||
|
}
|
||||||
|
page.on("console", consoleHandler)
|
||||||
|
page.on("pageerror", pageErrorHandler)
|
||||||
|
await use(page)
|
||||||
|
page.off("console", consoleHandler)
|
||||||
|
page.off("pageerror", pageErrorHandler)
|
||||||
|
if (boundary) throw new Error(boundary)
|
||||||
|
},
|
||||||
directory: [
|
directory: [
|
||||||
async ({}, use) => {
|
async ({}, use) => {
|
||||||
const directory = await getWorktree()
|
const directory = await getWorktree()
|
||||||
@@ -47,21 +80,20 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
const gotoSession = async (sessionID?: string) => {
|
||||||
await page.goto(sessionPath(directory, sessionID))
|
await page.goto(sessionPath(directory, sessionID))
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await waitSession(page, { directory, sessionID })
|
||||||
}
|
}
|
||||||
await use(gotoSession)
|
await use(gotoSession)
|
||||||
},
|
},
|
||||||
withProject: async ({ page }, use) => {
|
withProject: async ({ page }, use) => {
|
||||||
await use(async (callback, options) => {
|
await use(async (callback, options) => {
|
||||||
const root = await createTestProject()
|
const root = await createTestProject()
|
||||||
const slug = dirSlug(root)
|
|
||||||
const sessions = new Map<string, string>()
|
const sessions = new Map<string, string>()
|
||||||
const dirs = new Set<string>()
|
const dirs = new Set<string>()
|
||||||
await seedStorage(page, { directory: root, extra: options?.extra })
|
await seedStorage(page, { directory: root, extra: options?.extra })
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
const gotoSession = async (sessionID?: string) => {
|
||||||
await page.goto(sessionPath(root, sessionID))
|
await page.goto(sessionPath(root, sessionID))
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await waitSession(page, { directory: root, sessionID })
|
||||||
const current = sessionIDFromUrl(page.url())
|
const current = sessionIDFromUrl(page.url())
|
||||||
if (current) trackSession(current)
|
if (current) trackSession(current)
|
||||||
}
|
}
|
||||||
@@ -76,13 +108,16 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
const slug = await waitSlug(page)
|
||||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
||||||
} finally {
|
} finally {
|
||||||
|
setHealthPhase(page, "cleanup")
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
||||||
)
|
)
|
||||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
||||||
await cleanupTestProject(root)
|
await cleanupTestProject(root)
|
||||||
|
setHealthPhase(page, "test")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -91,6 +126,20 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
|
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
|
||||||
await seedProjects(page, input)
|
await seedProjects(page, input)
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
|
const win = window as E2EWindow
|
||||||
|
win.__opencode_e2e = {
|
||||||
|
...win.__opencode_e2e,
|
||||||
|
model: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
prompt: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
terminal: {
|
||||||
|
enabled: true,
|
||||||
|
terminals: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:model",
|
"opencode.global.dat:model",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
@@ -1,41 +1,19 @@
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
import type { Page } from "@playwright/test"
|
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
|
import {
|
||||||
|
defocus,
|
||||||
|
createTestProject,
|
||||||
|
cleanupTestProject,
|
||||||
|
openSidebar,
|
||||||
|
sessionIDFromUrl,
|
||||||
|
setWorkspacesEnabled,
|
||||||
|
waitSession,
|
||||||
|
waitSessionSaved,
|
||||||
|
waitSlug,
|
||||||
|
} from "../actions"
|
||||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { dirSlug, resolveDirectory } from "../utils"
|
import { dirSlug, resolveDirectory } from "../utils"
|
||||||
|
|
||||||
async function workspaces(page: Page, directory: string, enabled: boolean) {
|
|
||||||
await page.evaluate(
|
|
||||||
({ directory, enabled }: { directory: string; enabled: boolean }) => {
|
|
||||||
const key = "opencode.global.dat:layout"
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
const data = raw ? JSON.parse(raw) : {}
|
|
||||||
const sidebar = data.sidebar && typeof data.sidebar === "object" ? data.sidebar : {}
|
|
||||||
const current =
|
|
||||||
sidebar.workspaces && typeof sidebar.workspaces === "object" && !Array.isArray(sidebar.workspaces)
|
|
||||||
? sidebar.workspaces
|
|
||||||
: {}
|
|
||||||
const next = { ...current }
|
|
||||||
|
|
||||||
if (enabled) next[directory] = true
|
|
||||||
if (!enabled) delete next[directory]
|
|
||||||
|
|
||||||
localStorage.setItem(
|
|
||||||
key,
|
|
||||||
JSON.stringify({
|
|
||||||
...data,
|
|
||||||
sidebar: {
|
|
||||||
...sidebar,
|
|
||||||
workspaces: next,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ directory, enabled },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
@@ -76,9 +54,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
await withProject(
|
await withProject(
|
||||||
async ({ directory, slug, trackSession, trackDirectory }) => {
|
async ({ directory, slug, trackSession, trackDirectory }) => {
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
await workspaces(page, directory, true)
|
await setWorkspacesEnabled(page, slug, true)
|
||||||
await page.reload()
|
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
|
||||||
@@ -100,11 +76,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
await expect(btn).toBeVisible()
|
await expect(btn).toBeVisible()
|
||||||
await btn.click({ force: true })
|
await btn.click({ force: true })
|
||||||
|
|
||||||
// A new workspace can be discovered via a transient slug before the route and sidebar
|
await waitSession(page, { directory: space })
|
||||||
// settle to the canonical workspace path on Windows, so interact with either and assert
|
|
||||||
// against the resolved workspace slug.
|
|
||||||
await waitSlug(page)
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
|
||||||
|
|
||||||
// Create a session by sending a prompt
|
// Create a session by sending a prompt
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
@@ -118,6 +90,7 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
const created = sessionIDFromUrl(page.url())
|
const created = sessionIDFromUrl(page.url())
|
||||||
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
||||||
trackSession(created, space)
|
trackSession(created, space)
|
||||||
|
await waitSessionSaved(space, created)
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
||||||
|
|
||||||
@@ -125,14 +98,14 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
|
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
await expect(otherButton).toBeVisible()
|
await expect(otherButton).toBeVisible()
|
||||||
await otherButton.click()
|
await otherButton.click({ force: true })
|
||||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
await waitSession(page, { directory: other })
|
||||||
|
|
||||||
const rootButton = page.locator(projectSwitchSelector(slug)).first()
|
const rootButton = page.locator(projectSwitchSelector(slug)).first()
|
||||||
await expect(rootButton).toBeVisible()
|
await expect(rootButton).toBeVisible()
|
||||||
await rootButton.click()
|
await rootButton.click({ force: true })
|
||||||
|
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
|
await waitSession(page, { directory: space, sessionID: created })
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
||||||
},
|
},
|
||||||
{ extra: [other] },
|
{ extra: [other] },
|
||||||
|
|||||||
@@ -1,109 +1,94 @@
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
|
import {
|
||||||
|
openSidebar,
|
||||||
|
resolveSlug,
|
||||||
|
sessionIDFromUrl,
|
||||||
|
setWorkspacesEnabled,
|
||||||
|
waitDir,
|
||||||
|
waitSession,
|
||||||
|
waitSessionSaved,
|
||||||
|
waitSlug,
|
||||||
|
} from "../actions"
|
||||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { createSdk } from "../utils"
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
async function waitWorkspaceReady(page: Page, slug: string) {
|
function item(space: { slug: string; raw: string }) {
|
||||||
|
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function button(space: { slug: string; raw: string }) {
|
||||||
|
return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect
|
await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 })
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
|
||||||
try {
|
|
||||||
await item.hover({ timeout: 500 })
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
const slug = await waitSlug(page, [root, ...seen])
|
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
|
||||||
const directory = base64Decode(slug)
|
await waitDir(page, next.directory)
|
||||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
|
||||||
return { slug, directory }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openWorkspaceNewSession(page: Page, slug: string) {
|
|
||||||
await waitWorkspaceReady(page, slug)
|
|
||||||
|
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
|
||||||
await item.hover()
|
|
||||||
|
|
||||||
const button = page.locator(workspaceNewSessionSelector(slug)).first()
|
|
||||||
await expect(button).toBeVisible()
|
|
||||||
await button.click({ force: true })
|
|
||||||
|
|
||||||
const next = await waitSlug(page)
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
|
async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) {
|
||||||
const next = await openWorkspaceNewSession(page, slug)
|
await waitWorkspaceReady(page, space)
|
||||||
|
|
||||||
|
const row = page.locator(item(space)).first()
|
||||||
|
await row.hover()
|
||||||
|
|
||||||
|
const next = page.locator(button(space)).first()
|
||||||
|
await expect(next).toBeVisible()
|
||||||
|
await next.click({ force: true })
|
||||||
|
|
||||||
|
await waitSession(page, { directory: space.directory })
|
||||||
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSessionFromWorkspace(
|
||||||
|
page: Page,
|
||||||
|
space: { slug: string; raw: string; directory: string },
|
||||||
|
text: string,
|
||||||
|
) {
|
||||||
|
await openWorkspaceNewSession(page, space)
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
await expect(prompt).toBeVisible()
|
await expect(prompt).toBeVisible()
|
||||||
await expect(prompt).toBeEditable()
|
|
||||||
await prompt.click()
|
|
||||||
await expect(prompt).toBeFocused()
|
|
||||||
await prompt.fill(text)
|
await prompt.fill(text)
|
||||||
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
await page.keyboard.press("Enter")
|
||||||
await prompt.press("Enter")
|
|
||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url())).toBe(next)
|
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
|
||||||
|
|
||||||
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
||||||
const sessionID = sessionIDFromUrl(page.url())
|
const sessionID = sessionIDFromUrl(page.url())
|
||||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
|
|
||||||
return { sessionID, slug: next }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sessionDirectory(directory: string, sessionID: string) {
|
await waitSessionSaved(space.directory, sessionID)
|
||||||
const info = await createSdk(directory)
|
await createSdk(space.directory)
|
||||||
.session.get({ sessionID })
|
.session.abort({ sessionID })
|
||||||
.then((x) => x.data)
|
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
if (!info) return ""
|
return sessionID
|
||||||
return info.directory
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
|
await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await setWorkspacesEnabled(page, root, true)
|
await setWorkspacesEnabled(page, root, true)
|
||||||
|
|
||||||
const first = await createWorkspace(page, root, [])
|
const first = await createWorkspace(page, root, [])
|
||||||
trackDirectory(first.directory)
|
trackDirectory(first.directory)
|
||||||
await waitWorkspaceReady(page, first.slug)
|
await waitWorkspaceReady(page, first)
|
||||||
|
|
||||||
const second = await createWorkspace(page, root, [first.slug])
|
const second = await createWorkspace(page, root, [first.slug])
|
||||||
trackDirectory(second.directory)
|
trackDirectory(second.directory)
|
||||||
await waitWorkspaceReady(page, second.slug)
|
await waitWorkspaceReady(page, second)
|
||||||
|
|
||||||
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
|
||||||
trackSession(firstSession.sessionID, first.directory)
|
trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
|
||||||
|
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
|
||||||
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
|
||||||
trackSession(secondSession.sessionID, second.directory)
|
|
||||||
|
|
||||||
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
|
||||||
trackSession(thirdSession.sessionID, first.directory)
|
|
||||||
|
|
||||||
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
|
|
||||||
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
|
|
||||||
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
|
||||||
import fs from "node:fs/promises"
|
import fs from "node:fs/promises"
|
||||||
import os from "node:os"
|
import os from "node:os"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
|
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
@@ -13,8 +13,10 @@ import {
|
|||||||
confirmDialog,
|
confirmDialog,
|
||||||
openSidebar,
|
openSidebar,
|
||||||
openWorkspaceMenu,
|
openWorkspaceMenu,
|
||||||
|
resolveSlug,
|
||||||
setWorkspacesEnabled,
|
setWorkspacesEnabled,
|
||||||
slugFromUrl,
|
slugFromUrl,
|
||||||
|
waitDir,
|
||||||
waitSlug,
|
waitSlug,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||||
@@ -27,15 +29,15 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
|||||||
await setWorkspacesEnabled(page, rootSlug, true)
|
await setWorkspacesEnabled(page, rootSlug, true)
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const slug = await waitSlug(page, [rootSlug])
|
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
|
||||||
const dir = base64Decode(slug)
|
await waitDir(page, next.directory)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
const item = page.locator(workspaceItemSelector(next.slug)).first()
|
||||||
try {
|
try {
|
||||||
await item.hover({ timeout: 500 })
|
await item.hover({ timeout: 500 })
|
||||||
return true
|
return true
|
||||||
@@ -47,7 +49,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
|||||||
)
|
)
|
||||||
.toBe(true)
|
.toBe(true)
|
||||||
|
|
||||||
return { rootSlug, slug, directory: dir }
|
return { rootSlug, slug: next.slug, directory: next.directory }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
|
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
|
||||||
@@ -79,15 +81,15 @@ test("can create a workspace", async ({ page, withProject }) => {
|
|||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const workspaceSlug = await waitSlug(page, [slug])
|
const next = await resolveSlug(await waitSlug(page, [slug]))
|
||||||
const workspaceDir = base64Decode(workspaceSlug)
|
await waitDir(page, next.directory)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
const item = page.locator(workspaceItemSelector(next.slug)).first()
|
||||||
try {
|
try {
|
||||||
await item.hover({ timeout: 500 })
|
await item.hover({ timeout: 500 })
|
||||||
return true
|
return true
|
||||||
@@ -99,9 +101,9 @@ test("can create a workspace", async ({ page, withProject }) => {
|
|||||||
)
|
)
|
||||||
.toBe(true)
|
.toBe(true)
|
||||||
|
|
||||||
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
|
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
|
||||||
|
|
||||||
await cleanupTestProject(workspaceDir)
|
await cleanupTestProject(next.directory)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -119,7 +121,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
|
|||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
||||||
|
|
||||||
const activeDir = base64Decode(slugFromUrl(page.url()))
|
const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
|
||||||
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
|
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
@@ -331,9 +333,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
|
|||||||
for (const _ of [0, 1]) {
|
for (const _ of [0, 1]) {
|
||||||
const prev = slugFromUrl(page.url())
|
const prev = slugFromUrl(page.url())
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const slug = await waitSlug(page, [rootSlug, prev])
|
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
|
||||||
const dir = base64Decode(slug)
|
await waitDir(page, next.directory)
|
||||||
workspaces.push({ slug, directory: dir })
|
workspaces.push(next)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,10 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
|
|||||||
await page.keyboard.type(draft)
|
await page.keyboard.type(draft)
|
||||||
await wait(page, draft)
|
await wait(page, draft)
|
||||||
|
|
||||||
await edge(page, "start")
|
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
|
||||||
|
await prompt.fill("")
|
||||||
|
await wait(page, "")
|
||||||
|
|
||||||
await page.keyboard.press("ArrowUp")
|
await page.keyboard.press("ArrowUp")
|
||||||
await wait(page, second)
|
await wait(page, second)
|
||||||
|
|
||||||
@@ -119,7 +122,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
|
|||||||
await wait(page, second)
|
await wait(page, second)
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
await page.keyboard.press("ArrowDown")
|
||||||
await wait(page, draft)
|
await wait(page, "")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ test("shift+enter inserts a newline without submitting", async ({ page, gotoSess
|
|||||||
await expect(page).toHaveURL(/\/session\/?$/)
|
await expect(page).toHaveURL(/\/session\/?$/)
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
await prompt.click()
|
await prompt.focus()
|
||||||
await page.keyboard.type("line one")
|
await expect(prompt).toBeFocused()
|
||||||
await page.keyboard.press("Shift+Enter")
|
|
||||||
await page.keyboard.type("line two")
|
await prompt.pressSequentially("line one")
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
|
|
||||||
|
await prompt.press("Shift+Enter")
|
||||||
|
await expect(page).toHaveURL(/\/session\/?$/)
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
|
|
||||||
|
await prompt.pressSequentially("line two")
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/?$/)
|
await expect(page).toHaveURL(/\/session\/?$/)
|
||||||
await expect(prompt).toContainText("line one")
|
await expect.poll(() => prompt.evaluate((el) => el.innerText)).toBe("line one\nline two")
|
||||||
await expect(prompt).toContainText("line two")
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
import { runPromptSlash, waitTerminalFocusIdle } from "../actions"
|
||||||
import { promptSelector, terminalSelector } from "../selectors"
|
import { promptSelector, terminalSelector } from "../selectors"
|
||||||
|
|
||||||
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
||||||
@@ -9,15 +10,9 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => {
|
|||||||
|
|
||||||
await expect(terminal).not.toBeVisible()
|
await expect(terminal).not.toBeVisible()
|
||||||
|
|
||||||
await prompt.click()
|
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
|
||||||
await page.keyboard.type("/terminal")
|
await waitTerminalFocusIdle(page, { term: terminal })
|
||||||
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
await expect(terminal).toBeVisible()
|
|
||||||
|
|
||||||
await prompt.click()
|
await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" })
|
||||||
await page.keyboard.type("/terminal")
|
|
||||||
await expect(page.locator('[data-slash-id="terminal.toggle"]').first()).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
await expect(terminal).not.toBeVisible()
|
await expect(terminal).not.toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export const promptSelector = '[data-component="prompt-input"]'
|
export const promptSelector = '[data-component="prompt-input"]'
|
||||||
export const terminalSelector = '[data-component="terminal"]'
|
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
|
||||||
|
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
|
||||||
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
||||||
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
||||||
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
||||||
@@ -12,6 +13,9 @@ export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggl
|
|||||||
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
|
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
|
||||||
|
|
||||||
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
||||||
|
export const promptAgentSelector = '[data-component="prompt-agent-control"]'
|
||||||
|
export const promptModelSelector = '[data-component="prompt-model-control"]'
|
||||||
|
export const promptVariantSelector = '[data-component="prompt-variant-control"]'
|
||||||
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
||||||
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
|
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
|
||||||
export const settingsThemeSelector = '[data-action="settings-theme"]'
|
export const settingsThemeSelector = '[data-action="settings-theme"]'
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
|
import {
|
||||||
|
composerEvent,
|
||||||
|
type ComposerDriverState,
|
||||||
|
type ComposerProbeState,
|
||||||
|
type ComposerWindow,
|
||||||
|
} from "../../src/testing/session-composer"
|
||||||
|
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions"
|
||||||
import {
|
import {
|
||||||
permissionDockSelector,
|
permissionDockSelector,
|
||||||
promptSelector,
|
promptSelector,
|
||||||
questionDockSelector,
|
questionDockSelector,
|
||||||
sessionComposerDockSelector,
|
sessionComposerDockSelector,
|
||||||
sessionTodoDockSelector,
|
|
||||||
sessionTodoListSelector,
|
|
||||||
sessionTodoToggleButtonSelector,
|
sessionTodoToggleButtonSelector,
|
||||||
} from "../selectors"
|
} from "../selectors"
|
||||||
|
|
||||||
@@ -42,12 +46,8 @@ async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>
|
|||||||
|
|
||||||
async function clearPermissionDock(page: any, label: RegExp) {
|
async function clearPermissionDock(page: any, label: RegExp) {
|
||||||
const dock = page.locator(permissionDockSelector)
|
const dock = page.locator(permissionDockSelector)
|
||||||
for (let i = 0; i < 3; i++) {
|
await expect(dock).toBeVisible()
|
||||||
const count = await dock.count()
|
await dock.getByRole("button", { name: label }).click()
|
||||||
if (count === 0) return
|
|
||||||
await dock.getByRole("button", { name: label }).click()
|
|
||||||
await page.waitForTimeout(150)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setAutoAccept(page: any, enabled: boolean) {
|
async function setAutoAccept(page: any, enabled: boolean) {
|
||||||
@@ -59,6 +59,120 @@ async function setAutoAccept(page: any, enabled: boolean) {
|
|||||||
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
|
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function expectQuestionBlocked(page: any) {
|
||||||
|
await expect(page.locator(questionDockSelector)).toBeVisible()
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectQuestionOpen(page: any) {
|
||||||
|
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectPermissionBlocked(page: any) {
|
||||||
|
await expect(page.locator(permissionDockSelector)).toBeVisible()
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectPermissionOpen(page: any) {
|
||||||
|
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function todoDock(page: any, sessionID: string) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const win = window as ComposerWindow
|
||||||
|
win.__opencode_e2e = {
|
||||||
|
...win.__opencode_e2e,
|
||||||
|
composer: {
|
||||||
|
enabled: true,
|
||||||
|
sessions: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const write = async (driver: ComposerDriverState | undefined) => {
|
||||||
|
await page.evaluate(
|
||||||
|
(input) => {
|
||||||
|
const win = window as ComposerWindow
|
||||||
|
const composer = win.__opencode_e2e?.composer
|
||||||
|
if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
|
||||||
|
composer.sessions ??= {}
|
||||||
|
const prev = composer.sessions[input.sessionID] ?? {}
|
||||||
|
if (!input.driver) {
|
||||||
|
if (!prev.probe) {
|
||||||
|
delete composer.sessions[input.sessionID]
|
||||||
|
} else {
|
||||||
|
composer.sessions[input.sessionID] = { probe: prev.probe }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
composer.sessions[input.sessionID] = {
|
||||||
|
...prev,
|
||||||
|
driver: input.driver,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent(input.event, { detail: { sessionID: input.sessionID } }))
|
||||||
|
},
|
||||||
|
{ event: composerEvent, sessionID, driver },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const read = () =>
|
||||||
|
page.evaluate((sessionID) => {
|
||||||
|
const win = window as ComposerWindow
|
||||||
|
return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null
|
||||||
|
}, sessionID) as Promise<ComposerProbeState | null>
|
||||||
|
|
||||||
|
const api = {
|
||||||
|
async clear() {
|
||||||
|
await write(undefined)
|
||||||
|
return api
|
||||||
|
},
|
||||||
|
async open(todos: NonNullable<ComposerDriverState["todos"]>) {
|
||||||
|
await write({ live: true, todos })
|
||||||
|
return api
|
||||||
|
},
|
||||||
|
async finish(todos: NonNullable<ComposerDriverState["todos"]>) {
|
||||||
|
await write({ live: false, todos })
|
||||||
|
return api
|
||||||
|
},
|
||||||
|
async expectOpen(states: ComposerProbeState["states"]) {
|
||||||
|
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
|
||||||
|
mounted: true,
|
||||||
|
collapsed: false,
|
||||||
|
hidden: false,
|
||||||
|
count: states.length,
|
||||||
|
states,
|
||||||
|
})
|
||||||
|
return api
|
||||||
|
},
|
||||||
|
async expectCollapsed(states: ComposerProbeState["states"]) {
|
||||||
|
await expect.poll(read, { timeout: 10_000 }).toMatchObject({
|
||||||
|
mounted: true,
|
||||||
|
collapsed: true,
|
||||||
|
hidden: true,
|
||||||
|
count: states.length,
|
||||||
|
states,
|
||||||
|
})
|
||||||
|
return api
|
||||||
|
},
|
||||||
|
async expectClosed() {
|
||||||
|
await expect.poll(read, { timeout: 10_000 }).toMatchObject({ mounted: false })
|
||||||
|
return api
|
||||||
|
},
|
||||||
|
async collapse() {
|
||||||
|
await page.locator(sessionTodoToggleButtonSelector).click()
|
||||||
|
return api
|
||||||
|
},
|
||||||
|
async expand() {
|
||||||
|
await page.locator(sessionTodoToggleButtonSelector).click()
|
||||||
|
return api
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
async function withMockPermission<T>(
|
async function withMockPermission<T>(
|
||||||
page: any,
|
page: any,
|
||||||
request: {
|
request: {
|
||||||
@@ -70,7 +184,7 @@ async function withMockPermission<T>(
|
|||||||
always?: string[]
|
always?: string[]
|
||||||
},
|
},
|
||||||
opts: { child?: any } | undefined,
|
opts: { child?: any } | undefined,
|
||||||
fn: () => Promise<T>,
|
fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
|
||||||
) {
|
) {
|
||||||
let pending = [
|
let pending = [
|
||||||
{
|
{
|
||||||
@@ -119,8 +233,14 @@ async function withMockPermission<T>(
|
|||||||
|
|
||||||
if (sessionList) await page.route("**/session?*", sessionList)
|
if (sessionList) await page.route("**/session?*", sessionList)
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
async resolved() {
|
||||||
|
await expect.poll(() => pending.length, { timeout: 10_000 }).toBe(0)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await fn()
|
return await fn(state)
|
||||||
} finally {
|
} finally {
|
||||||
await page.unroute("**/permission", list)
|
await page.unroute("**/permission", list)
|
||||||
await page.unroute("**/session/*/permissions/*", reply)
|
await page.unroute("**/session/*/permissions/*", reply)
|
||||||
@@ -173,14 +293,12 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
|
|||||||
})
|
})
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
const dock = page.locator(questionDockSelector)
|
||||||
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
await expectQuestionBlocked(page)
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await dock.locator('[data-slot="question-option"]').first().click()
|
await dock.locator('[data-slot="question-option"]').first().click()
|
||||||
await dock.getByRole("button", { name: /submit/i }).click()
|
await dock.getByRole("button", { name: /submit/i }).click()
|
||||||
|
|
||||||
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
await expectQuestionOpen(page)
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -199,15 +317,14 @@ test("blocked permission flow supports allow once", async ({ page, sdk, gotoSess
|
|||||||
metadata: { description: "Need permission for command" },
|
metadata: { description: "Need permission for command" },
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
async () => {
|
async (state) => {
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
await expectPermissionBlocked(page)
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /allow once/i)
|
await clearPermissionDock(page, /allow once/i)
|
||||||
|
await state.resolved()
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
await expectPermissionOpen(page)
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -226,15 +343,14 @@ test("blocked permission flow supports reject", async ({ page, sdk, gotoSession
|
|||||||
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
async () => {
|
async (state) => {
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
await expectPermissionBlocked(page)
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /deny/i)
|
await clearPermissionDock(page, /deny/i)
|
||||||
|
await state.resolved()
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
await expectPermissionOpen(page)
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -254,15 +370,14 @@ test("blocked permission flow supports allow always", async ({ page, sdk, gotoSe
|
|||||||
metadata: { description: "Need permission for command" },
|
metadata: { description: "Need permission for command" },
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
async () => {
|
async (state) => {
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
await expectPermissionBlocked(page)
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /allow always/i)
|
await clearPermissionDock(page, /allow always/i)
|
||||||
|
await state.resolved()
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
await expectPermissionOpen(page)
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -301,14 +416,12 @@ test("child session question request blocks parent dock and unblocks after submi
|
|||||||
})
|
})
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
const dock = page.locator(questionDockSelector)
|
||||||
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
await expectQuestionBlocked(page)
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await dock.locator('[data-slot="question-option"]').first().click()
|
await dock.locator('[data-slot="question-option"]').first().click()
|
||||||
await dock.getByRole("button", { name: /submit/i }).click()
|
await dock.getByRole("button", { name: /submit/i }).click()
|
||||||
|
|
||||||
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
await expectQuestionOpen(page)
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupSession({ sdk, sessionID: child.id })
|
await cleanupSession({ sdk, sessionID: child.id })
|
||||||
@@ -344,17 +457,15 @@ test("child session permission request blocks parent dock and supports allow onc
|
|||||||
metadata: { description: "Need child permission" },
|
metadata: { description: "Need child permission" },
|
||||||
},
|
},
|
||||||
{ child },
|
{ child },
|
||||||
async () => {
|
async (state) => {
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
const dock = page.locator(permissionDockSelector)
|
await expectPermissionBlocked(page)
|
||||||
await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1)
|
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /allow once/i)
|
await clearPermissionDock(page, /allow once/i)
|
||||||
|
await state.resolved()
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
|
|
||||||
await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
await expectPermissionOpen(page)
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -365,36 +476,31 @@ test("child session permission request blocks parent dock and supports allow onc
|
|||||||
|
|
||||||
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
|
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
|
||||||
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
|
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
|
||||||
await withDockSeed(sdk, session.id, async () => {
|
const dock = await todoDock(page, session.id)
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||||
|
|
||||||
await seedSessionTodos(sdk, {
|
try {
|
||||||
sessionID: session.id,
|
await dock.open([
|
||||||
todos: [
|
{ content: "first task", status: "pending", priority: "high" },
|
||||||
{ content: "first task", status: "pending", priority: "high" },
|
{ content: "second task", status: "in_progress", priority: "medium" },
|
||||||
{ content: "second task", status: "in_progress", priority: "medium" },
|
])
|
||||||
],
|
await dock.expectOpen(["pending", "in_progress"])
|
||||||
})
|
|
||||||
|
|
||||||
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
await dock.collapse()
|
||||||
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
|
await dock.expectCollapsed(["pending", "in_progress"])
|
||||||
|
|
||||||
await page.locator(sessionTodoToggleButtonSelector).click()
|
await dock.expand()
|
||||||
await expect(page.locator(sessionTodoListSelector)).toBeHidden()
|
await dock.expectOpen(["pending", "in_progress"])
|
||||||
|
|
||||||
await page.locator(sessionTodoToggleButtonSelector).click()
|
await dock.finish([
|
||||||
await expect(page.locator(sessionTodoListSelector)).toBeVisible()
|
{ content: "first task", status: "completed", priority: "high" },
|
||||||
|
{ content: "second task", status: "cancelled", priority: "medium" },
|
||||||
await seedSessionTodos(sdk, {
|
])
|
||||||
sessionID: session.id,
|
await dock.expectClosed()
|
||||||
todos: [
|
} finally {
|
||||||
{ content: "first task", status: "completed", priority: "high" },
|
await dock.clear()
|
||||||
{ content: "second task", status: "cancelled", priority: "medium" },
|
}
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -414,8 +520,7 @@ test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSe
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1)
|
await expectQuestionBlocked(page)
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
|
||||||
|
|
||||||
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
||||||
await page.keyboard.type("abc")
|
await page.keyboard.type("abc")
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import type { Locator, Page } from "@playwright/test"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import {
|
||||||
|
openSidebar,
|
||||||
|
resolveSlug,
|
||||||
|
sessionIDFromUrl,
|
||||||
|
setWorkspacesEnabled,
|
||||||
|
waitSession,
|
||||||
|
waitSessionIdle,
|
||||||
|
waitSlug,
|
||||||
|
} from "../actions"
|
||||||
|
import {
|
||||||
|
promptAgentSelector,
|
||||||
|
promptModelSelector,
|
||||||
|
promptSelector,
|
||||||
|
promptVariantSelector,
|
||||||
|
workspaceItemSelector,
|
||||||
|
workspaceNewSessionSelector,
|
||||||
|
} from "../selectors"
|
||||||
|
import { createSdk, sessionPath } from "../utils"
|
||||||
|
|
||||||
|
type Footer = {
|
||||||
|
agent: string
|
||||||
|
model: string
|
||||||
|
variant: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Probe = {
|
||||||
|
dir?: string
|
||||||
|
sessionID?: string
|
||||||
|
model?: { providerID: string; modelID: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||||
|
|
||||||
|
const text = async (locator: Locator) => ((await locator.textContent()) ?? "").trim()
|
||||||
|
|
||||||
|
const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null)
|
||||||
|
|
||||||
|
async function probe(page: Page): Promise<Probe | null> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const win = window as Window & {
|
||||||
|
__opencode_e2e?: {
|
||||||
|
model?: {
|
||||||
|
current?: Probe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return win.__opencode_e2e?.model?.current ?? null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function read(page: Page): Promise<Footer> {
|
||||||
|
return {
|
||||||
|
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
|
||||||
|
model: await text(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()),
|
||||||
|
variant: await text(page.locator(`${promptVariantSelector} [data-slot="select-select-trigger-value"]`).first()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFooter(page: Page, expected: Partial<Footer>) {
|
||||||
|
let hit: Footer | null = null
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const state = await read(page)
|
||||||
|
const ok = Object.entries(expected).every(([key, value]) => state[key as keyof Footer] === value)
|
||||||
|
if (ok) hit = state
|
||||||
|
return ok
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
if (!hit) throw new Error("Failed to resolve prompt footer state")
|
||||||
|
return hit
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitModel(page: Page, value: string) {
|
||||||
|
await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).toBe(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function choose(page: Page, root: string, value: string) {
|
||||||
|
const select = page.locator(root)
|
||||||
|
await expect(select).toBeVisible()
|
||||||
|
await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
|
||||||
|
const item = page
|
||||||
|
.locator('[data-slot="select-select-item"]')
|
||||||
|
.filter({ hasText: new RegExp(`^\\s*${escape(value)}\\s*$`) })
|
||||||
|
.first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function variantCount(page: Page) {
|
||||||
|
const select = page.locator(promptVariantSelector)
|
||||||
|
await expect(select).toBeVisible()
|
||||||
|
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
const count = await page.locator('[data-slot="select-select-item"]').count()
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
async function agents(page: Page) {
|
||||||
|
const select = page.locator(promptAgentSelector)
|
||||||
|
await expect(select).toBeVisible()
|
||||||
|
await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
|
||||||
|
const labels = await page.locator('[data-slot="select-select-item-label"]').allTextContents()
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
return labels.map((item) => item.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureVariant(page: Page, directory: string): Promise<Footer> {
|
||||||
|
const current = await read(page)
|
||||||
|
if ((await variantCount(page)) >= 2) return current
|
||||||
|
|
||||||
|
const cfg = await createSdk(directory)
|
||||||
|
.config.get()
|
||||||
|
.then((x) => x.data)
|
||||||
|
const visible = new Set(await agents(page))
|
||||||
|
const entry = Object.entries(cfg?.agent ?? {}).find((item) => {
|
||||||
|
const value = item[1]
|
||||||
|
return !!value && typeof value === "object" && "variant" in value && "model" in value && visible.has(item[0])
|
||||||
|
})
|
||||||
|
const name = entry?.[0]
|
||||||
|
test.skip(!name, "no agent with alternate variants available")
|
||||||
|
if (!name) return current
|
||||||
|
|
||||||
|
await choose(page, promptAgentSelector, name)
|
||||||
|
await expect.poll(() => variantCount(page), { timeout: 30_000 }).toBeGreaterThanOrEqual(2)
|
||||||
|
return waitFooter(page, { agent: name })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseDifferentVariant(page: Page): Promise<Footer> {
|
||||||
|
const current = await read(page)
|
||||||
|
const select = page.locator(promptVariantSelector)
|
||||||
|
await expect(select).toBeVisible()
|
||||||
|
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
|
||||||
|
const items = page.locator('[data-slot="select-select-item"]')
|
||||||
|
const count = await items.count()
|
||||||
|
if (count < 2) throw new Error("Current model has no alternate variant to select")
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const item = items.nth(i)
|
||||||
|
const next = await text(item.locator('[data-slot="select-select-item-label"]').first())
|
||||||
|
if (!next || next === current.variant) continue
|
||||||
|
await item.click()
|
||||||
|
return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Failed to choose a different variant")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseOtherModel(page: Page): Promise<Footer> {
|
||||||
|
const current = await read(page)
|
||||||
|
const button = page.locator(`${promptModelSelector} [data-action="prompt-model"]`)
|
||||||
|
await expect(button).toBeVisible()
|
||||||
|
await button.click()
|
||||||
|
|
||||||
|
const dialog = page.getByRole("dialog")
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
const items = dialog.locator('[data-slot="list-item"]')
|
||||||
|
const count = await items.count()
|
||||||
|
expect(count).toBeGreaterThan(1)
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const item = items.nth(i)
|
||||||
|
const selected = (await item.getAttribute("data-selected")) === "true"
|
||||||
|
if (selected) continue
|
||||||
|
await item.click()
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
await expect.poll(async () => (await read(page)).model !== current.model, { timeout: 30_000 }).toBe(true)
|
||||||
|
return read(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Failed to choose a different model")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goto(page: Page, directory: string, sessionID?: string) {
|
||||||
|
await page.goto(sessionPath(directory, sessionID))
|
||||||
|
await waitSession(page, { directory, sessionID })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(page: Page, value: string) {
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await expect(prompt).toBeVisible()
|
||||||
|
await prompt.click()
|
||||||
|
await prompt.fill(value)
|
||||||
|
await prompt.press("Enter")
|
||||||
|
|
||||||
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
||||||
|
const id = sessionIDFromUrl(page.url())
|
||||||
|
if (!id) throw new Error(`Failed to resolve session id from ${page.url()}`)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitUser(directory: string, sessionID: string) {
|
||||||
|
const sdk = createSdk(directory)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const items = await sdk.session.messages({ sessionID, limit: 20 }).then((x) => x.data ?? [])
|
||||||
|
return items.some((item) => item.info.role === "user")
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
await sdk.session.abort({ sessionID }).catch(() => undefined)
|
||||||
|
await waitSessionIdle(sdk, sessionID, 30_000).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||||
|
await openSidebar(page)
|
||||||
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
|
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
|
||||||
|
await waitSession(page, { directory: next.directory })
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitWorkspace(page: Page, slug: string) {
|
||||||
|
await openSidebar(page)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||||
|
try {
|
||||||
|
await item.hover({ timeout: 500 })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newWorkspaceSession(page: Page, slug: string) {
|
||||||
|
await waitWorkspace(page, slug)
|
||||||
|
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||||
|
await item.hover()
|
||||||
|
|
||||||
|
const button = page.locator(workspaceNewSessionSelector(slug)).first()
|
||||||
|
await expect(button).toBeVisible()
|
||||||
|
await button.click({ force: true })
|
||||||
|
|
||||||
|
const next = await resolveSlug(await waitSlug(page))
|
||||||
|
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("session model and variant restore per session without leaking into new sessions", async ({
|
||||||
|
page,
|
||||||
|
withProject,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
|
||||||
|
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await ensureVariant(page, directory)
|
||||||
|
const firstState = await chooseDifferentVariant(page)
|
||||||
|
const first = await submit(page, `session variant ${Date.now()}`)
|
||||||
|
trackSession(first)
|
||||||
|
await waitUser(directory, first)
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
await waitSession(page, { directory, sessionID: first })
|
||||||
|
await waitFooter(page, firstState)
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
const fresh = await ensureVariant(page, directory)
|
||||||
|
expect(fresh.variant).not.toBe(firstState.variant)
|
||||||
|
|
||||||
|
const secondState = await chooseOtherModel(page)
|
||||||
|
const second = await submit(page, `session model ${Date.now()}`)
|
||||||
|
trackSession(second)
|
||||||
|
await waitUser(directory, second)
|
||||||
|
|
||||||
|
await goto(page, directory, first)
|
||||||
|
await waitFooter(page, firstState)
|
||||||
|
|
||||||
|
await goto(page, directory, second)
|
||||||
|
await waitFooter(page, secondState)
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
await waitFooter(page, fresh)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("session model restore across workspaces", async ({ page, withProject }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
|
||||||
|
await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await ensureVariant(page, root)
|
||||||
|
const firstState = await chooseDifferentVariant(page)
|
||||||
|
const first = await submit(page, `root session ${Date.now()}`)
|
||||||
|
trackSession(first, root)
|
||||||
|
await waitUser(root, first)
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
await setWorkspacesEnabled(page, slug, true)
|
||||||
|
|
||||||
|
const one = await createWorkspace(page, slug, [])
|
||||||
|
const oneDir = await newWorkspaceSession(page, one.slug)
|
||||||
|
trackDirectory(oneDir)
|
||||||
|
|
||||||
|
const secondState = await chooseOtherModel(page)
|
||||||
|
const second = await submit(page, `workspace one ${Date.now()}`)
|
||||||
|
trackSession(second, oneDir)
|
||||||
|
await waitUser(oneDir, second)
|
||||||
|
|
||||||
|
const two = await createWorkspace(page, slug, [one.slug])
|
||||||
|
const twoDir = await newWorkspaceSession(page, two.slug)
|
||||||
|
trackDirectory(twoDir)
|
||||||
|
|
||||||
|
await ensureVariant(page, twoDir)
|
||||||
|
const thirdState = await chooseDifferentVariant(page)
|
||||||
|
const third = await submit(page, `workspace two ${Date.now()}`)
|
||||||
|
trackSession(third, twoDir)
|
||||||
|
await waitUser(twoDir, third)
|
||||||
|
|
||||||
|
await goto(page, root, first)
|
||||||
|
await waitFooter(page, firstState)
|
||||||
|
|
||||||
|
await goto(page, oneDir, second)
|
||||||
|
await waitFooter(page, secondState)
|
||||||
|
|
||||||
|
await goto(page, twoDir, third)
|
||||||
|
await waitFooter(page, thirdState)
|
||||||
|
|
||||||
|
await goto(page, root, first)
|
||||||
|
await waitFooter(page, firstState)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("variant preserved when switching agent modes", async ({ page, withProject }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
|
||||||
|
await withProject(async ({ directory, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await ensureVariant(page, directory)
|
||||||
|
const updated = await chooseDifferentVariant(page)
|
||||||
|
|
||||||
|
const available = await agents(page)
|
||||||
|
const other = available.find((name) => name !== updated.agent)
|
||||||
|
test.skip(!other, "only one agent available")
|
||||||
|
if (!other) return
|
||||||
|
|
||||||
|
await choose(page, promptAgentSelector, other)
|
||||||
|
await waitFooter(page, { agent: other, variant: updated.variant })
|
||||||
|
|
||||||
|
await choose(page, promptAgentSelector, updated.agent)
|
||||||
|
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
import { waitSessionIdle, withSession } from "../actions"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
|
const count = 14
|
||||||
|
|
||||||
|
function body(mark: string) {
|
||||||
|
return [
|
||||||
|
`title ${mark}`,
|
||||||
|
`mark ${mark}`,
|
||||||
|
...Array.from({ length: 32 }, (_, i) => `line ${String(i + 1).padStart(2, "0")} ${mark}`),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function files(tag: string) {
|
||||||
|
return Array.from({ length: count }, (_, i) => {
|
||||||
|
const id = String(i).padStart(2, "0")
|
||||||
|
return {
|
||||||
|
file: `review-scroll-${id}.txt`,
|
||||||
|
mark: `${tag}-${id}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function seed(list: ReturnType<typeof files>) {
|
||||||
|
const out = ["*** Begin Patch"]
|
||||||
|
|
||||||
|
for (const item of list) {
|
||||||
|
out.push(`*** Add File: ${item.file}`)
|
||||||
|
for (const line of body(item.mark)) out.push(`+${line}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push("*** End Patch")
|
||||||
|
return out.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function edit(file: string, prev: string, next: string) {
|
||||||
|
return ["*** Begin Patch", `*** Update File: ${file}`, "@@", `-mark ${prev}`, `+mark ${next}`, "*** End Patch"].join(
|
||||||
|
"\n",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
|
||||||
|
await sdk.session.promptAsync({
|
||||||
|
sessionID,
|
||||||
|
agent: "build",
|
||||||
|
system: [
|
||||||
|
"You are seeding deterministic e2e UI state.",
|
||||||
|
"Your only valid response is one apply_patch tool call.",
|
||||||
|
`Use this JSON input: ${JSON.stringify({ patchText })}`,
|
||||||
|
"Do not call any other tools.",
|
||||||
|
"Do not output plain text.",
|
||||||
|
].join("\n"),
|
||||||
|
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitSessionIdle(sdk, sessionID, 120_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function show(page: Parameters<typeof test>[0]["page"]) {
|
||||||
|
const btn = page.getByRole("button", { name: "Toggle review" }).first()
|
||||||
|
await expect(btn).toBeVisible()
|
||||||
|
if ((await btn.getAttribute("aria-expanded")) !== "true") await btn.click()
|
||||||
|
await expect(btn).toHaveAttribute("aria-expanded", "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expand(page: Parameters<typeof test>[0]["page"]) {
|
||||||
|
const close = page.getByRole("button", { name: /^Collapse all$/i }).first()
|
||||||
|
const open = await close
|
||||||
|
.isVisible()
|
||||||
|
.then((value) => value)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
const btn = page.getByRole("button", { name: /^Expand all$/i }).first()
|
||||||
|
if (open) {
|
||||||
|
await close.click()
|
||||||
|
await expect(btn).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(btn).toBeVisible()
|
||||||
|
await btn.click()
|
||||||
|
await expect(close).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitMark(page: Parameters<typeof test>[0]["page"], file: string, mark: string) {
|
||||||
|
await page.waitForFunction(
|
||||||
|
({ file, mark }) => {
|
||||||
|
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
|
||||||
|
if (!(view instanceof HTMLElement)) return false
|
||||||
|
|
||||||
|
const head = Array.from(view.querySelectorAll("h3")).find(
|
||||||
|
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
|
||||||
|
)
|
||||||
|
if (!(head instanceof HTMLElement)) return false
|
||||||
|
|
||||||
|
return Array.from(head.parentElement?.querySelectorAll("diffs-container") ?? []).some((host) => {
|
||||||
|
if (!(host instanceof HTMLElement)) return false
|
||||||
|
const root = host.shadowRoot
|
||||||
|
return root?.textContent?.includes(`mark ${mark}`) ?? false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ file, mark },
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function spot(page: Parameters<typeof test>[0]["page"], file: string) {
|
||||||
|
return page.evaluate((file) => {
|
||||||
|
const view = document.querySelector('[data-slot="session-review-scroll"] .scroll-view__viewport')
|
||||||
|
if (!(view instanceof HTMLElement)) return null
|
||||||
|
|
||||||
|
const row = Array.from(view.querySelectorAll("h3")).find(
|
||||||
|
(node) => node instanceof HTMLElement && node.textContent?.includes(file),
|
||||||
|
)
|
||||||
|
if (!(row instanceof HTMLElement)) return null
|
||||||
|
|
||||||
|
const a = row.getBoundingClientRect()
|
||||||
|
const b = view.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
top: a.top - b.top,
|
||||||
|
y: view.scrollTop,
|
||||||
|
}
|
||||||
|
}, file)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function comment(page: Parameters<typeof test>[0]["page"], file: string, note: string) {
|
||||||
|
const row = page.locator(`[data-file="${file}"]`).first()
|
||||||
|
await expect(row).toBeVisible()
|
||||||
|
|
||||||
|
const line = row.locator('diffs-container [data-line="2"]').first()
|
||||||
|
await expect(line).toBeVisible()
|
||||||
|
await line.hover()
|
||||||
|
|
||||||
|
const add = row.getByRole("button", { name: /^Comment$/ }).first()
|
||||||
|
await expect(add).toBeVisible()
|
||||||
|
await add.click()
|
||||||
|
|
||||||
|
const area = row.locator('[data-slot="line-comment-textarea"]').first()
|
||||||
|
await expect(area).toBeVisible()
|
||||||
|
await area.fill(note)
|
||||||
|
|
||||||
|
const submit = row.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
|
||||||
|
await expect(submit).toBeEnabled()
|
||||||
|
await submit.click()
|
||||||
|
|
||||||
|
await expect(row.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
|
||||||
|
await expect(row.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function overflow(page: Parameters<typeof test>[0]["page"], file: string) {
|
||||||
|
const row = page.locator(`[data-file="${file}"]`).first()
|
||||||
|
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
||||||
|
const pop = row.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
|
||||||
|
const tools = row.locator('[data-slot="line-comment-tools"]').first()
|
||||||
|
|
||||||
|
const [width, viewBox, popBox, toolsBox] = await Promise.all([
|
||||||
|
view.evaluate((el) => el.scrollWidth - el.clientWidth),
|
||||||
|
view.boundingBox(),
|
||||||
|
pop.boundingBox(),
|
||||||
|
tools.boundingBox(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!viewBox || !popBox || !toolsBox) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
|
||||||
|
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openReviewFile(page: Parameters<typeof test>[0]["page"], file: string) {
|
||||||
|
const row = page.locator(`[data-file="${file}"]`).first()
|
||||||
|
await expect(row).toBeVisible()
|
||||||
|
await row.hover()
|
||||||
|
|
||||||
|
const open = row.getByRole("button", { name: /^Open file$/i }).first()
|
||||||
|
await expect(open).toBeVisible()
|
||||||
|
await open.click()
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: file }).first()
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
|
||||||
|
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||||
|
await expect(viewer).toBeVisible()
|
||||||
|
return viewer
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileComment(page: Parameters<typeof test>[0]["page"], note: string) {
|
||||||
|
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||||
|
await expect(viewer).toBeVisible()
|
||||||
|
|
||||||
|
const line = viewer.locator('diffs-container [data-line="2"]').first()
|
||||||
|
await expect(line).toBeVisible()
|
||||||
|
await line.hover()
|
||||||
|
|
||||||
|
const add = viewer.getByRole("button", { name: /^Comment$/ }).first()
|
||||||
|
await expect(add).toBeVisible()
|
||||||
|
await add.click()
|
||||||
|
|
||||||
|
const area = viewer.locator('[data-slot="line-comment-textarea"]').first()
|
||||||
|
await expect(area).toBeVisible()
|
||||||
|
await area.fill(note)
|
||||||
|
|
||||||
|
const submit = viewer.locator('[data-slot="line-comment-action"][data-variant="primary"]').first()
|
||||||
|
await expect(submit).toBeEnabled()
|
||||||
|
await submit.click()
|
||||||
|
|
||||||
|
await expect(viewer.locator('[data-slot="line-comment-content"]').filter({ hasText: note }).first()).toBeVisible()
|
||||||
|
await expect(viewer.locator('[data-slot="line-comment-tools"]').first()).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
|
||||||
|
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||||
|
const view = page.locator('[role="tabpanel"] .scroll-view__viewport').first()
|
||||||
|
const pop = viewer.locator('[data-slot="line-comment-popover"][data-inline-body]').first()
|
||||||
|
const tools = viewer.locator('[data-slot="line-comment-tools"]').first()
|
||||||
|
|
||||||
|
const [width, viewBox, popBox, toolsBox] = await Promise.all([
|
||||||
|
view.evaluate((el) => el.scrollWidth - el.clientWidth),
|
||||||
|
view.boundingBox(),
|
||||||
|
pop.boundingBox(),
|
||||||
|
tools.boundingBox(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (!viewBox || !popBox || !toolsBox) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
pop: popBox.x + popBox.width - (viewBox.x + viewBox.width),
|
||||||
|
tools: toolsBox.x + toolsBox.width - (viewBox.x + viewBox.width),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
|
||||||
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
|
const tag = `review-comment-${Date.now()}`
|
||||||
|
const file = `review-comment-${tag}.txt`
|
||||||
|
const note = `comment ${tag}`
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 })
|
||||||
|
|
||||||
|
await withProject(async (project) => {
|
||||||
|
const sdk = createSdk(project.directory)
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e review comment ${tag}`, async (session) => {
|
||||||
|
await patch(sdk, session.id, seed([{ file, mark: tag }]))
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
|
return diff.length
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(1)
|
||||||
|
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
await show(page)
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
|
||||||
|
await expand(page)
|
||||||
|
await waitMark(page, file, tag)
|
||||||
|
await comment(page, file, note)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
|
.toBeLessThanOrEqual(1)
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
|
.toBeLessThanOrEqual(1)
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
|
.toBeLessThanOrEqual(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
|
||||||
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
|
const tag = `review-file-comment-${Date.now()}`
|
||||||
|
const file = `review-file-comment-${tag}.txt`
|
||||||
|
const note = `comment ${tag}`
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 })
|
||||||
|
|
||||||
|
await withProject(async (project) => {
|
||||||
|
const sdk = createSdk(project.directory)
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
|
||||||
|
await patch(sdk, session.id, seed([{ file, mark: tag }]))
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
|
return diff.length
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(1)
|
||||||
|
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
await show(page)
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
|
||||||
|
await expand(page)
|
||||||
|
await waitMark(page, file, tag)
|
||||||
|
await openReviewFile(page, file)
|
||||||
|
await fileComment(page, note)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
|
.toBeLessThanOrEqual(1)
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
|
.toBeLessThanOrEqual(1)
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
|
.toBeLessThanOrEqual(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
|
||||||
|
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
|
||||||
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
|
const tag = `review-${Date.now()}`
|
||||||
|
const list = files(tag)
|
||||||
|
const hit = list[list.length - 4]!
|
||||||
|
const next = `${tag}-live`
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 1600, height: 1000 })
|
||||||
|
|
||||||
|
await withProject(async (project) => {
|
||||||
|
const sdk = createSdk(project.directory)
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e review ${tag}`, async (session) => {
|
||||||
|
await patch(sdk, session.id, seed(list))
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
|
||||||
|
return info?.summary?.files ?? 0
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(list.length)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
|
return diff.length
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(list.length)
|
||||||
|
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
await show(page)
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
|
||||||
|
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
||||||
|
await expect(view).toBeVisible()
|
||||||
|
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
|
||||||
|
await expect(heads).toHaveCount(list.length, {
|
||||||
|
timeout: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expand(page)
|
||||||
|
await waitMark(page, hit.file, hit.mark)
|
||||||
|
|
||||||
|
const row = page
|
||||||
|
.getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
|
||||||
|
.first()
|
||||||
|
await expect(row).toBeVisible()
|
||||||
|
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
|
||||||
|
|
||||||
|
await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
|
||||||
|
const prev = await spot(page, hit.file)
|
||||||
|
if (!prev) throw new Error(`missing review row for ${hit.file}`)
|
||||||
|
|
||||||
|
await patch(sdk, session.id, edit(hit.file, hit.mark, next))
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
|
const item = diff.find((item) => item.file === hit.file)
|
||||||
|
return typeof item?.after === "string" ? item.after : ""
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toContain(`mark ${next}`)
|
||||||
|
|
||||||
|
await waitMark(page, hit.file, next)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const next = await spot(page, hit.file)
|
||||||
|
if (!next) return Number.POSITIVE_INFINITY
|
||||||
|
return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBeLessThanOrEqual(32)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSettings, closeDialog, withSession } from "../actions"
|
import { openSettings, closeDialog, waitTerminalFocusIdle, withSession } from "../actions"
|
||||||
import { keybindButtonSelector, terminalSelector } from "../selectors"
|
import { keybindButtonSelector, terminalSelector } from "../selectors"
|
||||||
import { modKey } from "../utils"
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
@@ -241,7 +241,7 @@ test("changing file open keybind works", async ({ page, gotoSession }) => {
|
|||||||
await expect(keybindButton).toBeVisible()
|
await expect(keybindButton).toBeVisible()
|
||||||
|
|
||||||
const initialKeybind = await keybindButton.textContent()
|
const initialKeybind = await keybindButton.textContent()
|
||||||
expect(initialKeybind).toContain("P")
|
expect(initialKeybind).toContain("K")
|
||||||
|
|
||||||
await keybindButton.click()
|
await keybindButton.click()
|
||||||
await expect(keybindButton).toHaveText(/press/i)
|
await expect(keybindButton).toHaveText(/press/i)
|
||||||
@@ -302,7 +302,7 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
|
|||||||
await expect(terminal).not.toBeVisible()
|
await expect(terminal).not.toBeVisible()
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Y`)
|
await page.keyboard.press(`${modKey}+Y`)
|
||||||
await expect(terminal).toBeVisible()
|
await waitTerminalFocusIdle(page, { term: terminal })
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Y`)
|
await page.keyboard.press(`${modKey}+Y`)
|
||||||
await expect(terminal).not.toBeVisible()
|
await expect(terminal).not.toBeVisible()
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
|
import {
|
||||||
|
defocus,
|
||||||
|
cleanupSession,
|
||||||
|
cleanupTestProject,
|
||||||
|
closeSidebar,
|
||||||
|
createTestProject,
|
||||||
|
hoverSessionItem,
|
||||||
|
openSidebar,
|
||||||
|
waitSession,
|
||||||
|
} from "../actions"
|
||||||
import { projectSwitchSelector } from "../selectors"
|
import { projectSwitchSelector } from "../selectors"
|
||||||
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
|
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
@@ -37,3 +47,72 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
|||||||
await cleanupSession({ sdk, sessionID: two.id })
|
await cleanupSession({ sdk, sessionID: two.id })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const other = await createTestProject()
|
||||||
|
const slug = dirSlug(other)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withProject(
|
||||||
|
async () => {
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||||
|
const card = page.locator('[data-component="hover-card-content"]')
|
||||||
|
|
||||||
|
await expect(project).toBeVisible()
|
||||||
|
await project.hover()
|
||||||
|
await expect(card.getByText(/recent sessions/i)).toBeVisible()
|
||||||
|
|
||||||
|
await page.mouse.down()
|
||||||
|
await expect(card).toHaveCount(0)
|
||||||
|
await page.mouse.up()
|
||||||
|
|
||||||
|
await waitSession(page, { directory: other })
|
||||||
|
await expect(card).toHaveCount(0)
|
||||||
|
},
|
||||||
|
{ extra: [other] },
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
await cleanupTestProject(other)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const other = await createTestProject()
|
||||||
|
const slug = dirSlug(other)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withProject(
|
||||||
|
async () => {
|
||||||
|
await openSidebar(page)
|
||||||
|
await defocus(page)
|
||||||
|
|
||||||
|
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||||
|
|
||||||
|
await expect(project).toBeVisible()
|
||||||
|
|
||||||
|
let hit = false
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
hit = await project.evaluate((el) => {
|
||||||
|
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
|
||||||
|
})
|
||||||
|
if (hit) break
|
||||||
|
await page.keyboard.press("Tab")
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(hit).toBe(true)
|
||||||
|
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await waitSession(page, { directory: other })
|
||||||
|
},
|
||||||
|
{ extra: [other] },
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
await cleanupTestProject(other)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
import { waitTerminalFocusIdle, waitTerminalReady } from "../actions"
|
||||||
import { promptSelector, terminalSelector } from "../selectors"
|
import { promptSelector, terminalSelector } from "../selectors"
|
||||||
import { terminalToggleKey } from "../utils"
|
import { terminalToggleKey } from "../utils"
|
||||||
|
|
||||||
@@ -13,8 +14,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
|
|||||||
await page.keyboard.press(terminalToggleKey)
|
await page.keyboard.press(terminalToggleKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
await expect(terminals.first()).toBeVisible()
|
await waitTerminalFocusIdle(page, { term: terminals.first() })
|
||||||
await expect(terminals.first().locator("textarea")).toHaveCount(1)
|
|
||||||
await expect(terminals).toHaveCount(1)
|
await expect(terminals).toHaveCount(1)
|
||||||
|
|
||||||
// Ghostty captures a lot of keybinds when focused; move focus back
|
// Ghostty captures a lot of keybinds when focused; move focus back
|
||||||
@@ -24,5 +24,5 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes
|
|||||||
|
|
||||||
await expect(tabs).toHaveCount(2)
|
await expect(tabs).toHaveCount(2)
|
||||||
await expect(terminals).toHaveCount(1)
|
await expect(terminals).toHaveCount(1)
|
||||||
await expect(terminals.first().locator("textarea")).toHaveCount(1)
|
await waitTerminalReady(page, { term: terminals.first() })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { disconnectTerminal, runTerminal, terminalConnects, waitTerminalReady } from "../actions"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { terminalSelector } from "../selectors"
|
||||||
|
import { terminalToggleKey } from "../utils"
|
||||||
|
|
||||||
|
async function open(page: Page) {
|
||||||
|
const term = page.locator(terminalSelector).first()
|
||||||
|
const visible = await term.isVisible().catch(() => false)
|
||||||
|
if (!visible) await page.keyboard.press(terminalToggleKey)
|
||||||
|
await waitTerminalReady(page, { term })
|
||||||
|
return term
|
||||||
|
}
|
||||||
|
|
||||||
|
test("terminal reconnects without replacing the pty", async ({ page, withProject }) => {
|
||||||
|
await withProject(async ({ gotoSession }) => {
|
||||||
|
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
|
||||||
|
const token = `E2E_RECONNECT_${Date.now()}`
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const term = await open(page)
|
||||||
|
const id = await term.getAttribute("data-pty-id")
|
||||||
|
if (!id) throw new Error("Active terminal missing data-pty-id")
|
||||||
|
|
||||||
|
const prev = await terminalConnects(page, { term })
|
||||||
|
|
||||||
|
await runTerminal(page, {
|
||||||
|
term,
|
||||||
|
cmd: `export ${name}=${token}; echo ${token}`,
|
||||||
|
token,
|
||||||
|
})
|
||||||
|
|
||||||
|
await disconnectTerminal(page, { term })
|
||||||
|
|
||||||
|
await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev)
|
||||||
|
await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id)
|
||||||
|
|
||||||
|
await runTerminal(page, {
|
||||||
|
term,
|
||||||
|
cmd: `echo $${name}`,
|
||||||
|
token,
|
||||||
|
timeout: 15_000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
|
import { runTerminal, waitTerminalReady } from "../actions"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { terminalSelector } from "../selectors"
|
import { dropdownMenuContentSelector, terminalSelector } from "../selectors"
|
||||||
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
@@ -17,16 +18,7 @@ async function open(page: Page) {
|
|||||||
const terminal = page.locator(terminalSelector)
|
const terminal = page.locator(terminalSelector)
|
||||||
const visible = await terminal.isVisible().catch(() => false)
|
const visible = await terminal.isVisible().catch(() => false)
|
||||||
if (!visible) await page.keyboard.press(terminalToggleKey)
|
if (!visible) await page.keyboard.press(terminalToggleKey)
|
||||||
await expect(terminal).toBeVisible()
|
await waitTerminalReady(page, { term: terminal })
|
||||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run(page: Page, cmd: string) {
|
|
||||||
const terminal = page.locator(terminalSelector)
|
|
||||||
await expect(terminal).toBeVisible()
|
|
||||||
await terminal.click()
|
|
||||||
await page.keyboard.type(cmd)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function store(page: Page, key: string) {
|
async function store(page: Page, key: string) {
|
||||||
@@ -56,15 +48,16 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
|
|||||||
await gotoSession()
|
await gotoSession()
|
||||||
await open(page)
|
await open(page)
|
||||||
|
|
||||||
await run(page, `echo ${one}`)
|
await runTerminal(page, { cmd: `echo ${one}`, token: one })
|
||||||
|
|
||||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||||
await expect(tabs).toHaveCount(2)
|
await expect(tabs).toHaveCount(2)
|
||||||
|
|
||||||
await run(page, `echo ${two}`)
|
await runTerminal(page, { cmd: `echo ${two}`, token: two })
|
||||||
|
|
||||||
await first.click()
|
await first.click()
|
||||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
@@ -76,7 +69,7 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
|
|||||||
second: second.includes(two),
|
second: second.includes(two),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 5_000 },
|
||||||
)
|
)
|
||||||
.toEqual({ first: false, second: true })
|
.toEqual({ first: false, second: true })
|
||||||
|
|
||||||
@@ -93,7 +86,7 @@ test("inactive terminal tab buffers persist across tab switches", async ({ page,
|
|||||||
second: second.includes(two),
|
second: second.includes(two),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 5_000 },
|
||||||
)
|
)
|
||||||
.toEqual({ first: true, second: false })
|
.toEqual({ first: true, second: false })
|
||||||
})
|
})
|
||||||
@@ -137,3 +130,39 @@ test("closing the active terminal tab falls back to the previous tab", async ({
|
|||||||
.toEqual({ count: 1, first: true })
|
.toEqual({ count: 1, first: true })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
|
||||||
|
await withProject(async ({ directory, gotoSession }) => {
|
||||||
|
const key = workspacePersistKey(directory, "terminal")
|
||||||
|
const rename = `E2E term ${Date.now()}`
|
||||||
|
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
await open(page)
|
||||||
|
|
||||||
|
await expect(tab).toContainText(/Terminal 1/)
|
||||||
|
await tab.click({ button: "right" })
|
||||||
|
|
||||||
|
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
|
||||||
|
await expect(menu).toHaveCount(0)
|
||||||
|
|
||||||
|
const input = page.locator('#terminal-panel input[type="text"]').first()
|
||||||
|
await expect(input).toBeVisible()
|
||||||
|
await input.fill(rename)
|
||||||
|
await input.press("Enter")
|
||||||
|
|
||||||
|
await expect(input).toHaveCount(0)
|
||||||
|
await expect(tab).toContainText(rename)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const state = await store(page, key)
|
||||||
|
return state?.all[0]?.title
|
||||||
|
},
|
||||||
|
{ timeout: 5_000 },
|
||||||
|
)
|
||||||
|
.toBe(rename)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
import { waitTerminalReady } from "../actions"
|
||||||
import { terminalSelector } from "../selectors"
|
import { terminalSelector } from "../selectors"
|
||||||
import { terminalToggleKey } from "../utils"
|
import { terminalToggleKey } from "../utils"
|
||||||
|
|
||||||
@@ -13,5 +14,5 @@ test("terminal panel can be toggled", async ({ page, gotoSession }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await page.keyboard.press(terminalToggleKey)
|
await page.keyboard.press(terminalToggleKey)
|
||||||
await expect(terminal).toBeVisible()
|
await waitTerminalReady(page, { term: terminal })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"extends": "../tsconfig.json",
|
"extends": "../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
"rootDir": "..",
|
||||||
"types": ["node", "bun"]
|
"types": ["node", "bun"]
|
||||||
},
|
},
|
||||||
"include": ["./**/*.ts"]
|
"include": ["./**/*.ts", "../src/testing/terminal.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.2.24",
|
"version": "1.3.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -45,17 +45,20 @@
|
|||||||
"@shikijs/transformers": "3.9.2",
|
"@shikijs/transformers": "3.9.2",
|
||||||
"@solid-primitives/active-element": "2.1.3",
|
"@solid-primitives/active-element": "2.1.3",
|
||||||
"@solid-primitives/audio": "1.4.2",
|
"@solid-primitives/audio": "1.4.2",
|
||||||
"@solid-primitives/i18n": "2.2.1",
|
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
|
"@solid-primitives/i18n": "2.2.1",
|
||||||
"@solid-primitives/media": "2.3.3",
|
"@solid-primitives/media": "2.3.3",
|
||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
"@solid-primitives/scroll": "2.1.3",
|
"@solid-primitives/scroll": "2.1.3",
|
||||||
"@solid-primitives/storage": "catalog:",
|
"@solid-primitives/storage": "catalog:",
|
||||||
|
"@solid-primitives/timer": "1.4.4",
|
||||||
"@solid-primitives/websocket": "1.3.1",
|
"@solid-primitives/websocket": "1.3.1",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
|
"@tanstack/solid-query": "5.91.4",
|
||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
|
"effect": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
|||||||
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||||
const reuse = !process.env.CI
|
const reuse = !process.env.CI
|
||||||
|
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
@@ -17,6 +18,7 @@ export default defineConfig({
|
|||||||
fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1",
|
fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1",
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
workers,
|
||||||
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
||||||
webServer: {
|
webServer: {
|
||||||
command,
|
command,
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ const serverEnv = {
|
|||||||
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
|
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
|
||||||
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
|
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
|
||||||
OPENCODE_CLIENT: "app",
|
OPENCODE_CLIENT: "app",
|
||||||
|
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
||||||
} satisfies Record<string, string>
|
} satisfies Record<string, string>
|
||||||
|
|
||||||
const runnerEnv = {
|
const runnerEnv = {
|
||||||
|
|||||||
+182
-44
@@ -1,50 +1,59 @@
|
|||||||
import "@/index.css"
|
import "@/index.css"
|
||||||
import { File } from "@opencode-ai/ui/file"
|
|
||||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||||
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
|
||||||
|
import { File } from "@opencode-ai/ui/file"
|
||||||
import { Font } from "@opencode-ai/ui/font"
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
import { Splash } from "@opencode-ai/ui/logo"
|
||||||
|
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||||
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
|
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||||
|
import { type Duration, Effect } from "effect"
|
||||||
|
import {
|
||||||
|
type Component,
|
||||||
|
createMemo,
|
||||||
|
createResource,
|
||||||
|
createSignal,
|
||||||
|
ErrorBoundary,
|
||||||
|
For,
|
||||||
|
type JSX,
|
||||||
|
lazy,
|
||||||
|
onCleanup,
|
||||||
|
type ParentProps,
|
||||||
|
Show,
|
||||||
|
Suspense,
|
||||||
|
} from "solid-js"
|
||||||
|
import { Dynamic } from "solid-js/web"
|
||||||
import { CommandProvider } from "@/context/command"
|
import { CommandProvider } from "@/context/command"
|
||||||
import { CommentsProvider } from "@/context/comments"
|
import { CommentsProvider } from "@/context/comments"
|
||||||
import { FileProvider } from "@/context/file"
|
import { FileProvider } from "@/context/file"
|
||||||
import { GlobalSDKProvider } from "@/context/global-sdk"
|
import { GlobalSDKProvider } from "@/context/global-sdk"
|
||||||
import { GlobalSyncProvider } from "@/context/global-sync"
|
import { GlobalSyncProvider } from "@/context/global-sync"
|
||||||
import { HighlightsProvider } from "@/context/highlights"
|
import { HighlightsProvider } from "@/context/highlights"
|
||||||
import { LanguageProvider, useLanguage } from "@/context/language"
|
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
|
||||||
import { LayoutProvider } from "@/context/layout"
|
import { LayoutProvider } from "@/context/layout"
|
||||||
import { ModelsProvider } from "@/context/models"
|
import { ModelsProvider } from "@/context/models"
|
||||||
import { NotificationProvider } from "@/context/notification"
|
import { NotificationProvider } from "@/context/notification"
|
||||||
import { PermissionProvider } from "@/context/permission"
|
import { PermissionProvider } from "@/context/permission"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { PromptProvider } from "@/context/prompt"
|
import { PromptProvider } from "@/context/prompt"
|
||||||
import { type ServerConnection, ServerProvider, useServer } from "@/context/server"
|
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||||
import { SettingsProvider } from "@/context/settings"
|
import { SettingsProvider } from "@/context/settings"
|
||||||
import { TerminalProvider } from "@/context/terminal"
|
import { TerminalProvider } from "@/context/terminal"
|
||||||
import DirectoryLayout from "@/pages/directory-layout"
|
import DirectoryLayout from "@/pages/directory-layout"
|
||||||
import Layout from "@/pages/layout"
|
import Layout from "@/pages/layout"
|
||||||
import { ErrorPage } from "./pages/error"
|
import { ErrorPage } from "./pages/error"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { useCheckServerHealth } from "./utils/server-health"
|
||||||
|
|
||||||
const Home = lazy(() => import("@/pages/home"))
|
const HomeRoute = lazy(() => import("@/pages/home"))
|
||||||
const Session = lazy(() => import("@/pages/session"))
|
const Session = lazy(() => import("@/pages/session"))
|
||||||
const Loading = () => <div class="size-full" />
|
const Loading = () => <div class="size-full" />
|
||||||
|
|
||||||
const HomeRoute = () => (
|
|
||||||
<Suspense fallback={<Loading />}>
|
|
||||||
<Home />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
|
|
||||||
const SessionRoute = () => (
|
const SessionRoute = () => (
|
||||||
<SessionProviders>
|
<SessionProviders>
|
||||||
<Suspense fallback={<Loading />}>
|
<Session />
|
||||||
<Session />
|
|
||||||
</Suspense>
|
|
||||||
</SessionProviders>
|
</SessionProviders>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,7 +61,7 @@ const SessionIndexRoute = () => <Navigate href="session" />
|
|||||||
|
|
||||||
function UiI18nBridge(props: ParentProps) {
|
function UiI18nBridge(props: ParentProps) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider>
|
return <I18nProvider value={{ locale: language.intl, t: language.t }}>{props.children}</I18nProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -62,6 +71,9 @@ declare global {
|
|||||||
deepLinks?: string[]
|
deepLinks?: string[]
|
||||||
wsl?: boolean
|
wsl?: boolean
|
||||||
}
|
}
|
||||||
|
api?: {
|
||||||
|
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +82,11 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
|
|||||||
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function QueryProvider(props: ParentProps) {
|
||||||
|
const client = new QueryClient()
|
||||||
|
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
||||||
|
}
|
||||||
|
|
||||||
function AppShellProviders(props: ParentProps) {
|
function AppShellProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
@@ -105,25 +122,33 @@ function SessionProviders(props: ParentProps) {
|
|||||||
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
|
||||||
return (
|
return (
|
||||||
<AppShellProviders>
|
<AppShellProviders>
|
||||||
{props.appChildren}
|
<Suspense fallback={<Loading />}>
|
||||||
{props.children}
|
{props.appChildren}
|
||||||
|
{props.children}
|
||||||
|
</Suspense>
|
||||||
</AppShellProviders>
|
</AppShellProviders>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppBaseProviders(props: ParentProps) {
|
export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||||
return (
|
return (
|
||||||
<MetaProvider>
|
<MetaProvider>
|
||||||
<Font />
|
<Font />
|
||||||
<ThemeProvider>
|
<ThemeProvider
|
||||||
<LanguageProvider>
|
onThemeApplied={(_, mode) => {
|
||||||
|
void window.api?.setTitlebar?.({ mode })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LanguageProvider locale={props.locale}>
|
||||||
<UiI18nBridge>
|
<UiI18nBridge>
|
||||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||||
<DialogProvider>
|
<QueryProvider>
|
||||||
<MarkedProviderWithNativeParser>
|
<DialogProvider>
|
||||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
<MarkedProviderWithNativeParser>
|
||||||
</MarkedProviderWithNativeParser>
|
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||||
</DialogProvider>
|
</MarkedProviderWithNativeParser>
|
||||||
|
</DialogProvider>
|
||||||
|
</QueryProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</UiI18nBridge>
|
</UiI18nBridge>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
@@ -132,6 +157,116 @@ export function AppBaseProviders(props: ParentProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const effectMinDuration =
|
||||||
|
(duration: Duration.Input) =>
|
||||||
|
<A, E, R>(e: Effect.Effect<A, E, R>) =>
|
||||||
|
Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0]))
|
||||||
|
|
||||||
|
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
||||||
|
const server = useServer()
|
||||||
|
const checkServerHealth = useCheckServerHealth()
|
||||||
|
|
||||||
|
const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
|
||||||
|
|
||||||
|
// performs repeated health check with a grace period for
|
||||||
|
// non-http connections, otherwise fails instantly
|
||||||
|
const [startupHealthCheck, healthCheckActions] = createResource(() =>
|
||||||
|
props.disableHealthCheck
|
||||||
|
? true
|
||||||
|
: Effect.gen(function* () {
|
||||||
|
if (!server.current) return true
|
||||||
|
const { http, type } = server.current
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const res = yield* Effect.promise(() => checkServerHealth(http))
|
||||||
|
if (res.healthy) return true
|
||||||
|
if (checkMode() === "background" || type === "http") return false
|
||||||
|
}
|
||||||
|
}).pipe(
|
||||||
|
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
|
||||||
|
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
|
||||||
|
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
||||||
|
Effect.runPromise,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show
|
||||||
|
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
|
||||||
|
fallback={
|
||||||
|
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
||||||
|
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Show
|
||||||
|
when={startupHealthCheck()}
|
||||||
|
fallback={
|
||||||
|
<ConnectionError
|
||||||
|
onRetry={() => {
|
||||||
|
if (checkMode() === "background") healthCheckActions.refetch()
|
||||||
|
}}
|
||||||
|
onServerSelected={(key) => {
|
||||||
|
setCheckMode("blocking")
|
||||||
|
server.setActive(key)
|
||||||
|
healthCheckActions.refetch()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
|
||||||
|
const language = useLanguage()
|
||||||
|
const server = useServer()
|
||||||
|
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
|
||||||
|
const name = createMemo(() => server.name || server.key)
|
||||||
|
const serverToken = "\u0000server\u0000"
|
||||||
|
const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken))
|
||||||
|
|
||||||
|
const timer = setInterval(() => props.onRetry?.(), 1000)
|
||||||
|
onCleanup(() => clearInterval(timer))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
|
||||||
|
<div class="flex flex-col items-center max-w-md text-center">
|
||||||
|
<Splash class="w-12 h-15 mb-4" />
|
||||||
|
<p class="text-14-regular text-text-base">
|
||||||
|
{unreachable()[0]}
|
||||||
|
<span class="text-text-strong font-medium">{name()}</span>
|
||||||
|
{unreachable()[1]}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-12-regular text-text-weak">{language.t("app.server.retrying")}</p>
|
||||||
|
</div>
|
||||||
|
<Show when={others().length > 0}>
|
||||||
|
<div class="flex flex-col gap-2 w-full max-w-sm">
|
||||||
|
<span class="text-12-regular text-text-base text-center">{language.t("app.server.otherServers")}</span>
|
||||||
|
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
|
||||||
|
<For each={others()}>
|
||||||
|
{(conn) => {
|
||||||
|
const key = ServerConnection.key(conn)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||||
|
onClick={() => props.onServerSelected?.(key)}
|
||||||
|
>
|
||||||
|
<span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ServerKey(props: ParentProps) {
|
function ServerKey(props: ParentProps) {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
return (
|
return (
|
||||||
@@ -146,25 +281,28 @@ export function AppInterface(props: {
|
|||||||
defaultServer: ServerConnection.Key
|
defaultServer: ServerConnection.Key
|
||||||
servers?: Array<ServerConnection.Any>
|
servers?: Array<ServerConnection.Any>
|
||||||
router?: Component<BaseRouterProps>
|
router?: Component<BaseRouterProps>
|
||||||
|
disableHealthCheck?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||||
<ServerKey>
|
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||||
<GlobalSDKProvider>
|
<ServerKey>
|
||||||
<GlobalSyncProvider>
|
<GlobalSDKProvider>
|
||||||
<Dynamic
|
<GlobalSyncProvider>
|
||||||
component={props.router ?? Router}
|
<Dynamic
|
||||||
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
component={props.router ?? Router}
|
||||||
>
|
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||||
<Route path="/" component={HomeRoute} />
|
>
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
<Route path="/" component={HomeRoute} />
|
||||||
<Route path="/" component={SessionIndexRoute} />
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
<Route path="/session/:id?" component={SessionRoute} />
|
<Route path="/" component={SessionIndexRoute} />
|
||||||
</Route>
|
<Route path="/session/:id?" component={SessionRoute} />
|
||||||
</Dynamic>
|
</Route>
|
||||||
</GlobalSyncProvider>
|
</Dynamic>
|
||||||
</GlobalSDKProvider>
|
</GlobalSyncProvider>
|
||||||
</ServerKey>
|
</GlobalSDKProvider>
|
||||||
|
</ServerKey>
|
||||||
|
</ConnectionGate>
|
||||||
</ServerProvider>
|
</ServerProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,443 @@
|
|||||||
|
import { useIsRouting, useLocation } from "@solidjs/router"
|
||||||
|
import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
|
type Mem = Performance & {
|
||||||
|
memory?: {
|
||||||
|
usedJSHeapSize: number
|
||||||
|
jsHeapSizeLimit: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Evt = PerformanceEntry & {
|
||||||
|
interactionId?: number
|
||||||
|
processingStart?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Shift = PerformanceEntry & {
|
||||||
|
hadRecentInput: boolean
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Obs = PerformanceObserverInit & {
|
||||||
|
durationThreshold?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const span = 5000
|
||||||
|
|
||||||
|
const ms = (n?: number, d = 0) => {
|
||||||
|
if (n === undefined || Number.isNaN(n)) return
|
||||||
|
return `${n.toFixed(d)}ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = (n?: number) => {
|
||||||
|
if (n === undefined || Number.isNaN(n)) return
|
||||||
|
return `${Math.round(n)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const mb = (n?: number) => {
|
||||||
|
if (n === undefined || Number.isNaN(n)) return
|
||||||
|
const v = n / 1024 / 1024
|
||||||
|
return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
const bad = (n: number | undefined, limit: number, low = false) => {
|
||||||
|
if (n === undefined || Number.isNaN(n)) return false
|
||||||
|
return low ? n < limit : n > limit
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = (path: string) => path.includes("/session")
|
||||||
|
|
||||||
|
function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; value: string; wide?: boolean }) {
|
||||||
|
return (
|
||||||
|
<Tooltip value={props.tip} placement="top">
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"flex min-h-[42px] w-full min-w-0 flex-col items-center justify-center rounded-[8px] px-0.5 py-1 text-center": true,
|
||||||
|
"col-span-2": !!props.wide,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="text-[10px] leading-none font-black uppercase tracking-[0.04em] opacity-70">{props.label}</div>
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"text-[13px] leading-none font-bold tabular-nums sm:text-[14px]": true,
|
||||||
|
"text-text-on-critical-base": !!props.bad,
|
||||||
|
"opacity-70": !!props.dim,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DebugBar() {
|
||||||
|
const language = useLanguage()
|
||||||
|
const location = useLocation()
|
||||||
|
const routing = useIsRouting()
|
||||||
|
const [state, setState] = createStore({
|
||||||
|
cls: undefined as number | undefined,
|
||||||
|
delay: undefined as number | undefined,
|
||||||
|
fps: undefined as number | undefined,
|
||||||
|
gap: undefined as number | undefined,
|
||||||
|
heap: {
|
||||||
|
limit: undefined as number | undefined,
|
||||||
|
used: undefined as number | undefined,
|
||||||
|
},
|
||||||
|
inp: undefined as number | undefined,
|
||||||
|
jank: undefined as number | undefined,
|
||||||
|
long: {
|
||||||
|
block: undefined as number | undefined,
|
||||||
|
count: undefined as number | undefined,
|
||||||
|
max: undefined as number | undefined,
|
||||||
|
},
|
||||||
|
nav: {
|
||||||
|
dur: undefined as number | undefined,
|
||||||
|
pending: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const na = () => language.t("debugBar.na")
|
||||||
|
const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
|
||||||
|
const heapv = () => {
|
||||||
|
const value = heap()
|
||||||
|
if (value === undefined) return na()
|
||||||
|
return `${Math.round(value * 100)}%`
|
||||||
|
}
|
||||||
|
const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`)
|
||||||
|
const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na()))
|
||||||
|
|
||||||
|
let prev = ""
|
||||||
|
let start = 0
|
||||||
|
let init = false
|
||||||
|
let one = 0
|
||||||
|
let two = 0
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const busy = routing()
|
||||||
|
const next = `${location.pathname}${location.search}`
|
||||||
|
|
||||||
|
if (!init) {
|
||||||
|
init = true
|
||||||
|
prev = next
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (busy) {
|
||||||
|
if (one !== 0) cancelAnimationFrame(one)
|
||||||
|
if (two !== 0) cancelAnimationFrame(two)
|
||||||
|
one = 0
|
||||||
|
two = 0
|
||||||
|
if (start !== 0) return
|
||||||
|
start = performance.now()
|
||||||
|
if (session(prev)) setState("nav", { dur: undefined, pending: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start === 0) {
|
||||||
|
prev = next
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const at = start
|
||||||
|
const from = prev
|
||||||
|
start = 0
|
||||||
|
prev = next
|
||||||
|
|
||||||
|
if (!(session(from) || session(next))) return
|
||||||
|
|
||||||
|
if (one !== 0) cancelAnimationFrame(one)
|
||||||
|
if (two !== 0) cancelAnimationFrame(two)
|
||||||
|
one = requestAnimationFrame(() => {
|
||||||
|
one = 0
|
||||||
|
two = requestAnimationFrame(() => {
|
||||||
|
two = 0
|
||||||
|
setState("nav", { dur: performance.now() - at, pending: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const obs: PerformanceObserver[] = []
|
||||||
|
const fps: Array<{ at: number; dur: number }> = []
|
||||||
|
const long: Array<{ at: number; dur: number }> = []
|
||||||
|
const seen = new Map<number | string, { at: number; delay: number; dur: number }>()
|
||||||
|
let hasLong = false
|
||||||
|
let poll: number | undefined
|
||||||
|
let raf = 0
|
||||||
|
let last = 0
|
||||||
|
let snap = 0
|
||||||
|
|
||||||
|
const trim = (list: Array<{ at: number; dur: number }>, span: number, at: number) => {
|
||||||
|
while (list[0] && at - list[0].at > span) list.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncFrame = (at: number) => {
|
||||||
|
trim(fps, span, at)
|
||||||
|
const total = fps.reduce((sum, entry) => sum + entry.dur, 0)
|
||||||
|
const gap = fps.reduce((max, entry) => Math.max(max, entry.dur), 0)
|
||||||
|
const jank = fps.filter((entry) => entry.dur > 32).length
|
||||||
|
batch(() => {
|
||||||
|
setState("fps", total > 0 ? (fps.length * 1000) / total : undefined)
|
||||||
|
setState("gap", gap > 0 ? gap : undefined)
|
||||||
|
setState("jank", jank)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncLong = (at = performance.now()) => {
|
||||||
|
if (!hasLong) return
|
||||||
|
trim(long, span, at)
|
||||||
|
const block = long.reduce((sum, entry) => sum + Math.max(0, entry.dur - 50), 0)
|
||||||
|
const max = long.reduce((hi, entry) => Math.max(hi, entry.dur), 0)
|
||||||
|
setState("long", { block, count: long.length, max })
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncInp = (at = performance.now()) => {
|
||||||
|
for (const [key, entry] of seen) {
|
||||||
|
if (at - entry.at > span) seen.delete(key)
|
||||||
|
}
|
||||||
|
let delay = 0
|
||||||
|
let inp = 0
|
||||||
|
for (const entry of seen.values()) {
|
||||||
|
delay = Math.max(delay, entry.delay)
|
||||||
|
inp = Math.max(inp, entry.dur)
|
||||||
|
}
|
||||||
|
batch(() => {
|
||||||
|
setState("delay", delay > 0 ? delay : undefined)
|
||||||
|
setState("inp", inp > 0 ? inp : undefined)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncHeap = () => {
|
||||||
|
const mem = (performance as Mem).memory
|
||||||
|
if (!mem) return
|
||||||
|
setState("heap", { limit: mem.jsHeapSizeLimit, used: mem.usedJSHeapSize })
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
fps.length = 0
|
||||||
|
long.length = 0
|
||||||
|
seen.clear()
|
||||||
|
last = 0
|
||||||
|
snap = 0
|
||||||
|
batch(() => {
|
||||||
|
setState("fps", undefined)
|
||||||
|
setState("gap", undefined)
|
||||||
|
setState("jank", undefined)
|
||||||
|
setState("delay", undefined)
|
||||||
|
setState("inp", undefined)
|
||||||
|
if (hasLong) setState("long", { block: 0, count: 0, max: 0 })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const watch = (type: string, init: Obs, fn: (entries: PerformanceEntry[]) => void) => {
|
||||||
|
if (typeof PerformanceObserver === "undefined") return false
|
||||||
|
if (!(PerformanceObserver.supportedEntryTypes ?? []).includes(type)) return false
|
||||||
|
const ob = new PerformanceObserver((list) => fn(list.getEntries()))
|
||||||
|
try {
|
||||||
|
ob.observe(init)
|
||||||
|
obs.push(ob)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
ob.disconnect()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
watch("layout-shift", { buffered: true, type: "layout-shift" }, (entries) => {
|
||||||
|
const add = entries.reduce((sum, entry) => {
|
||||||
|
const item = entry as Shift
|
||||||
|
if (item.hadRecentInput) return sum
|
||||||
|
return sum + item.value
|
||||||
|
}, 0)
|
||||||
|
if (add === 0) return
|
||||||
|
setState("cls", (value) => (value ?? 0) + add)
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
setState("cls", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
watch("longtask", { buffered: true, type: "longtask" }, (entries) => {
|
||||||
|
const at = performance.now()
|
||||||
|
long.push(...entries.map((entry) => ({ at: entry.startTime, dur: entry.duration })))
|
||||||
|
syncLong(at)
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
hasLong = true
|
||||||
|
setState("long", { block: 0, count: 0, max: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
watch("event", { buffered: true, durationThreshold: 16, type: "event" }, (entries) => {
|
||||||
|
for (const raw of entries) {
|
||||||
|
const entry = raw as Evt
|
||||||
|
if (entry.duration < 16) continue
|
||||||
|
const key =
|
||||||
|
entry.interactionId && entry.interactionId > 0
|
||||||
|
? entry.interactionId
|
||||||
|
: `${entry.name}:${Math.round(entry.startTime)}`
|
||||||
|
const prev = seen.get(key)
|
||||||
|
const delay = Math.max(0, (entry.processingStart ?? entry.startTime) - entry.startTime)
|
||||||
|
seen.set(key, {
|
||||||
|
at: entry.startTime,
|
||||||
|
delay: Math.max(prev?.delay ?? 0, delay),
|
||||||
|
dur: Math.max(prev?.dur ?? 0, entry.duration),
|
||||||
|
})
|
||||||
|
if (seen.size <= 200) continue
|
||||||
|
const first = seen.keys().next().value
|
||||||
|
if (first !== undefined) seen.delete(first)
|
||||||
|
}
|
||||||
|
syncInp()
|
||||||
|
})
|
||||||
|
|
||||||
|
const loop = (at: number) => {
|
||||||
|
if (document.visibilityState !== "visible") {
|
||||||
|
raf = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (last === 0) {
|
||||||
|
last = at
|
||||||
|
raf = requestAnimationFrame(loop)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fps.push({ at, dur: at - last })
|
||||||
|
last = at
|
||||||
|
|
||||||
|
if (at - snap >= 250) {
|
||||||
|
snap = at
|
||||||
|
syncFrame(at)
|
||||||
|
}
|
||||||
|
|
||||||
|
raf = requestAnimationFrame(loop)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (raf !== 0) cancelAnimationFrame(raf)
|
||||||
|
raf = 0
|
||||||
|
if (poll === undefined) return
|
||||||
|
clearInterval(poll)
|
||||||
|
poll = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (document.visibilityState !== "visible") return
|
||||||
|
if (poll === undefined) {
|
||||||
|
poll = window.setInterval(() => {
|
||||||
|
syncLong()
|
||||||
|
syncInp()
|
||||||
|
syncHeap()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
if (raf !== 0) return
|
||||||
|
raf = requestAnimationFrame(loop)
|
||||||
|
}
|
||||||
|
|
||||||
|
const vis = () => {
|
||||||
|
if (document.visibilityState !== "visible") {
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reset()
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
syncHeap()
|
||||||
|
start()
|
||||||
|
document.addEventListener("visibilitychange", vis)
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (one !== 0) cancelAnimationFrame(one)
|
||||||
|
if (two !== 0) cancelAnimationFrame(two)
|
||||||
|
stop()
|
||||||
|
document.removeEventListener("visibilitychange", vis)
|
||||||
|
for (const ob of obs) ob.disconnect()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
aria-label={language.t("debugBar.ariaLabel")}
|
||||||
|
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border border-border-base bg-surface-raised-stronger-non-alpha p-0.5 text-text-strong shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-5 gap-px font-mono">
|
||||||
|
<Cell
|
||||||
|
label={language.t("debugBar.nav.label")}
|
||||||
|
tip={language.t("debugBar.nav.tip")}
|
||||||
|
value={navv()}
|
||||||
|
bad={bad(state.nav.dur, 400)}
|
||||||
|
dim={state.nav.dur === undefined && !state.nav.pending}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label={language.t("debugBar.fps.label")}
|
||||||
|
tip={language.t("debugBar.fps.tip")}
|
||||||
|
value={state.fps === undefined ? na() : `${Math.round(state.fps)}`}
|
||||||
|
bad={bad(state.fps, 50, true)}
|
||||||
|
dim={state.fps === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label={language.t("debugBar.frame.label")}
|
||||||
|
tip={language.t("debugBar.frame.tip")}
|
||||||
|
value={time(state.gap) ?? na()}
|
||||||
|
bad={bad(state.gap, 50)}
|
||||||
|
dim={state.gap === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label={language.t("debugBar.jank.label")}
|
||||||
|
tip={language.t("debugBar.jank.tip")}
|
||||||
|
value={state.jank === undefined ? na() : `${state.jank}`}
|
||||||
|
bad={bad(state.jank, 8)}
|
||||||
|
dim={state.jank === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label={language.t("debugBar.long.label")}
|
||||||
|
tip={language.t("debugBar.long.tip", { max: ms(state.long.max) ?? na() })}
|
||||||
|
value={longv()}
|
||||||
|
bad={bad(state.long.block, 200)}
|
||||||
|
dim={state.long.count === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label={language.t("debugBar.delay.label")}
|
||||||
|
tip={language.t("debugBar.delay.tip")}
|
||||||
|
value={time(state.delay) ?? na()}
|
||||||
|
bad={bad(state.delay, 100)}
|
||||||
|
dim={state.delay === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label={language.t("debugBar.inp.label")}
|
||||||
|
tip={language.t("debugBar.inp.tip")}
|
||||||
|
value={time(state.inp) ?? na()}
|
||||||
|
bad={bad(state.inp, 200)}
|
||||||
|
dim={state.inp === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label={language.t("debugBar.cls.label")}
|
||||||
|
tip={language.t("debugBar.cls.tip")}
|
||||||
|
value={state.cls === undefined ? na() : state.cls.toFixed(2)}
|
||||||
|
bad={bad(state.cls, 0.1)}
|
||||||
|
dim={state.cls === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label={language.t("debugBar.mem.label")}
|
||||||
|
tip={
|
||||||
|
state.heap.used === undefined
|
||||||
|
? language.t("debugBar.mem.tipUnavailable")
|
||||||
|
: language.t("debugBar.mem.tip", {
|
||||||
|
used: mb(state.heap.used) ?? na(),
|
||||||
|
limit: mb(state.heap.limit) ?? na(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
value={heapv()}
|
||||||
|
bad={bad(heap(), 0.8)}
|
||||||
|
dim={state.heap.used === undefined}
|
||||||
|
wide
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
|
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
@@ -9,21 +9,18 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
|
import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { useLanguage } from "@/context/language"
|
||||||
import { DialogSelectModel } from "./dialog-select-model"
|
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
|
||||||
export function DialogConnectProvider(props: { provider: string }) {
|
export function DialogConnectProvider(props: { provider: string }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const platform = usePlatform()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const alive = { value: true }
|
const alive = { value: true }
|
||||||
@@ -37,25 +34,36 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
|
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
|
||||||
const methods = createMemo(
|
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||||
() =>
|
{
|
||||||
globalSync.data.provider_auth[props.provider] ?? [
|
type: "api" as const,
|
||||||
{
|
label: language.t("provider.connect.method.apiKey"),
|
||||||
type: "api",
|
},
|
||||||
label: language.t("provider.connect.method.apiKey"),
|
])
|
||||||
},
|
const [auth] = createResource(
|
||||||
],
|
() => props.provider,
|
||||||
|
async () => {
|
||||||
|
const cached = globalSync.data.provider_auth[props.provider]
|
||||||
|
if (cached) return cached
|
||||||
|
const res = await globalSDK.client.provider.auth()
|
||||||
|
if (!alive.value) return fallback()
|
||||||
|
globalSync.set("provider_auth", res.data ?? {})
|
||||||
|
return res.data?.[props.provider] ?? fallback()
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
|
||||||
|
const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
methodIndex: undefined as undefined | number,
|
methodIndex: undefined as undefined | number,
|
||||||
authorization: undefined as undefined | ProviderAuthAuthorization,
|
authorization: undefined as undefined | ProviderAuthAuthorization,
|
||||||
state: "pending" as undefined | "pending" | "complete" | "error",
|
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
|
||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| { type: "method.select"; index: number }
|
| { type: "method.select"; index: number }
|
||||||
| { type: "method.reset" }
|
| { type: "method.reset" }
|
||||||
|
| { type: "auth.prompt" }
|
||||||
| { type: "auth.pending" }
|
| { type: "auth.pending" }
|
||||||
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
|
||||||
| { type: "auth.error"; error: string }
|
| { type: "auth.error"; error: string }
|
||||||
@@ -77,6 +85,11 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
draft.error = undefined
|
draft.error = undefined
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (action.type === "auth.prompt") {
|
||||||
|
draft.state = "prompt"
|
||||||
|
draft.error = undefined
|
||||||
|
return
|
||||||
|
}
|
||||||
if (action.type === "auth.pending") {
|
if (action.type === "auth.pending") {
|
||||||
draft.state = "pending"
|
draft.state = "pending"
|
||||||
draft.error = undefined
|
draft.error = undefined
|
||||||
@@ -120,7 +133,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectMethod(index: number) {
|
async function selectMethod(index: number, inputs?: Record<string, string>) {
|
||||||
if (timer.current !== undefined) {
|
if (timer.current !== undefined) {
|
||||||
clearTimeout(timer.current)
|
clearTimeout(timer.current)
|
||||||
timer.current = undefined
|
timer.current = undefined
|
||||||
@@ -130,6 +143,10 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
dispatch({ type: "method.select", index })
|
dispatch({ type: "method.select", index })
|
||||||
|
|
||||||
if (method.type === "oauth") {
|
if (method.type === "oauth") {
|
||||||
|
if (method.prompts?.length && !inputs) {
|
||||||
|
dispatch({ type: "auth.prompt" })
|
||||||
|
return
|
||||||
|
}
|
||||||
dispatch({ type: "auth.pending" })
|
dispatch({ type: "auth.pending" })
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
await globalSDK.client.provider.oauth
|
await globalSDK.client.provider.oauth
|
||||||
@@ -137,6 +154,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
{
|
{
|
||||||
providerID: props.provider,
|
providerID: props.provider,
|
||||||
method: index,
|
method: index,
|
||||||
|
inputs,
|
||||||
},
|
},
|
||||||
{ throwOnError: true },
|
{ throwOnError: true },
|
||||||
)
|
)
|
||||||
@@ -163,6 +181,126 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OAuthPromptsView() {
|
||||||
|
const [formStore, setFormStore] = createStore({
|
||||||
|
value: {} as Record<string, string>,
|
||||||
|
index: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
|
||||||
|
const value = method()
|
||||||
|
if (value?.type !== "oauth") return []
|
||||||
|
return value.prompts ?? []
|
||||||
|
})
|
||||||
|
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
|
||||||
|
if (!prompt.when) return true
|
||||||
|
const actual = value[prompt.when.key]
|
||||||
|
if (actual === undefined) return false
|
||||||
|
return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value
|
||||||
|
}
|
||||||
|
const current = createMemo(() => {
|
||||||
|
const all = prompts()
|
||||||
|
const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
|
||||||
|
if (index === -1) return
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
prompt: all[index],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const valid = createMemo(() => {
|
||||||
|
const item = current()
|
||||||
|
if (!item || item.prompt.type !== "text") return false
|
||||||
|
const value = formStore.value[item.prompt.key] ?? ""
|
||||||
|
return value.trim().length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
async function next(index: number, value: Record<string, string>) {
|
||||||
|
if (store.methodIndex === undefined) return
|
||||||
|
const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
|
||||||
|
if (next !== -1) {
|
||||||
|
setFormStore("index", next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await selectMethod(store.methodIndex, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const item = current()
|
||||||
|
if (!item || item.prompt.type !== "text") return
|
||||||
|
if (!valid()) return
|
||||||
|
await next(item.index, formStore.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = () => current()
|
||||||
|
const text = createMemo(() => {
|
||||||
|
const prompt = item()?.prompt
|
||||||
|
if (!prompt || prompt.type !== "text") return
|
||||||
|
return prompt
|
||||||
|
})
|
||||||
|
const select = createMemo(() => {
|
||||||
|
const prompt = item()?.prompt
|
||||||
|
if (!prompt || prompt.type !== "select") return
|
||||||
|
return prompt
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
|
||||||
|
<Switch>
|
||||||
|
<Match when={item()?.prompt.type === "text"}>
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
label={text()?.message ?? ""}
|
||||||
|
placeholder={text()?.placeholder}
|
||||||
|
value={text() ? (formStore.value[text()!.key] ?? "") : ""}
|
||||||
|
onChange={(value) => {
|
||||||
|
const prompt = text()
|
||||||
|
if (!prompt) return
|
||||||
|
setFormStore("value", prompt.key, value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button class="w-auto" type="submit" size="large" variant="primary" disabled={!valid()}>
|
||||||
|
{language.t("common.continue")}
|
||||||
|
</Button>
|
||||||
|
</Match>
|
||||||
|
<Match when={item()?.prompt.type === "select"}>
|
||||||
|
<div class="w-full flex flex-col gap-1.5">
|
||||||
|
<div class="text-14-regular text-text-base">{select()?.message}</div>
|
||||||
|
<div>
|
||||||
|
<List
|
||||||
|
items={select()?.options ?? []}
|
||||||
|
key={(x) => x.value}
|
||||||
|
current={select()?.options.find((x) => x.value === formStore.value[select()!.key])}
|
||||||
|
onSelect={(value) => {
|
||||||
|
if (!value) return
|
||||||
|
const prompt = select()
|
||||||
|
if (!prompt) return
|
||||||
|
const nextValue = {
|
||||||
|
...formStore.value,
|
||||||
|
[prompt.key]: value.value,
|
||||||
|
}
|
||||||
|
setFormStore("value", prompt.key, value.value)
|
||||||
|
void next(item()!.index, nextValue)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(option) => (
|
||||||
|
<div class="w-full flex items-center gap-x-2">
|
||||||
|
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
|
||||||
|
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
|
||||||
|
</div>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
<span class="text-14-regular text-text-weak">{option.hint}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let listRef: ListRef | undefined
|
let listRef: ListRef | undefined
|
||||||
function handleKey(e: KeyboardEvent) {
|
function handleKey(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
|
if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
|
||||||
@@ -172,8 +310,12 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
listRef?.onKeyDown(e)
|
listRef?.onKeyDown(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
let auto = false
|
||||||
|
createEffect(() => {
|
||||||
|
if (auto) return
|
||||||
|
if (loading()) return
|
||||||
if (methods().length === 1) {
|
if (methods().length === 1) {
|
||||||
|
auto = true
|
||||||
selectMethod(0)
|
selectMethod(0)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -301,7 +443,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
error={formStore.error}
|
error={formStore.error}
|
||||||
/>
|
/>
|
||||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||||
{language.t("common.submit")}
|
{language.t("common.continue")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -314,12 +456,6 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
error: undefined as string | undefined,
|
error: undefined as string | undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (store.authorization?.method === "code" && store.authorization?.url) {
|
|
||||||
platform.openLink(store.authorization.url)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
@@ -368,7 +504,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
error={formStore.error}
|
error={formStore.error}
|
||||||
/>
|
/>
|
||||||
<Button class="w-auto" type="submit" size="large" variant="primary">
|
<Button class="w-auto" type="submit" size="large" variant="primary">
|
||||||
{language.t("common.submit")}
|
{language.t("common.continue")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -386,10 +522,6 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (store.authorization?.url) {
|
|
||||||
platform.openLink(store.authorization.url)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await globalSDK.client.provider.oauth
|
const result = await globalSDK.client.provider.oauth
|
||||||
.callback({
|
.callback({
|
||||||
providerID: props.provider,
|
providerID: props.provider,
|
||||||
@@ -459,6 +591,14 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
||||||
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
||||||
<Switch>
|
<Switch>
|
||||||
|
<Match when={loading()}>
|
||||||
|
<div class="text-14-regular text-text-base">
|
||||||
|
<div class="flex items-center gap-x-2">
|
||||||
|
<Spinner />
|
||||||
|
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
<Match when={store.methodIndex === undefined}>
|
<Match when={store.methodIndex === undefined}>
|
||||||
<MethodSelection />
|
<MethodSelection />
|
||||||
</Match>
|
</Match>
|
||||||
@@ -470,6 +610,9 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
|
<Match when={store.state === "prompt"}>
|
||||||
|
<OAuthPromptsView />
|
||||||
|
</Match>
|
||||||
<Match when={store.state === "error"}>
|
<Match when={store.state === "error"}>
|
||||||
<div class="text-14-regular text-text-base">
|
<div class="text-14-regular text-text-base">
|
||||||
<div class="flex items-center gap-x-2">
|
<div class="flex items-center gap-x-2">
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
||||||
|
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
||||||
|
|
||||||
|
type Translator = (key: string, vars?: Record<string, string | number | boolean>) => string
|
||||||
|
|
||||||
|
export type ModelErr = {
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HeaderErr = {
|
||||||
|
key?: string
|
||||||
|
value?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ModelRow = {
|
||||||
|
row: string
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
err: ModelErr
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HeaderRow = {
|
||||||
|
row: string
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
err: HeaderErr
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FormState = {
|
||||||
|
providerID: string
|
||||||
|
name: string
|
||||||
|
baseURL: string
|
||||||
|
apiKey: string
|
||||||
|
models: ModelRow[]
|
||||||
|
headers: HeaderRow[]
|
||||||
|
err: {
|
||||||
|
providerID?: string
|
||||||
|
name?: string
|
||||||
|
baseURL?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValidateArgs = {
|
||||||
|
form: FormState
|
||||||
|
t: Translator
|
||||||
|
disabledProviders: string[]
|
||||||
|
existingProviderIDs: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCustomProvider(input: ValidateArgs) {
|
||||||
|
const providerID = input.form.providerID.trim()
|
||||||
|
const name = input.form.name.trim()
|
||||||
|
const baseURL = input.form.baseURL.trim()
|
||||||
|
const apiKey = input.form.apiKey.trim()
|
||||||
|
|
||||||
|
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
||||||
|
const key = apiKey && !env ? apiKey : undefined
|
||||||
|
|
||||||
|
const idError = !providerID
|
||||||
|
? input.t("provider.custom.error.providerID.required")
|
||||||
|
: !PROVIDER_ID.test(providerID)
|
||||||
|
? input.t("provider.custom.error.providerID.format")
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
|
||||||
|
const urlError = !baseURL
|
||||||
|
? input.t("provider.custom.error.baseURL.required")
|
||||||
|
: !/^https?:\/\//.test(baseURL)
|
||||||
|
? input.t("provider.custom.error.baseURL.format")
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const disabled = input.disabledProviders.includes(providerID)
|
||||||
|
const existsError = idError
|
||||||
|
? undefined
|
||||||
|
: input.existingProviderIDs.has(providerID) && !disabled
|
||||||
|
? input.t("provider.custom.error.providerID.exists")
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const seenModels = new Set<string>()
|
||||||
|
const models = input.form.models.map((m) => {
|
||||||
|
const id = m.id.trim()
|
||||||
|
const idError = !id
|
||||||
|
? input.t("provider.custom.error.required")
|
||||||
|
: seenModels.has(id)
|
||||||
|
? input.t("provider.custom.error.duplicate")
|
||||||
|
: (() => {
|
||||||
|
seenModels.add(id)
|
||||||
|
return undefined
|
||||||
|
})()
|
||||||
|
const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
|
||||||
|
return { id: idError, name: nameError }
|
||||||
|
})
|
||||||
|
const modelsValid = models.every((m) => !m.id && !m.name)
|
||||||
|
const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
||||||
|
|
||||||
|
const seenHeaders = new Set<string>()
|
||||||
|
const headers = input.form.headers.map((h) => {
|
||||||
|
const key = h.key.trim()
|
||||||
|
const value = h.value.trim()
|
||||||
|
|
||||||
|
if (!key && !value) return {}
|
||||||
|
const keyError = !key
|
||||||
|
? input.t("provider.custom.error.required")
|
||||||
|
: seenHeaders.has(key.toLowerCase())
|
||||||
|
? input.t("provider.custom.error.duplicate")
|
||||||
|
: (() => {
|
||||||
|
seenHeaders.add(key.toLowerCase())
|
||||||
|
return undefined
|
||||||
|
})()
|
||||||
|
const valueError = !value ? input.t("provider.custom.error.required") : undefined
|
||||||
|
return { key: keyError, value: valueError }
|
||||||
|
})
|
||||||
|
const headersValid = headers.every((h) => !h.key && !h.value)
|
||||||
|
const headerConfig = Object.fromEntries(
|
||||||
|
input.form.headers
|
||||||
|
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
||||||
|
.filter((h) => !!h.key && !!h.value)
|
||||||
|
.map((h) => [h.key, h.value]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const err = {
|
||||||
|
providerID: idError ?? existsError,
|
||||||
|
name: nameError,
|
||||||
|
baseURL: urlError,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
||||||
|
if (!ok) return { err, models, headers }
|
||||||
|
|
||||||
|
return {
|
||||||
|
err,
|
||||||
|
models,
|
||||||
|
headers,
|
||||||
|
result: {
|
||||||
|
providerID,
|
||||||
|
name,
|
||||||
|
key,
|
||||||
|
config: {
|
||||||
|
npm: OPENAI_COMPATIBLE,
|
||||||
|
name,
|
||||||
|
...(env ? { env: [env] } : {}),
|
||||||
|
options: {
|
||||||
|
baseURL,
|
||||||
|
...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
|
||||||
|
},
|
||||||
|
models: modelConfig,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let row = 0
|
||||||
|
|
||||||
|
const nextRow = () => `row-${row++}`
|
||||||
|
|
||||||
|
export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} })
|
||||||
|
export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} })
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { validateCustomProvider } from "./dialog-custom-provider-form"
|
||||||
|
|
||||||
|
const t = (key: string) => key
|
||||||
|
|
||||||
|
describe("validateCustomProvider", () => {
|
||||||
|
test("builds trimmed config payload", () => {
|
||||||
|
const result = validateCustomProvider({
|
||||||
|
form: {
|
||||||
|
providerID: "custom-provider",
|
||||||
|
name: " Custom Provider ",
|
||||||
|
baseURL: "https://api.example.com ",
|
||||||
|
apiKey: " {env: CUSTOM_PROVIDER_KEY} ",
|
||||||
|
models: [{ row: "m0", id: " model-a ", name: " Model A ", err: {} }],
|
||||||
|
headers: [
|
||||||
|
{ row: "h0", key: " X-Test ", value: " enabled ", err: {} },
|
||||||
|
{ row: "h1", key: "", value: "", err: {} },
|
||||||
|
],
|
||||||
|
err: {},
|
||||||
|
},
|
||||||
|
t,
|
||||||
|
disabledProviders: [],
|
||||||
|
existingProviderIDs: new Set(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.result).toEqual({
|
||||||
|
providerID: "custom-provider",
|
||||||
|
name: "Custom Provider",
|
||||||
|
key: undefined,
|
||||||
|
config: {
|
||||||
|
npm: "@ai-sdk/openai-compatible",
|
||||||
|
name: "Custom Provider",
|
||||||
|
env: ["CUSTOM_PROVIDER_KEY"],
|
||||||
|
options: {
|
||||||
|
baseURL: "https://api.example.com",
|
||||||
|
headers: {
|
||||||
|
"X-Test": "enabled",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
models: {
|
||||||
|
"model-a": { name: "Model A" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("flags duplicate rows and allows reconnecting disabled providers", () => {
|
||||||
|
const result = validateCustomProvider({
|
||||||
|
form: {
|
||||||
|
providerID: "custom-provider",
|
||||||
|
name: "Provider",
|
||||||
|
baseURL: "https://api.example.com",
|
||||||
|
apiKey: "secret",
|
||||||
|
models: [
|
||||||
|
{ row: "m0", id: "model-a", name: "Model A", err: {} },
|
||||||
|
{ row: "m1", id: "model-a", name: "Model A 2", err: {} },
|
||||||
|
],
|
||||||
|
headers: [
|
||||||
|
{ row: "h0", key: "Authorization", value: "one", err: {} },
|
||||||
|
{ row: "h1", key: "authorization", value: "two", err: {} },
|
||||||
|
],
|
||||||
|
err: {},
|
||||||
|
},
|
||||||
|
t,
|
||||||
|
disabledProviders: ["custom-provider"],
|
||||||
|
existingProviderIDs: new Set(["custom-provider"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.result).toBeUndefined()
|
||||||
|
expect(result.err.providerID).toBeUndefined()
|
||||||
|
expect(result.models[1]).toEqual({
|
||||||
|
id: "provider.custom.error.duplicate",
|
||||||
|
name: undefined,
|
||||||
|
})
|
||||||
|
expect(result.headers[1]).toEqual({
|
||||||
|
key: "provider.custom.error.duplicate",
|
||||||
|
value: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,160 +3,18 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { For } from "solid-js"
|
import { batch, For } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Link } from "@/components/link"
|
import { Link } from "@/components/link"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
|
|
||||||
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
|
|
||||||
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
|
|
||||||
|
|
||||||
type Translator = ReturnType<typeof useLanguage>["t"]
|
|
||||||
|
|
||||||
type ModelRow = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type HeaderRow = {
|
|
||||||
key: string
|
|
||||||
value: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type FormState = {
|
|
||||||
providerID: string
|
|
||||||
name: string
|
|
||||||
baseURL: string
|
|
||||||
apiKey: string
|
|
||||||
models: ModelRow[]
|
|
||||||
headers: HeaderRow[]
|
|
||||||
saving: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
type FormErrors = {
|
|
||||||
providerID: string | undefined
|
|
||||||
name: string | undefined
|
|
||||||
baseURL: string | undefined
|
|
||||||
models: Array<{ id?: string; name?: string }>
|
|
||||||
headers: Array<{ key?: string; value?: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
type ValidateArgs = {
|
|
||||||
form: FormState
|
|
||||||
t: Translator
|
|
||||||
disabledProviders: string[]
|
|
||||||
existingProviderIDs: Set<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateCustomProvider(input: ValidateArgs) {
|
|
||||||
const providerID = input.form.providerID.trim()
|
|
||||||
const name = input.form.name.trim()
|
|
||||||
const baseURL = input.form.baseURL.trim()
|
|
||||||
const apiKey = input.form.apiKey.trim()
|
|
||||||
|
|
||||||
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
|
|
||||||
const key = apiKey && !env ? apiKey : undefined
|
|
||||||
|
|
||||||
const idError = !providerID
|
|
||||||
? input.t("provider.custom.error.providerID.required")
|
|
||||||
: !PROVIDER_ID.test(providerID)
|
|
||||||
? input.t("provider.custom.error.providerID.format")
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
|
|
||||||
const urlError = !baseURL
|
|
||||||
? input.t("provider.custom.error.baseURL.required")
|
|
||||||
: !/^https?:\/\//.test(baseURL)
|
|
||||||
? input.t("provider.custom.error.baseURL.format")
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const disabled = input.disabledProviders.includes(providerID)
|
|
||||||
const existsError = idError
|
|
||||||
? undefined
|
|
||||||
: input.existingProviderIDs.has(providerID) && !disabled
|
|
||||||
? input.t("provider.custom.error.providerID.exists")
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const seenModels = new Set<string>()
|
|
||||||
const modelErrors = input.form.models.map((m) => {
|
|
||||||
const id = m.id.trim()
|
|
||||||
const modelIdError = !id
|
|
||||||
? input.t("provider.custom.error.required")
|
|
||||||
: seenModels.has(id)
|
|
||||||
? input.t("provider.custom.error.duplicate")
|
|
||||||
: (() => {
|
|
||||||
seenModels.add(id)
|
|
||||||
return undefined
|
|
||||||
})()
|
|
||||||
const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
|
|
||||||
return { id: modelIdError, name: modelNameError }
|
|
||||||
})
|
|
||||||
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
|
|
||||||
const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
|
|
||||||
|
|
||||||
const seenHeaders = new Set<string>()
|
|
||||||
const headerErrors = input.form.headers.map((h) => {
|
|
||||||
const key = h.key.trim()
|
|
||||||
const value = h.value.trim()
|
|
||||||
|
|
||||||
if (!key && !value) return {}
|
|
||||||
const keyError = !key
|
|
||||||
? input.t("provider.custom.error.required")
|
|
||||||
: seenHeaders.has(key.toLowerCase())
|
|
||||||
? input.t("provider.custom.error.duplicate")
|
|
||||||
: (() => {
|
|
||||||
seenHeaders.add(key.toLowerCase())
|
|
||||||
return undefined
|
|
||||||
})()
|
|
||||||
const valueError = !value ? input.t("provider.custom.error.required") : undefined
|
|
||||||
return { key: keyError, value: valueError }
|
|
||||||
})
|
|
||||||
const headersValid = headerErrors.every((h) => !h.key && !h.value)
|
|
||||||
const headers = Object.fromEntries(
|
|
||||||
input.form.headers
|
|
||||||
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
|
|
||||||
.filter((h) => !!h.key && !!h.value)
|
|
||||||
.map((h) => [h.key, h.value]),
|
|
||||||
)
|
|
||||||
|
|
||||||
const errors: FormErrors = {
|
|
||||||
providerID: idError ?? existsError,
|
|
||||||
name: nameError,
|
|
||||||
baseURL: urlError,
|
|
||||||
models: modelErrors,
|
|
||||||
headers: headerErrors,
|
|
||||||
}
|
|
||||||
|
|
||||||
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
|
|
||||||
if (!ok) return { errors }
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
baseURL,
|
|
||||||
...(Object.keys(headers).length ? { headers } : {}),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
errors,
|
|
||||||
result: {
|
|
||||||
providerID,
|
|
||||||
name,
|
|
||||||
key,
|
|
||||||
config: {
|
|
||||||
npm: OPENAI_COMPATIBLE,
|
|
||||||
name,
|
|
||||||
...(env ? { env: [env] } : {}),
|
|
||||||
options,
|
|
||||||
models,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
back?: "providers" | "close"
|
back?: "providers" | "close"
|
||||||
}
|
}
|
||||||
@@ -172,17 +30,9 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
name: "",
|
name: "",
|
||||||
baseURL: "",
|
baseURL: "",
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
models: [{ id: "", name: "" }],
|
models: [modelRow()],
|
||||||
headers: [{ key: "", value: "" }],
|
headers: [headerRow()],
|
||||||
saving: false,
|
err: {},
|
||||||
})
|
|
||||||
|
|
||||||
const [errors, setErrors] = createStore<FormErrors>({
|
|
||||||
providerID: undefined,
|
|
||||||
name: undefined,
|
|
||||||
baseURL: undefined,
|
|
||||||
models: [{}],
|
|
||||||
headers: [{}],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
@@ -194,25 +44,61 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addModel = () => {
|
const addModel = () => {
|
||||||
setForm("models", (v) => [...v, { id: "", name: "" }])
|
setForm(
|
||||||
setErrors("models", (v) => [...v, {}])
|
"models",
|
||||||
|
produce((rows) => {
|
||||||
|
rows.push(modelRow())
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeModel = (index: number) => {
|
const removeModel = (index: number) => {
|
||||||
if (form.models.length <= 1) return
|
if (form.models.length <= 1) return
|
||||||
setForm("models", (v) => v.filter((_, i) => i !== index))
|
setForm(
|
||||||
setErrors("models", (v) => v.filter((_, i) => i !== index))
|
"models",
|
||||||
|
produce((rows) => {
|
||||||
|
rows.splice(index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const addHeader = () => {
|
const addHeader = () => {
|
||||||
setForm("headers", (v) => [...v, { key: "", value: "" }])
|
setForm(
|
||||||
setErrors("headers", (v) => [...v, {}])
|
"headers",
|
||||||
|
produce((rows) => {
|
||||||
|
rows.push(headerRow())
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeHeader = (index: number) => {
|
const removeHeader = (index: number) => {
|
||||||
if (form.headers.length <= 1) return
|
if (form.headers.length <= 1) return
|
||||||
setForm("headers", (v) => v.filter((_, i) => i !== index))
|
setForm(
|
||||||
setErrors("headers", (v) => v.filter((_, i) => i !== index))
|
"headers",
|
||||||
|
produce((rows) => {
|
||||||
|
rows.splice(index, 1)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setField = (key: "providerID" | "name" | "baseURL" | "apiKey", value: string) => {
|
||||||
|
setForm(key, value)
|
||||||
|
if (key === "apiKey") return
|
||||||
|
setForm("err", key, undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setModel = (index: number, key: "id" | "name", value: string) => {
|
||||||
|
batch(() => {
|
||||||
|
setForm("models", index, key, value)
|
||||||
|
setForm("models", index, "err", key, undefined)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setHeader = (index: number, key: "key" | "value", value: string) => {
|
||||||
|
batch(() => {
|
||||||
|
setForm("headers", index, key, value)
|
||||||
|
setForm("headers", index, "err", key, undefined)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
@@ -222,52 +108,57 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
disabledProviders: globalSync.data.config.disabled_providers ?? [],
|
disabledProviders: globalSync.data.config.disabled_providers ?? [],
|
||||||
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
|
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
|
||||||
})
|
})
|
||||||
setErrors(output.errors)
|
batch(() => {
|
||||||
|
setForm("err", output.err)
|
||||||
|
output.models.forEach((err, index) => setForm("models", index, "err", err))
|
||||||
|
output.headers.forEach((err, index) => setForm("headers", index, "err", err))
|
||||||
|
})
|
||||||
return output.result
|
return output.result
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = async (e: SubmitEvent) => {
|
const saveMutation = useMutation(() => ({
|
||||||
e.preventDefault()
|
mutationFn: async (result: NonNullable<ReturnType<typeof validate>>) => {
|
||||||
if (form.saving) return
|
const disabledProviders = globalSync.data.config.disabled_providers ?? []
|
||||||
|
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
||||||
|
|
||||||
const result = validate()
|
if (result.key) {
|
||||||
if (!result) return
|
await globalSDK.client.auth.set({
|
||||||
|
|
||||||
setForm("saving", true)
|
|
||||||
|
|
||||||
const disabledProviders = globalSync.data.config.disabled_providers ?? []
|
|
||||||
const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
|
|
||||||
|
|
||||||
const auth = result.key
|
|
||||||
? globalSDK.client.auth.set({
|
|
||||||
providerID: result.providerID,
|
providerID: result.providerID,
|
||||||
auth: {
|
auth: {
|
||||||
type: "api",
|
type: "api",
|
||||||
key: result.key,
|
key: result.key,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: Promise.resolve()
|
}
|
||||||
|
|
||||||
auth
|
await globalSync.updateConfig({
|
||||||
.then(() =>
|
provider: { [result.providerID]: result.config },
|
||||||
globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }),
|
disabled_providers: nextDisabled,
|
||||||
)
|
|
||||||
.then(() => {
|
|
||||||
dialog.close()
|
|
||||||
showToast({
|
|
||||||
variant: "success",
|
|
||||||
icon: "circle-check",
|
|
||||||
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
|
|
||||||
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
return result
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
},
|
||||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
onSuccess: (result) => {
|
||||||
})
|
dialog.close()
|
||||||
.finally(() => {
|
showToast({
|
||||||
setForm("saving", false)
|
variant: "success",
|
||||||
|
icon: "circle-check",
|
||||||
|
title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
|
||||||
|
description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const save = (e: SubmitEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (saveMutation.isPending) return
|
||||||
|
|
||||||
|
const result = validate()
|
||||||
|
if (!result) return
|
||||||
|
saveMutation.mutate(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -305,32 +196,32 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||||
description={language.t("provider.custom.field.providerID.description")}
|
description={language.t("provider.custom.field.providerID.description")}
|
||||||
value={form.providerID}
|
value={form.providerID}
|
||||||
onChange={(v) => setForm("providerID", v)}
|
onChange={(v) => setField("providerID", v)}
|
||||||
validationState={errors.providerID ? "invalid" : undefined}
|
validationState={form.err.providerID ? "invalid" : undefined}
|
||||||
error={errors.providerID}
|
error={form.err.providerID}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.field.name.label")}
|
label={language.t("provider.custom.field.name.label")}
|
||||||
placeholder={language.t("provider.custom.field.name.placeholder")}
|
placeholder={language.t("provider.custom.field.name.placeholder")}
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={(v) => setForm("name", v)}
|
onChange={(v) => setField("name", v)}
|
||||||
validationState={errors.name ? "invalid" : undefined}
|
validationState={form.err.name ? "invalid" : undefined}
|
||||||
error={errors.name}
|
error={form.err.name}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.field.baseURL.label")}
|
label={language.t("provider.custom.field.baseURL.label")}
|
||||||
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
||||||
value={form.baseURL}
|
value={form.baseURL}
|
||||||
onChange={(v) => setForm("baseURL", v)}
|
onChange={(v) => setField("baseURL", v)}
|
||||||
validationState={errors.baseURL ? "invalid" : undefined}
|
validationState={form.err.baseURL ? "invalid" : undefined}
|
||||||
error={errors.baseURL}
|
error={form.err.baseURL}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.field.apiKey.label")}
|
label={language.t("provider.custom.field.apiKey.label")}
|
||||||
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
||||||
description={language.t("provider.custom.field.apiKey.description")}
|
description={language.t("provider.custom.field.apiKey.description")}
|
||||||
value={form.apiKey}
|
value={form.apiKey}
|
||||||
onChange={(v) => setForm("apiKey", v)}
|
onChange={(v) => setField("apiKey", v)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -338,16 +229,16 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
|
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
|
||||||
<For each={form.models}>
|
<For each={form.models}>
|
||||||
{(m, i) => (
|
{(m, i) => (
|
||||||
<div class="flex gap-2 items-start">
|
<div class="flex gap-2 items-start" data-row={m.row}>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.models.id.label")}
|
label={language.t("provider.custom.models.id.label")}
|
||||||
hideLabel
|
hideLabel
|
||||||
placeholder={language.t("provider.custom.models.id.placeholder")}
|
placeholder={language.t("provider.custom.models.id.placeholder")}
|
||||||
value={m.id}
|
value={m.id}
|
||||||
onChange={(v) => setForm("models", i(), "id", v)}
|
onChange={(v) => setModel(i(), "id", v)}
|
||||||
validationState={errors.models[i()]?.id ? "invalid" : undefined}
|
validationState={m.err.id ? "invalid" : undefined}
|
||||||
error={errors.models[i()]?.id}
|
error={m.err.id}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -356,9 +247,9 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
hideLabel
|
hideLabel
|
||||||
placeholder={language.t("provider.custom.models.name.placeholder")}
|
placeholder={language.t("provider.custom.models.name.placeholder")}
|
||||||
value={m.name}
|
value={m.name}
|
||||||
onChange={(v) => setForm("models", i(), "name", v)}
|
onChange={(v) => setModel(i(), "name", v)}
|
||||||
validationState={errors.models[i()]?.name ? "invalid" : undefined}
|
validationState={m.err.name ? "invalid" : undefined}
|
||||||
error={errors.models[i()]?.name}
|
error={m.err.name}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -382,16 +273,16 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
|
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
|
||||||
<For each={form.headers}>
|
<For each={form.headers}>
|
||||||
{(h, i) => (
|
{(h, i) => (
|
||||||
<div class="flex gap-2 items-start">
|
<div class="flex gap-2 items-start" data-row={h.row}>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<TextField
|
<TextField
|
||||||
label={language.t("provider.custom.headers.key.label")}
|
label={language.t("provider.custom.headers.key.label")}
|
||||||
hideLabel
|
hideLabel
|
||||||
placeholder={language.t("provider.custom.headers.key.placeholder")}
|
placeholder={language.t("provider.custom.headers.key.placeholder")}
|
||||||
value={h.key}
|
value={h.key}
|
||||||
onChange={(v) => setForm("headers", i(), "key", v)}
|
onChange={(v) => setHeader(i(), "key", v)}
|
||||||
validationState={errors.headers[i()]?.key ? "invalid" : undefined}
|
validationState={h.err.key ? "invalid" : undefined}
|
||||||
error={errors.headers[i()]?.key}
|
error={h.err.key}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -400,9 +291,9 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
hideLabel
|
hideLabel
|
||||||
placeholder={language.t("provider.custom.headers.value.placeholder")}
|
placeholder={language.t("provider.custom.headers.value.placeholder")}
|
||||||
value={h.value}
|
value={h.value}
|
||||||
onChange={(v) => setForm("headers", i(), "value", v)}
|
onChange={(v) => setHeader(i(), "value", v)}
|
||||||
validationState={errors.headers[i()]?.value ? "invalid" : undefined}
|
validationState={h.err.value ? "invalid" : undefined}
|
||||||
error={errors.headers[i()]?.value}
|
error={h.err.value}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
<IconButton
|
||||||
@@ -422,8 +313,14 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
|
<Button
|
||||||
{form.saving ? language.t("common.saving") : language.t("common.submit")}
|
class="w-auto self-start"
|
||||||
|
type="submit"
|
||||||
|
size="large"
|
||||||
|
variant="primary"
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Button } from "@opencode-ai/ui/button"
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { createMemo, For, Show } from "solid-js"
|
import { createMemo, For, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
@@ -28,7 +29,6 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
color: props.project.icon?.color || "pink",
|
color: props.project.icon?.color || "pink",
|
||||||
iconUrl: props.project.icon?.override || "",
|
iconUrl: props.project.icon?.override || "",
|
||||||
startup: props.project.commands?.start ?? "",
|
startup: props.project.commands?.start ?? "",
|
||||||
saving: false,
|
|
||||||
dragOver: false,
|
dragOver: false,
|
||||||
iconHover: false,
|
iconHover: false,
|
||||||
})
|
})
|
||||||
@@ -71,38 +71,37 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
setStore("iconUrl", "")
|
setStore("iconUrl", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
const saveMutation = useMutation(() => ({
|
||||||
e.preventDefault()
|
mutationFn: async () => {
|
||||||
|
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
||||||
|
const start = store.startup.trim()
|
||||||
|
|
||||||
await Promise.resolve()
|
if (props.project.id && props.project.id !== "global") {
|
||||||
.then(async () => {
|
await globalSDK.client.project.update({
|
||||||
setStore("saving", true)
|
projectID: props.project.id,
|
||||||
const name = store.name.trim() === folderName() ? "" : store.name.trim()
|
directory: props.project.worktree,
|
||||||
const start = store.startup.trim()
|
|
||||||
|
|
||||||
if (props.project.id && props.project.id !== "global") {
|
|
||||||
await globalSDK.client.project.update({
|
|
||||||
projectID: props.project.id,
|
|
||||||
directory: props.project.worktree,
|
|
||||||
name,
|
|
||||||
icon: { color: store.color, override: store.iconUrl },
|
|
||||||
commands: { start },
|
|
||||||
})
|
|
||||||
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
|
||||||
dialog.close()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
globalSync.project.meta(props.project.worktree, {
|
|
||||||
name,
|
name,
|
||||||
icon: { color: store.color, override: store.iconUrl || undefined },
|
icon: { color: store.color, override: store.iconUrl },
|
||||||
commands: { start: start || undefined },
|
commands: { start },
|
||||||
})
|
})
|
||||||
|
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
|
||||||
dialog.close()
|
dialog.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
globalSync.project.meta(props.project.worktree, {
|
||||||
|
name,
|
||||||
|
icon: { color: store.color, override: store.iconUrl || undefined },
|
||||||
|
commands: { start: start || undefined },
|
||||||
})
|
})
|
||||||
.finally(() => {
|
dialog.close()
|
||||||
setStore("saving", false)
|
},
|
||||||
})
|
}))
|
||||||
|
|
||||||
|
function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (saveMutation.isPending) return
|
||||||
|
saveMutation.mutate()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -246,8 +245,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
|||||||
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||||
{language.t("common.cancel")}
|
{language.t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" variant="primary" size="large" disabled={store.saving}>
|
<Button type="submit" variant="primary" size="large" disabled={saveMutation.isPending}>
|
||||||
{store.saving ? language.t("common.saving") : language.t("common.save")}
|
{saveMutation.isPending ? language.t("common.saving") : language.t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export const DialogFork: Component = () => {
|
|||||||
directory: sdk.directory,
|
directory: sdk.directory,
|
||||||
attachmentName: language.t("common.attachment"),
|
attachmentName: language.t("common.attachment"),
|
||||||
})
|
})
|
||||||
|
const dir = base64Encode(sdk.directory)
|
||||||
|
|
||||||
sdk.client.session
|
sdk.client.session
|
||||||
.fork({ sessionID, messageID: item.id })
|
.fork({ sessionID, messageID: item.id })
|
||||||
@@ -75,10 +76,8 @@ export const DialogFork: Component = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
dialog.close()
|
dialog.close()
|
||||||
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
|
prompt.set(restored, undefined, { dir, id: forked.data.id })
|
||||||
requestAnimationFrame(() => {
|
navigate(`/${dir}/session/${forked.data.id}`)
|
||||||
prompt.set(restored)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Keybind } from "@opencode-ai/ui/keybind"
|
|||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
||||||
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
@@ -14,6 +14,8 @@ import { useGlobalSync } from "@/context/global-sync"
|
|||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { getRelativeTime } from "@/utils/time"
|
import { getRelativeTime } from "@/utils/time"
|
||||||
|
|
||||||
@@ -132,9 +134,14 @@ function createFileEntries(props: {
|
|||||||
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
}) {
|
}) {
|
||||||
|
const tabState = createSessionTabs({
|
||||||
|
tabs: props.tabs,
|
||||||
|
pathFromTab: props.file.pathFromTab,
|
||||||
|
normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
|
||||||
|
})
|
||||||
const recent = createMemo(() => {
|
const recent = createMemo(() => {
|
||||||
const all = props.tabs().all()
|
const all = tabState.openedTabs()
|
||||||
const active = props.tabs().active()
|
const active = tabState.activeFileTab()
|
||||||
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
const order = active ? [active, ...all.filter((item) => item !== active)] : all
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const category = props.language.t("palette.group.files")
|
const category = props.language.t("palette.group.files")
|
||||||
@@ -259,14 +266,11 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const params = useParams()
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const globalSDK = useGlobalSDK()
|
const globalSDK = useGlobalSDK()
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
|
const { params, tabs, view } = useSessionLayout()
|
||||||
const filesOnly = () => props.mode === "files"
|
const filesOnly = () => props.mode === "files"
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
|
||||||
const view = createMemo(() => layout.view(sessionKey))
|
|
||||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||||
const [grouped, setGrouped] = createSignal(false)
|
const [grouped, setGrouped] = createSignal(false)
|
||||||
const commandEntries = createCommandEntries({ filesOnly, command, language })
|
const commandEntries = createCommandEntries({ filesOnly, command, language })
|
||||||
@@ -422,7 +426,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={item.keybind}>
|
<Show when={item.keybind}>
|
||||||
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
|
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "", language.t)}</Keybind>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Match>
|
</Match>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Component, createMemo, createSignal, Show } from "solid-js"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
|
import { Component, createMemo, Show } from "solid-js"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
@@ -17,7 +18,6 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const [loading, setLoading] = createSignal<string | null>(null)
|
|
||||||
|
|
||||||
const items = createMemo(() =>
|
const items = createMemo(() =>
|
||||||
Object.entries(sync.data.mcp ?? {})
|
Object.entries(sync.data.mcp ?? {})
|
||||||
@@ -25,10 +25,8 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggle = async (name: string) => {
|
const toggle = useMutation(() => ({
|
||||||
if (loading()) return
|
mutationFn: async (name: string) => {
|
||||||
setLoading(name)
|
|
||||||
try {
|
|
||||||
const status = sync.data.mcp[name]
|
const status = sync.data.mcp[name]
|
||||||
if (status?.status === "connected") {
|
if (status?.status === "connected") {
|
||||||
await sdk.client.mcp.disconnect({ name })
|
await sdk.client.mcp.disconnect({ name })
|
||||||
@@ -38,10 +36,8 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
|
|
||||||
const result = await sdk.client.mcp.status()
|
const result = await sdk.client.mcp.status()
|
||||||
if (result.data) sync.set("mcp", result.data)
|
if (result.data) sync.set("mcp", result.data)
|
||||||
} finally {
|
},
|
||||||
setLoading(null)
|
}))
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
|
||||||
const totalCount = createMemo(() => items().length)
|
const totalCount = createMemo(() => items().length)
|
||||||
@@ -59,7 +55,8 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
filterKeys={["name", "status"]}
|
filterKeys={["name", "status"]}
|
||||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (x) toggle(x.name)
|
if (!x || toggle.isPending) return
|
||||||
|
toggle.mutate(x.name)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
@@ -83,7 +80,7 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
<Show when={statusLabel()}>
|
<Show when={statusLabel()}>
|
||||||
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={loading() === i.name}>
|
<Show when={toggle.isPending && toggle.variables === i.name}>
|
||||||
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,7 +89,14 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
|
<Switch
|
||||||
|
checked={enabled()}
|
||||||
|
disabled={toggle.isPending && toggle.variables === i.name}
|
||||||
|
onChange={() => {
|
||||||
|
if (toggle.isPending) return
|
||||||
|
toggle.mutate(i.name)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import { DialogSelectProvider } from "./dialog-select-provider"
|
|||||||
import { ModelTooltip } from "./model-tooltip"
|
import { ModelTooltip } from "./model-tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
export const DialogSelectModelUnpaid: Component = () => {
|
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||||
const local = useLocal()
|
|
||||||
|
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
|
||||||
|
const model = props.model ?? useLocal().model
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -35,8 +37,8 @@ export const DialogSelectModelUnpaid: Component = () => {
|
|||||||
<List
|
<List
|
||||||
class="[&_[data-slot=list-scroll]]:overflow-visible"
|
class="[&_[data-slot=list-scroll]]:overflow-visible"
|
||||||
ref={(ref) => (listRef = ref)}
|
ref={(ref) => (listRef = ref)}
|
||||||
items={local.model.list}
|
items={model.list}
|
||||||
current={local.model.current()}
|
current={model.current()}
|
||||||
key={(x) => `${x.provider.id}:${x.id}`}
|
key={(x) => `${x.provider.id}:${x.id}`}
|
||||||
itemWrapper={(item, node) => (
|
itemWrapper={(item, node) => (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -55,7 +57,7 @@ export const DialogSelectModelUnpaid: Component = () => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||||
recent: true,
|
recent: true,
|
||||||
})
|
})
|
||||||
dialog.close()
|
dialog.close()
|
||||||
|
|||||||
@@ -18,19 +18,22 @@ import { useLanguage } from "@/context/language"
|
|||||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||||
provider === "opencode" && (!cost || cost.input === 0)
|
provider === "opencode" && (!cost || cost.input === 0)
|
||||||
|
|
||||||
|
type ModelState = ReturnType<typeof useLocal>["model"]
|
||||||
|
|
||||||
const ModelList: Component<{
|
const ModelList: Component<{
|
||||||
provider?: string
|
provider?: string
|
||||||
class?: string
|
class?: string
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
action?: JSX.Element
|
action?: JSX.Element
|
||||||
|
model?: ModelState
|
||||||
}> = (props) => {
|
}> = (props) => {
|
||||||
const local = useLocal()
|
const model = props.model ?? useLocal().model
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const models = createMemo(() =>
|
const models = createMemo(() =>
|
||||||
local.model
|
model
|
||||||
.list()
|
.list()
|
||||||
.filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id }))
|
.filter((m) => model.visible({ modelID: m.id, providerID: m.provider.id }))
|
||||||
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
|
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,7 +44,7 @@ const ModelList: Component<{
|
|||||||
emptyMessage={language.t("dialog.model.empty")}
|
emptyMessage={language.t("dialog.model.empty")}
|
||||||
key={(x) => `${x.provider.id}:${x.id}`}
|
key={(x) => `${x.provider.id}:${x.id}`}
|
||||||
items={models}
|
items={models}
|
||||||
current={local.model.current()}
|
current={model.current()}
|
||||||
filterKeys={["provider.name", "name", "id"]}
|
filterKeys={["provider.name", "name", "id"]}
|
||||||
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
sortBy={(a, b) => a.name.localeCompare(b.name)}
|
||||||
groupBy={(x) => x.provider.name}
|
groupBy={(x) => x.provider.name}
|
||||||
@@ -63,7 +66,7 @@ const ModelList: Component<{
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
|
||||||
recent: true,
|
recent: true,
|
||||||
})
|
})
|
||||||
props.onSelect()
|
props.onSelect()
|
||||||
@@ -88,6 +91,7 @@ type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "a
|
|||||||
|
|
||||||
export function ModelSelectorPopover(props: {
|
export function ModelSelectorPopover(props: {
|
||||||
provider?: string
|
provider?: string
|
||||||
|
model?: ModelState
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
triggerAs?: ValidComponent
|
triggerAs?: ValidComponent
|
||||||
triggerProps?: ModelSelectorTriggerProps
|
triggerProps?: ModelSelectorTriggerProps
|
||||||
@@ -151,6 +155,7 @@ export function ModelSelectorPopover(props: {
|
|||||||
<Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
|
<Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
|
||||||
<ModelList
|
<ModelList
|
||||||
provider={props.provider}
|
provider={props.provider}
|
||||||
|
model={props.model}
|
||||||
onSelect={() => setStore("open", false)}
|
onSelect={() => setStore("open", false)}
|
||||||
class="p-1"
|
class="p-1"
|
||||||
action={
|
action={
|
||||||
@@ -184,7 +189,7 @@ export function ModelSelectorPopover(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
@@ -202,7 +207,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ModelList provider={props.provider} onSelect={() => dialog.close()} />
|
<ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="ml-3 mt-5 mb-6 text-text-base self-start"
|
class="ml-3 mt-5 mb-6 text-text-base self-start"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Icon } from "@opencode-ai/ui/icon"
|
|||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
import { TextField } from "@opencode-ai/ui/text-field"
|
||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
|
||||||
@@ -14,7 +15,9 @@ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
|
const DEFAULT_USERNAME = "opencode"
|
||||||
|
|
||||||
interface ServerFormProps {
|
interface ServerFormProps {
|
||||||
value: string
|
value: string
|
||||||
@@ -41,13 +44,15 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
|
function useDefaultServer() {
|
||||||
const [defaultUrl, defaultUrlActions] = createResource(
|
const language = useLanguage()
|
||||||
|
const platform = usePlatform()
|
||||||
|
const [defaultKey, defaultUrlActions] = createResource(
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
try {
|
||||||
const url = await platform.getDefaultServerUrl?.()
|
const key = await platform.getDefaultServer?.()
|
||||||
if (!url) return null
|
if (!key) return null
|
||||||
return normalizeServerUrl(url) ?? null
|
return key
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showRequestError(language, err)
|
showRequestError(language, err)
|
||||||
return null
|
return null
|
||||||
@@ -56,20 +61,22 @@ function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: Re
|
|||||||
{ initialValue: null },
|
{ initialValue: null },
|
||||||
)
|
)
|
||||||
|
|
||||||
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
|
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
||||||
const setDefault = async (url: string | null) => {
|
const setDefault = async (key: ServerConnection.Key | null) => {
|
||||||
try {
|
try {
|
||||||
await platform.setDefaultServerUrl?.(url)
|
await platform.setDefaultServer?.(key)
|
||||||
defaultUrlActions.mutate(url)
|
defaultUrlActions.mutate(key)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showRequestError(language, err)
|
showRequestError(language, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { defaultUrl, canDefault, setDefault }
|
return { defaultKey, canDefault, setDefault }
|
||||||
}
|
}
|
||||||
|
|
||||||
function useServerPreview(fetcher: typeof fetch) {
|
function useServerPreview() {
|
||||||
|
const checkServerHealth = useCheckServerHealth()
|
||||||
|
|
||||||
const looksComplete = (value: string) => {
|
const looksComplete = (value: string) => {
|
||||||
const normalized = normalizeServerUrl(value)
|
const normalized = normalizeServerUrl(value)
|
||||||
if (!normalized) return false
|
if (!normalized) return false
|
||||||
@@ -92,7 +99,7 @@ function useServerPreview(fetcher: typeof fetch) {
|
|||||||
const http: ServerConnection.HttpBase = { url: normalized }
|
const http: ServerConnection.HttpBase = { url: normalized }
|
||||||
if (username) http.username = username
|
if (username) http.username = username
|
||||||
if (password) http.password = password
|
if (password) http.password = password
|
||||||
const result = await checkServerHealth(http, fetcher)
|
const result = await checkServerHealth(http)
|
||||||
setStatus(result.healthy)
|
setStatus(result.healthy)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +122,7 @@ function ServerForm(props: ServerFormProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="px-5">
|
<div class="px-5">
|
||||||
<div class="bg-surface-raised-base rounded-md p-5 flex flex-col gap-3">
|
<div class="bg-surface-base rounded-md p-5 flex flex-col gap-3">
|
||||||
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
|
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
|
||||||
<TextField
|
<TextField
|
||||||
type="text"
|
type="text"
|
||||||
@@ -143,7 +150,7 @@ function ServerForm(props: ServerFormProps) {
|
|||||||
<TextField
|
<TextField
|
||||||
type="text"
|
type="text"
|
||||||
label={language.t("dialog.server.add.username")}
|
label={language.t("dialog.server.add.username")}
|
||||||
placeholder="username"
|
placeholder={language.t("dialog.server.add.usernamePlaceholder")}
|
||||||
value={props.username}
|
value={props.username}
|
||||||
disabled={props.busy}
|
disabled={props.busy}
|
||||||
onChange={props.onUsernameChange}
|
onChange={props.onUsernameChange}
|
||||||
@@ -152,7 +159,7 @@ function ServerForm(props: ServerFormProps) {
|
|||||||
<TextField
|
<TextField
|
||||||
type="password"
|
type="password"
|
||||||
label={language.t("dialog.server.add.password")}
|
label={language.t("dialog.server.add.password")}
|
||||||
placeholder="password"
|
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||||
value={props.password}
|
value={props.password}
|
||||||
disabled={props.busy}
|
disabled={props.busy}
|
||||||
onChange={props.onPasswordChange}
|
onChange={props.onPasswordChange}
|
||||||
@@ -170,17 +177,16 @@ export function DialogSelectServer() {
|
|||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const fetcher = platform.fetch ?? globalThis.fetch
|
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
||||||
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
|
const { previewStatus } = useServerPreview()
|
||||||
const { previewStatus } = useServerPreview(fetcher)
|
const checkServerHealth = useCheckServerHealth()
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||||
addServer: {
|
addServer: {
|
||||||
url: "",
|
url: "",
|
||||||
name: "",
|
name: "",
|
||||||
username: "",
|
username: DEFAULT_USERNAME,
|
||||||
password: "",
|
password: "",
|
||||||
adding: false,
|
|
||||||
error: "",
|
error: "",
|
||||||
showForm: false,
|
showForm: false,
|
||||||
status: undefined as boolean | undefined,
|
status: undefined as boolean | undefined,
|
||||||
@@ -192,7 +198,6 @@ export function DialogSelectServer() {
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
busy: false,
|
|
||||||
status: undefined as boolean | undefined,
|
status: undefined as boolean | undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -201,9 +206,8 @@ export function DialogSelectServer() {
|
|||||||
setStore("addServer", {
|
setStore("addServer", {
|
||||||
url: "",
|
url: "",
|
||||||
name: "",
|
name: "",
|
||||||
username: "",
|
username: DEFAULT_USERNAME,
|
||||||
password: "",
|
password: "",
|
||||||
adding: false,
|
|
||||||
error: "",
|
error: "",
|
||||||
showForm: false,
|
showForm: false,
|
||||||
status: undefined,
|
status: undefined,
|
||||||
@@ -218,10 +222,78 @@ export function DialogSelectServer() {
|
|||||||
password: "",
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
status: undefined,
|
status: undefined,
|
||||||
busy: false,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addMutation = useMutation(() => ({
|
||||||
|
mutationFn: async (value: string) => {
|
||||||
|
const normalized = normalizeServerUrl(value)
|
||||||
|
if (!normalized) {
|
||||||
|
resetAdd()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn: ServerConnection.Http = {
|
||||||
|
type: "http",
|
||||||
|
http: { url: normalized },
|
||||||
|
}
|
||||||
|
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
||||||
|
if (store.addServer.password) conn.http.password = store.addServer.password
|
||||||
|
if (store.addServer.password && store.addServer.username) conn.http.username = store.addServer.username
|
||||||
|
const result = await checkServerHealth(conn.http)
|
||||||
|
if (!result.healthy) {
|
||||||
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resetAdd()
|
||||||
|
await select(conn, true)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const editMutation = useMutation(() => ({
|
||||||
|
mutationFn: async (input: { original: ServerConnection.Any; value: string }) => {
|
||||||
|
if (input.original.type !== "http") return
|
||||||
|
const normalized = normalizeServerUrl(input.value)
|
||||||
|
if (!normalized) {
|
||||||
|
resetEdit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = store.editServer.name.trim() || undefined
|
||||||
|
const username = store.editServer.username || undefined
|
||||||
|
const password = store.editServer.password || undefined
|
||||||
|
const existingName = input.original.displayName
|
||||||
|
if (
|
||||||
|
normalized === input.original.http.url &&
|
||||||
|
name === existingName &&
|
||||||
|
username === input.original.http.username &&
|
||||||
|
password === input.original.http.password
|
||||||
|
) {
|
||||||
|
resetEdit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const conn: ServerConnection.Http = {
|
||||||
|
type: "http",
|
||||||
|
displayName: name,
|
||||||
|
http: { url: normalized, username, password },
|
||||||
|
}
|
||||||
|
const result = await checkServerHealth(conn.http)
|
||||||
|
if (!result.healthy) {
|
||||||
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (normalized === input.original.http.url) {
|
||||||
|
server.add(conn)
|
||||||
|
} else {
|
||||||
|
replaceServer(input.original, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetEdit()
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
||||||
const active = server.key
|
const active = server.key
|
||||||
const newConn = server.add(next)
|
const newConn = server.add(next)
|
||||||
@@ -264,7 +336,7 @@ export function DialogSelectServer() {
|
|||||||
const results: Record<ServerConnection.Key, ServerHealth> = {}
|
const results: Record<ServerConnection.Key, ServerHealth> = {}
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
items().map(async (conn) => {
|
items().map(async (conn) => {
|
||||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
|
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
setStore("status", reconcile(results))
|
setStore("status", reconcile(results))
|
||||||
@@ -285,12 +357,12 @@ export function DialogSelectServer() {
|
|||||||
navigate("/")
|
navigate("/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
server.setActive(ServerConnection.key(conn))
|
|
||||||
navigate("/")
|
navigate("/")
|
||||||
|
queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddChange = (value: string) => {
|
const handleAddChange = (value: string) => {
|
||||||
if (store.addServer.adding) return
|
if (addMutation.isPending) return
|
||||||
setStore("addServer", { url: value, error: "" })
|
setStore("addServer", { url: value, error: "" })
|
||||||
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
||||||
setStore("addServer", { status: next }),
|
setStore("addServer", { status: next }),
|
||||||
@@ -298,12 +370,12 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddNameChange = (value: string) => {
|
const handleAddNameChange = (value: string) => {
|
||||||
if (store.addServer.adding) return
|
if (addMutation.isPending) return
|
||||||
setStore("addServer", { name: value, error: "" })
|
setStore("addServer", { name: value, error: "" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddUsernameChange = (value: string) => {
|
const handleAddUsernameChange = (value: string) => {
|
||||||
if (store.addServer.adding) return
|
if (addMutation.isPending) return
|
||||||
setStore("addServer", { username: value, error: "" })
|
setStore("addServer", { username: value, error: "" })
|
||||||
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
||||||
setStore("addServer", { status: next }),
|
setStore("addServer", { status: next }),
|
||||||
@@ -311,7 +383,7 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAddPasswordChange = (value: string) => {
|
const handleAddPasswordChange = (value: string) => {
|
||||||
if (store.addServer.adding) return
|
if (addMutation.isPending) return
|
||||||
setStore("addServer", { password: value, error: "" })
|
setStore("addServer", { password: value, error: "" })
|
||||||
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
||||||
setStore("addServer", { status: next }),
|
setStore("addServer", { status: next }),
|
||||||
@@ -319,7 +391,7 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleEditChange = (value: string) => {
|
const handleEditChange = (value: string) => {
|
||||||
if (store.editServer.busy) return
|
if (editMutation.isPending) return
|
||||||
setStore("editServer", { value, error: "" })
|
setStore("editServer", { value, error: "" })
|
||||||
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
||||||
setStore("editServer", { status: next }),
|
setStore("editServer", { status: next }),
|
||||||
@@ -327,12 +399,12 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleEditNameChange = (value: string) => {
|
const handleEditNameChange = (value: string) => {
|
||||||
if (store.editServer.busy) return
|
if (editMutation.isPending) return
|
||||||
setStore("editServer", { name: value, error: "" })
|
setStore("editServer", { name: value, error: "" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditUsernameChange = (value: string) => {
|
const handleEditUsernameChange = (value: string) => {
|
||||||
if (store.editServer.busy) return
|
if (editMutation.isPending) return
|
||||||
setStore("editServer", { username: value, error: "" })
|
setStore("editServer", { username: value, error: "" })
|
||||||
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
||||||
setStore("editServer", { status: next }),
|
setStore("editServer", { status: next }),
|
||||||
@@ -340,85 +412,13 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleEditPasswordChange = (value: string) => {
|
const handleEditPasswordChange = (value: string) => {
|
||||||
if (store.editServer.busy) return
|
if (editMutation.isPending) return
|
||||||
setStore("editServer", { password: value, error: "" })
|
setStore("editServer", { password: value, error: "" })
|
||||||
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
||||||
setStore("editServer", { status: next }),
|
setStore("editServer", { status: next }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAdd(value: string) {
|
|
||||||
if (store.addServer.adding) return
|
|
||||||
const normalized = normalizeServerUrl(value)
|
|
||||||
if (!normalized) {
|
|
||||||
resetAdd()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setStore("addServer", { adding: true, error: "" })
|
|
||||||
|
|
||||||
const conn: ServerConnection.Http = {
|
|
||||||
type: "http",
|
|
||||||
http: { url: normalized },
|
|
||||||
}
|
|
||||||
if (store.addServer.name.trim()) conn.displayName = store.addServer.name.trim()
|
|
||||||
if (store.addServer.username) conn.http.username = store.addServer.username
|
|
||||||
if (store.addServer.password) conn.http.password = store.addServer.password
|
|
||||||
const result = await checkServerHealth(conn.http, fetcher)
|
|
||||||
setStore("addServer", { adding: false })
|
|
||||||
if (!result.healthy) {
|
|
||||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
resetAdd()
|
|
||||||
await select(conn, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleEdit(original: ServerConnection.Any, value: string) {
|
|
||||||
if (store.editServer.busy || original.type !== "http") return
|
|
||||||
const normalized = normalizeServerUrl(value)
|
|
||||||
if (!normalized) {
|
|
||||||
resetEdit()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = store.editServer.name.trim() || undefined
|
|
||||||
const username = store.editServer.username || undefined
|
|
||||||
const password = store.editServer.password || undefined
|
|
||||||
const existingName = original.displayName
|
|
||||||
if (
|
|
||||||
normalized === original.http.url &&
|
|
||||||
name === existingName &&
|
|
||||||
username === original.http.username &&
|
|
||||||
password === original.http.password
|
|
||||||
) {
|
|
||||||
resetEdit()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setStore("editServer", { busy: true, error: "" })
|
|
||||||
|
|
||||||
const conn: ServerConnection.Http = {
|
|
||||||
type: "http",
|
|
||||||
displayName: name,
|
|
||||||
http: { url: normalized, username, password },
|
|
||||||
}
|
|
||||||
const result = await checkServerHealth(conn.http, fetcher)
|
|
||||||
setStore("editServer", { busy: false })
|
|
||||||
if (!result.healthy) {
|
|
||||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (normalized === original.http.url) {
|
|
||||||
server.add(conn)
|
|
||||||
} else {
|
|
||||||
replaceServer(original, conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
resetEdit()
|
|
||||||
}
|
|
||||||
|
|
||||||
const mode = createMemo<"list" | "add" | "edit">(() => {
|
const mode = createMemo<"list" | "add" | "edit">(() => {
|
||||||
if (store.editServer.id) return "edit"
|
if (store.editServer.id) return "edit"
|
||||||
if (store.addServer.showForm) return "add"
|
if (store.addServer.showForm) return "add"
|
||||||
@@ -441,7 +441,7 @@ export function DialogSelectServer() {
|
|||||||
showForm: true,
|
showForm: true,
|
||||||
url: "",
|
url: "",
|
||||||
name: "",
|
name: "",
|
||||||
username: "",
|
username: DEFAULT_USERNAME,
|
||||||
password: "",
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
status: undefined,
|
status: undefined,
|
||||||
@@ -458,23 +458,26 @@ export function DialogSelectServer() {
|
|||||||
password: conn.http.password ?? "",
|
password: conn.http.password ?? "",
|
||||||
error: "",
|
error: "",
|
||||||
status: store.status[ServerConnection.key(conn)]?.healthy,
|
status: store.status[ServerConnection.key(conn)]?.healthy,
|
||||||
busy: false,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitForm = () => {
|
const submitForm = () => {
|
||||||
if (mode() === "add") {
|
if (mode() === "add") {
|
||||||
void handleAdd(store.addServer.url)
|
if (addMutation.isPending) return
|
||||||
|
setStore("addServer", { error: "" })
|
||||||
|
addMutation.mutate(store.addServer.url)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const original = editing()
|
const original = editing()
|
||||||
if (!original) return
|
if (!original) return
|
||||||
void handleEdit(original, store.editServer.value)
|
if (editMutation.isPending) return
|
||||||
|
setStore("editServer", { error: "" })
|
||||||
|
editMutation.mutate({ original, value: store.editServer.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFormMode = createMemo(() => mode() !== "list")
|
const isFormMode = createMemo(() => mode() !== "list")
|
||||||
const isAddMode = createMemo(() => mode() === "add")
|
const isAddMode = createMemo(() => mode() === "add")
|
||||||
const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy))
|
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
|
||||||
|
|
||||||
const formTitle = createMemo(() => {
|
const formTitle = createMemo(() => {
|
||||||
if (!isFormMode()) return language.t("dialog.server.title")
|
if (!isFormMode()) return language.t("dialog.server.title")
|
||||||
@@ -494,8 +497,8 @@ export function DialogSelectServer() {
|
|||||||
|
|
||||||
async function handleRemove(url: ServerConnection.Key) {
|
async function handleRemove(url: ServerConnection.Key) {
|
||||||
server.remove(url)
|
server.remove(url)
|
||||||
if ((await platform.getDefaultServerUrl?.()) === url) {
|
if ((await platform.getDefaultServer?.()) === url) {
|
||||||
platform.setDefaultServerUrl?.(null)
|
platform.setDefaultServer?.(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,7 +539,7 @@ export function DialogSelectServer() {
|
|||||||
if (x) select(x)
|
if (x) select(x)
|
||||||
}}
|
}}
|
||||||
divider={true}
|
divider={true}
|
||||||
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
|
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent"
|
||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
const key = ServerConnection.key(i)
|
const key = ServerConnection.key(i)
|
||||||
@@ -551,7 +554,7 @@ export function DialogSelectServer() {
|
|||||||
status={store.status[key]}
|
status={store.status[key]}
|
||||||
class="flex items-center gap-3 min-w-0 flex-1"
|
class="flex items-center gap-3 min-w-0 flex-1"
|
||||||
badge={
|
badge={
|
||||||
<Show when={defaultUrl() === i.http.url}>
|
<Show when={defaultKey() === ServerConnection.key(i)}>
|
||||||
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||||
{language.t("dialog.server.status.default")}
|
{language.t("dialog.server.status.default")}
|
||||||
</span>
|
</span>
|
||||||
@@ -584,14 +587,14 @@ export function DialogSelectServer() {
|
|||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<Show when={canDefault() && defaultUrl() !== i.http.url}>
|
<Show when={canDefault() && defaultKey() !== key}>
|
||||||
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
|
<DropdownMenu.Item onSelect={() => setDefault(key)}>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.default")}
|
{language.t("dialog.server.menu.default")}
|
||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={canDefault() && defaultUrl() === i.http.url}>
|
<Show when={canDefault() && defaultKey() === key}>
|
||||||
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||||
import { createEffect, on, Component, Show, onCleanup, Switch, Match, createMemo, createSignal } from "solid-js"
|
import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { createFocusSignal } from "@solid-primitives/active-element"
|
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +16,6 @@ import {
|
|||||||
} from "@/context/prompt"
|
} from "@/context/prompt"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useParams } from "@solidjs/router"
|
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
@@ -27,7 +25,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
|||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Select } from "@opencode-ai/ui/select"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
import { RadioGroup } from "@opencode-ai/ui/radio-group"
|
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||||
@@ -37,8 +34,12 @@ import { Persist, persisted } from "@/utils/persist"
|
|||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
|
import { promptEnabled, promptProbe } from "@/testing/prompt"
|
||||||
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
|
||||||
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
|
import { createPromptAttachments } from "./prompt-input/attachments"
|
||||||
|
import { ACCEPTED_FILE_TYPES } from "./prompt-input/files"
|
||||||
import {
|
import {
|
||||||
canNavigateHistoryAtCursor,
|
canNavigateHistoryAtCursor,
|
||||||
navigatePromptHistory,
|
navigatePromptHistory,
|
||||||
@@ -48,7 +49,7 @@ import {
|
|||||||
type PromptHistoryStoredEntry,
|
type PromptHistoryStoredEntry,
|
||||||
promptLength,
|
promptLength,
|
||||||
} from "./prompt-input/history"
|
} from "./prompt-input/history"
|
||||||
import { createPromptSubmit } from "./prompt-input/submit"
|
import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit"
|
||||||
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
|
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
|
||||||
import { PromptContextItems } from "./prompt-input/context-items"
|
import { PromptContextItems } from "./prompt-input/context-items"
|
||||||
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
import { PromptImageAttachments } from "./prompt-input/image-attachments"
|
||||||
@@ -61,6 +62,11 @@ interface PromptInputProps {
|
|||||||
ref?: (el: HTMLDivElement) => void
|
ref?: (el: HTMLDivElement) => void
|
||||||
newSessionWorktree?: string
|
newSessionWorktree?: string
|
||||||
onNewSessionWorktreeReset?: () => void
|
onNewSessionWorktreeReset?: () => void
|
||||||
|
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
|
||||||
|
onEditLoaded?: () => void
|
||||||
|
shouldQueue?: () => boolean
|
||||||
|
onQueue?: (draft: FollowupDraft) => void
|
||||||
|
onAbort?: () => void
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,20 +108,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
const params = useParams()
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const { params, tabs, view } = useSessionLayout()
|
||||||
let editorRef!: HTMLDivElement
|
let editorRef!: HTMLDivElement
|
||||||
let fileInputRef: HTMLInputElement | undefined
|
let fileInputRef: HTMLInputElement | undefined
|
||||||
let scrollRef!: HTMLDivElement
|
let scrollRef!: HTMLDivElement
|
||||||
let slashPopoverRef!: HTMLDivElement
|
let slashPopoverRef!: HTMLDivElement
|
||||||
|
|
||||||
const mirror = { input: false }
|
const mirror = { input: false }
|
||||||
const inset = 44
|
const inset = 56
|
||||||
|
const space = `${inset}px`
|
||||||
|
|
||||||
const scrollCursorIntoView = () => {
|
const scrollCursorIntoView = () => {
|
||||||
const container = scrollRef
|
const container = scrollRef
|
||||||
@@ -150,13 +157,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const queueScroll = () => {
|
const queueScroll = (count = 2) => {
|
||||||
requestAnimationFrame(scrollCursorIntoView)
|
requestAnimationFrame(() => {
|
||||||
|
scrollCursorIntoView()
|
||||||
|
if (count > 1) queueScroll(count - 1)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const activeFileTab = createSessionTabs({
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
tabs,
|
||||||
const view = createMemo(() => layout.view(sessionKey))
|
pathFromTab: files.pathFromTab,
|
||||||
|
normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
|
||||||
|
}).activeFileTab
|
||||||
|
|
||||||
const commentInReview = (path: string) => {
|
const commentInReview = (path: string) => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
@@ -209,7 +221,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const recent = createMemo(() => {
|
const recent = createMemo(() => {
|
||||||
const all = tabs().all()
|
const all = tabs().all()
|
||||||
const active = tabs().active()
|
const active = activeFileTab()
|
||||||
const order = active ? [active, ...all.filter((x) => x !== active)] : all
|
const order = active ? [active, ...all.filter((x) => x !== active)] : all
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const paths: string[] = []
|
const paths: string[] = []
|
||||||
@@ -232,6 +244,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
const working = createMemo(() => status()?.type !== "idle")
|
const working = createMemo(() => status()?.type !== "idle")
|
||||||
|
const tip = () => {
|
||||||
|
if (working()) {
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{language.t("prompt.action.stop")}</span>
|
||||||
|
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{language.t("prompt.action.send")}</span>
|
||||||
|
<Icon name="enter" size="small" class="text-icon-base" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
const imageAttachments = createMemo(() =>
|
const imageAttachments = createMemo(() =>
|
||||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||||
)
|
)
|
||||||
@@ -255,6 +284,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
||||||
|
const motion = (value: number) => ({
|
||||||
|
opacity: value,
|
||||||
|
transform: `scale(${0.95 + value * 0.05})`,
|
||||||
|
filter: `blur(${(1 - value) * 2}px)`,
|
||||||
|
"pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const),
|
||||||
|
})
|
||||||
|
const buttons = createMemo(() => motion(buttonsSpring()))
|
||||||
|
const shell = createMemo(() => motion(1 - buttonsSpring()))
|
||||||
|
const control = createMemo(() => ({ height: "28px", ...buttons() }))
|
||||||
|
|
||||||
const commentCount = createMemo(() => {
|
const commentCount = createMemo(() => {
|
||||||
if (store.mode === "shell") return 0
|
if (store.mode === "shell") return 0
|
||||||
@@ -390,7 +428,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isFocused = createFocusSignal(() => editorRef)
|
|
||||||
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
|
const escBlur = () => platform.platform === "desktop" && platform.os === "macos"
|
||||||
|
|
||||||
const pick = () => fileInputRef?.click()
|
const pick = () => fileInputRef?.click()
|
||||||
@@ -490,6 +527,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
setComposing(false)
|
setComposing(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCompositionStart = () => {
|
||||||
|
setComposing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCompositionEnd = () => {
|
||||||
|
setComposing(false)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (composing()) return
|
||||||
|
reconcile(prompt.current().filter((part) => part.type !== "image"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const agentList = createMemo(() =>
|
const agentList = createMemo(() =>
|
||||||
sync.data.agent
|
sync.data.agent
|
||||||
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
.filter((agent) => !agent.hidden && agent.mode !== "primary")
|
||||||
@@ -523,6 +572,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const open = recent()
|
const open = recent()
|
||||||
const seen = new Set(open)
|
const seen = new Set(open)
|
||||||
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
|
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
|
||||||
|
if (!query.trim()) return [...agents, ...pinned]
|
||||||
const paths = await files.searchFilesAndDirectories(query)
|
const paths = await files.searchFilesAndDirectories(query)
|
||||||
const fileOptions: AtOption[] = paths
|
const fileOptions: AtOption[] = paths
|
||||||
.filter((path) => !seen.has(path))
|
.filter((path) => !seen.has(path))
|
||||||
@@ -573,6 +623,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
|
||||||
if (!cmd) return
|
if (!cmd) return
|
||||||
|
promptProbe.select(cmd.id)
|
||||||
closePopover()
|
closePopover()
|
||||||
|
|
||||||
if (cmd.type === "custom") {
|
if (cmd.type === "custom") {
|
||||||
@@ -661,6 +712,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (promptEnabled()) {
|
||||||
|
createEffect(() => {
|
||||||
|
promptProbe.set({
|
||||||
|
popover: store.popover,
|
||||||
|
slash: {
|
||||||
|
active: slashActive() ?? null,
|
||||||
|
ids: slashFlat().map((cmd) => cmd.id),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => promptProbe.clear())
|
||||||
|
}
|
||||||
|
|
||||||
const selectPopoverActive = () => {
|
const selectPopoverActive = () => {
|
||||||
if (store.popover === "at") {
|
if (store.popover === "at") {
|
||||||
const items = atFlat()
|
const items = atFlat()
|
||||||
@@ -680,24 +745,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reconcile = (input: Prompt) => {
|
||||||
|
if (mirror.input) {
|
||||||
|
mirror.input = false
|
||||||
|
if (isNormalizedEditor()) return
|
||||||
|
|
||||||
|
renderEditorWithCursor(input)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const dom = parseFromDOM()
|
||||||
|
if (isNormalizedEditor() && isPromptEqual(input, dom)) return
|
||||||
|
|
||||||
|
renderEditorWithCursor(input)
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => prompt.current(),
|
() => prompt.current(),
|
||||||
(currentParts) => {
|
(parts) => {
|
||||||
const inputParts = currentParts.filter((part) => part.type !== "image")
|
if (composing()) return
|
||||||
|
reconcile(parts.filter((part) => part.type !== "image"))
|
||||||
if (mirror.input) {
|
|
||||||
mirror.input = false
|
|
||||||
if (isNormalizedEditor()) return
|
|
||||||
|
|
||||||
renderEditorWithCursor(inputParts)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const domParts = parseFromDOM()
|
|
||||||
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
|
|
||||||
|
|
||||||
renderEditorWithCursor(inputParts)
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -921,6 +989,45 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
setCurrentHistory("entries", next)
|
setCurrentHistory("entries", next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => props.edit?.id,
|
||||||
|
(id) => {
|
||||||
|
const edit = props.edit
|
||||||
|
if (!id || !edit) return
|
||||||
|
|
||||||
|
for (const item of prompt.context.items()) {
|
||||||
|
prompt.context.remove(item.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of edit.context) {
|
||||||
|
prompt.context.add({
|
||||||
|
type: item.type,
|
||||||
|
path: item.path,
|
||||||
|
selection: item.selection,
|
||||||
|
comment: item.comment,
|
||||||
|
commentID: item.commentID,
|
||||||
|
commentOrigin: item.commentOrigin,
|
||||||
|
preview: item.preview,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setStore("mode", "normal")
|
||||||
|
setStore("popover", null)
|
||||||
|
setStore("historyIndex", -1)
|
||||||
|
setStore("savedPrompt", null)
|
||||||
|
prompt.set(edit.prompt, promptLength(edit.prompt))
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
editorRef.focus()
|
||||||
|
setCursorPosition(editorRef, promptLength(edit.prompt))
|
||||||
|
queueScroll()
|
||||||
|
})
|
||||||
|
props.onEditLoaded?.()
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const navigateHistory = (direction: "up" | "down") => {
|
const navigateHistory = (direction: "up" | "down") => {
|
||||||
const result = navigatePromptHistory({
|
const result = navigatePromptHistory({
|
||||||
direction,
|
direction,
|
||||||
@@ -937,9 +1044,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const { addImageAttachment, removeImageAttachment, handlePaste } = createPromptAttachments({
|
const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
|
||||||
editor: () => editorRef,
|
editor: () => editorRef,
|
||||||
isFocused,
|
|
||||||
isDialogActive: () => !!dialog.active,
|
isDialogActive: () => !!dialog.active,
|
||||||
setDraggingType: (type) => setStore("draggingType", type),
|
setDraggingType: (type) => setStore("draggingType", type),
|
||||||
focusEditor: () => {
|
focusEditor: () => {
|
||||||
@@ -956,6 +1062,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||||
return permission.isAutoAccepting(id, sdk.directory)
|
return permission.isAutoAccepting(id, sdk.directory)
|
||||||
})
|
})
|
||||||
|
const acceptLabel = createMemo(() =>
|
||||||
|
language.t(accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable"),
|
||||||
|
)
|
||||||
|
const toggleAccept = () => {
|
||||||
|
if (!params.id) {
|
||||||
|
permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
permission.toggleAutoAccept(params.id, sdk.directory)
|
||||||
|
}
|
||||||
|
|
||||||
const { abort, handleSubmit } = createPromptSubmit({
|
const { abort, handleSubmit } = createPromptSubmit({
|
||||||
info,
|
info,
|
||||||
@@ -975,6 +1092,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
setPopover: (popover) => setStore("popover", popover),
|
setPopover: (popover) => setStore("popover", popover),
|
||||||
newSessionWorktree: () => props.newSessionWorktree,
|
newSessionWorktree: () => props.newSessionWorktree,
|
||||||
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
|
||||||
|
shouldQueue: props.shouldQueue,
|
||||||
|
onQueue: props.onQueue,
|
||||||
|
onAbort: props.onAbort,
|
||||||
onSubmit: props.onSubmit,
|
onSubmit: props.onSubmit,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1122,6 +1242,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
// Note: Shift+Enter is handled earlier, before IME check
|
// Note: Shift+Enter is handled earlier, before IME check
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (event.repeat) return
|
||||||
|
if (
|
||||||
|
working() &&
|
||||||
|
prompt
|
||||||
|
.current()
|
||||||
|
.map((part) => ("content" in part ? part.content : ""))
|
||||||
|
.join("")
|
||||||
|
.trim().length === 0 &&
|
||||||
|
imageAttachments().length === 0 &&
|
||||||
|
commentCount() === 0
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
handleSubmit(event)
|
handleSubmit(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1174,7 +1308,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
onOpen={(attachment) =>
|
onOpen={(attachment) =>
|
||||||
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
|
||||||
}
|
}
|
||||||
onRemove={removeImageAttachment}
|
onRemove={removeAttachment}
|
||||||
removeLabel={language.t("prompt.attachment.remove")}
|
removeLabel={language.t("prompt.attachment.remove")}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -1192,7 +1326,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
editorRef?.focus()
|
editorRef?.focus()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="relative max-h-[240px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
|
<div
|
||||||
|
class="relative max-h-[240px] overflow-y-auto no-scrollbar"
|
||||||
|
ref={(el) => (scrollRef = el)}
|
||||||
|
style={{ "scroll-padding-bottom": space }}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
data-component="prompt-input"
|
data-component="prompt-input"
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
@@ -1208,44 +1346,75 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
spellcheck={store.mode === "normal"}
|
spellcheck={store.mode === "normal"}
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onCompositionStart={() => setComposing(true)}
|
onCompositionStart={handleCompositionStart}
|
||||||
onCompositionEnd={() => setComposing(false)}
|
onCompositionEnd={handleCompositionEnd}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
classList={{
|
classList={{
|
||||||
"select-text": true,
|
"select-text": true,
|
||||||
"w-full pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
"w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
|
||||||
"[&_[data-type=file]]:text-syntax-property": true,
|
"[&_[data-type=file]]:text-syntax-property": true,
|
||||||
"[&_[data-type=agent]]:text-syntax-type": true,
|
"[&_[data-type=agent]]:text-syntax-type": true,
|
||||||
"font-mono!": store.mode === "shell",
|
"font-mono!": store.mode === "shell",
|
||||||
}}
|
}}
|
||||||
|
style={{ "padding-bottom": space }}
|
||||||
/>
|
/>
|
||||||
<Show when={!prompt.dirty()}>
|
<Show when={!prompt.dirty()}>
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 pb-11 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
|
||||||
classList={{ "font-mono!": store.mode === "shell" }}
|
classList={{ "font-mono!": store.mode === "shell" }}
|
||||||
|
style={{ "padding-bottom": space }}
|
||||||
>
|
>
|
||||||
{placeholder()}
|
{placeholder()}
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="pointer-events-none absolute inset-x-0 bottom-0"
|
||||||
|
style={{
|
||||||
|
height: space,
|
||||||
|
background:
|
||||||
|
"linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
|
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
|
multiple
|
||||||
accept={ACCEPTED_FILE_TYPES.join(",")}
|
accept={ACCEPTED_FILE_TYPES.join(",")}
|
||||||
class="hidden"
|
class="hidden"
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.currentTarget.files?.[0]
|
const list = e.currentTarget.files
|
||||||
if (file) addImageAttachment(file)
|
if (list) void addAttachments(Array.from(list))
|
||||||
e.currentTarget.value = ""
|
e.currentTarget.value = ""
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1 pointer-events-auto">
|
||||||
|
<Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}>
|
||||||
|
<IconButton
|
||||||
|
data-action="prompt-submit"
|
||||||
|
type="submit"
|
||||||
|
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
|
||||||
|
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||||
|
icon={working() ? "stop" : "arrow-up"}
|
||||||
|
variant="primary"
|
||||||
|
class="size-8"
|
||||||
|
style={buttons()}
|
||||||
|
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pointer-events-none absolute bottom-2 left-2">
|
||||||
<div
|
<div
|
||||||
aria-hidden={store.mode !== "normal"}
|
aria-hidden={store.mode !== "normal"}
|
||||||
class="flex items-center gap-1"
|
class="pointer-events-auto"
|
||||||
style={{
|
style={{
|
||||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||||
}}
|
}}
|
||||||
@@ -1260,11 +1429,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="size-8 p-0"
|
class="size-8 p-0"
|
||||||
style={{
|
style={buttons()}
|
||||||
opacity: buttonsSpring(),
|
|
||||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
|
||||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
|
||||||
}}
|
|
||||||
onClick={pick}
|
onClick={pick}
|
||||||
disabled={store.mode !== "normal"}
|
disabled={store.mode !== "normal"}
|
||||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||||
@@ -1273,85 +1438,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
<Icon name="plus" class="size-4.5" />
|
<Icon name="plus" class="size-4.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
|
|
||||||
<Tooltip
|
|
||||||
placement="top"
|
|
||||||
inactive={!prompt.dirty() && !working()}
|
|
||||||
value={
|
|
||||||
<Switch>
|
|
||||||
<Match when={working()}>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{language.t("prompt.action.stop")}</span>
|
|
||||||
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
<Match when={true}>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{language.t("prompt.action.send")}</span>
|
|
||||||
<Icon name="enter" size="small" class="text-icon-base" />
|
|
||||||
</div>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
data-action="prompt-submit"
|
|
||||||
type="submit"
|
|
||||||
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
|
|
||||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
|
||||||
icon={working() ? "stop" : "arrow-up"}
|
|
||||||
variant="primary"
|
|
||||||
class="size-8"
|
|
||||||
style={{
|
|
||||||
opacity: buttonsSpring(),
|
|
||||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
|
||||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
|
||||||
}}
|
|
||||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pointer-events-none absolute bottom-2 left-2">
|
|
||||||
<div class="pointer-events-auto">
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={8}
|
|
||||||
title={language.t(
|
|
||||||
accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable",
|
|
||||||
)}
|
|
||||||
keybind={command.keybind("permissions.autoaccept")}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
data-action="prompt-permissions"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
if (!params.id) {
|
|
||||||
permission.toggleAutoAcceptDirectory(sdk.directory)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
permission.toggleAutoAccept(params.id, sdk.directory)
|
|
||||||
}}
|
|
||||||
classList={{
|
|
||||||
"size-6 flex items-center justify-center": true,
|
|
||||||
"text-text-base": !accepting(),
|
|
||||||
"hover:bg-surface-success-base": accepting(),
|
|
||||||
}}
|
|
||||||
aria-label={
|
|
||||||
accepting()
|
|
||||||
? language.t("command.permissions.autoaccept.disable")
|
|
||||||
: language.t("command.permissions.autoaccept.enable")
|
|
||||||
}
|
|
||||||
aria-pressed={accepting()}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
name="chevron-double-right"
|
|
||||||
size="small"
|
|
||||||
classList={{ "text-icon-success-base": accepting() }}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1364,65 +1450,87 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
|
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
|
||||||
style={{
|
style={{
|
||||||
padding: "0 4px 0 8px",
|
padding: "0 4px 0 8px",
|
||||||
opacity: 1 - buttonsSpring(),
|
...shell(),
|
||||||
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
|
|
||||||
filter: `blur(${buttonsSpring() * 2}px)`,
|
|
||||||
"pointer-events": buttonsSpring() < 0.5 ? "auto" : "none",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
||||||
<div class="size-4 shrink-0" />
|
<div class="size-4 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
<TooltipKeybind
|
<div data-component="prompt-agent-control">
|
||||||
placement="top"
|
<TooltipKeybind
|
||||||
gutter={4}
|
placement="top"
|
||||||
title={language.t("command.agent.cycle")}
|
gutter={4}
|
||||||
keybind={command.keybind("agent.cycle")}
|
title={language.t("command.agent.cycle")}
|
||||||
>
|
keybind={command.keybind("agent.cycle")}
|
||||||
<Select
|
>
|
||||||
size="normal"
|
<Select
|
||||||
options={agentNames()}
|
size="normal"
|
||||||
current={local.agent.current()?.name ?? ""}
|
options={agentNames()}
|
||||||
onSelect={local.agent.set}
|
current={local.agent.current()?.name ?? ""}
|
||||||
class="capitalize max-w-[160px]"
|
onSelect={local.agent.set}
|
||||||
valueClass="truncate text-13-regular"
|
class="capitalize max-w-[160px] text-text-base"
|
||||||
triggerStyle={{
|
valueClass="truncate text-13-regular text-text-base"
|
||||||
height: "28px",
|
triggerStyle={control()}
|
||||||
opacity: buttonsSpring(),
|
triggerProps={{ "data-action": "prompt-agent" }}
|
||||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
variant="ghost"
|
||||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
/>
|
||||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
</TooltipKeybind>
|
||||||
}}
|
</div>
|
||||||
variant="ghost"
|
<div data-component="prompt-model-control">
|
||||||
/>
|
<Show
|
||||||
</TooltipKeybind>
|
when={providers.paid().length > 0}
|
||||||
<Show
|
fallback={
|
||||||
when={providers.paid().length > 0}
|
<TooltipKeybind
|
||||||
fallback={
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
title={language.t("command.model.choose")}
|
||||||
|
keybind={command.keybind("model.choose")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
data-action="prompt-model"
|
||||||
|
as="div"
|
||||||
|
variant="ghost"
|
||||||
|
size="normal"
|
||||||
|
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||||
|
style={control()}
|
||||||
|
onClick={() => dialog.show(() => <DialogSelectModelUnpaid model={local.model} />)}
|
||||||
|
>
|
||||||
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
|
<ProviderIcon
|
||||||
|
id={local.model.current()?.provider?.id ?? ""}
|
||||||
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<span class="truncate">
|
||||||
|
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
}
|
||||||
|
>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
title={language.t("command.model.choose")}
|
title={language.t("command.model.choose")}
|
||||||
keybind={command.keybind("model.choose")}
|
keybind={command.keybind("model.choose")}
|
||||||
>
|
>
|
||||||
<Button
|
<ModelSelectorPopover
|
||||||
as="div"
|
model={local.model}
|
||||||
variant="ghost"
|
triggerAs={Button}
|
||||||
size="normal"
|
triggerProps={{
|
||||||
class="min-w-0 max-w-[320px] text-13-regular group"
|
variant: "ghost",
|
||||||
style={{
|
size: "normal",
|
||||||
height: "28px",
|
style: control(),
|
||||||
opacity: buttonsSpring(),
|
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
||||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
"data-action": "prompt-model",
|
||||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
|
||||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
|
||||||
}}
|
}}
|
||||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
|
||||||
>
|
>
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
id={local.model.current()!.provider.id}
|
id={local.model.current()?.provider?.id ?? ""}
|
||||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
/>
|
/>
|
||||||
@@ -1431,100 +1539,54 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||||
</span>
|
</span>
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
</Button>
|
</ModelSelectorPopover>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
}
|
</Show>
|
||||||
>
|
</div>
|
||||||
|
<div data-component="prompt-variant-control">
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
title={language.t("command.model.choose")}
|
title={language.t("command.model.variant.cycle")}
|
||||||
keybind={command.keybind("model.choose")}
|
keybind={command.keybind("model.variant.cycle")}
|
||||||
>
|
>
|
||||||
<ModelSelectorPopover
|
<Select
|
||||||
triggerAs={Button}
|
size="normal"
|
||||||
triggerProps={{
|
options={variants()}
|
||||||
variant: "ghost",
|
current={local.model.variant.current() ?? "default"}
|
||||||
size: "normal",
|
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||||
style: {
|
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||||
height: "28px",
|
class="capitalize max-w-[160px] text-text-base"
|
||||||
opacity: buttonsSpring(),
|
valueClass="truncate text-13-regular text-text-base"
|
||||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
triggerStyle={control()}
|
||||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
triggerProps={{ "data-action": "prompt-model-variant" }}
|
||||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
variant="ghost"
|
||||||
},
|
|
||||||
class: "min-w-0 max-w-[320px] text-13-regular group",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
|
||||||
<ProviderIcon
|
|
||||||
id={local.model.current()!.provider.id}
|
|
||||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
|
||||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<span class="truncate">
|
|
||||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
|
||||||
</span>
|
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
|
||||||
</ModelSelectorPopover>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</Show>
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={4}
|
|
||||||
title={language.t("command.model.variant.cycle")}
|
|
||||||
keybind={command.keybind("model.variant.cycle")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
size="normal"
|
|
||||||
options={variants()}
|
|
||||||
current={local.model.variant.current() ?? "default"}
|
|
||||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
|
||||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
|
||||||
class="capitalize max-w-[160px]"
|
|
||||||
valueClass="truncate text-13-regular"
|
|
||||||
triggerStyle={{
|
|
||||||
height: "28px",
|
|
||||||
opacity: buttonsSpring(),
|
|
||||||
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
|
||||||
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
|
||||||
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
|
||||||
}}
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="shrink-0">
|
|
||||||
<RadioGroup
|
|
||||||
options={["shell", "normal"] as const}
|
|
||||||
current={store.mode}
|
|
||||||
value={(mode) => mode}
|
|
||||||
label={(mode) => (
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={4}
|
|
||||||
openDelay={2000}
|
|
||||||
title={language.t(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
|
|
||||||
keybind={command.keybind(mode === "shell" ? "prompt.mode.shell" : "prompt.mode.normal")}
|
|
||||||
class="size-full flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
name={mode === "shell" ? "console" : "prompt"}
|
|
||||||
class="size-[18px]"
|
|
||||||
classList={{
|
|
||||||
"text-icon-strong-base": store.mode === mode,
|
|
||||||
"text-icon-weak": store.mode !== mode,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
)}
|
</div>
|
||||||
onSelect={(mode) => mode && setMode(mode)}
|
<TooltipKeybind
|
||||||
fill
|
placement="top"
|
||||||
pad="none"
|
gutter={8}
|
||||||
class="w-[68px]"
|
title={acceptLabel()}
|
||||||
/>
|
keybind={command.keybind("permissions.autoaccept")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
data-action="prompt-permissions"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={toggleAccept}
|
||||||
|
classList={{
|
||||||
|
"h-7 w-7 p-0 shrink-0 flex items-center justify-center": true,
|
||||||
|
"text-text-base": !accepting(),
|
||||||
|
"hover:bg-surface-success-base": accepting(),
|
||||||
|
}}
|
||||||
|
style={control()}
|
||||||
|
aria-label={acceptLabel()}
|
||||||
|
aria-pressed={accepting()}
|
||||||
|
>
|
||||||
|
<Icon name="shield" size="small" classList={{ "text-icon-success-base": accepting() }} />
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DockTray>
|
</DockTray>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { attachmentMime } from "./files"
|
||||||
|
import { pasteMode } from "./paste"
|
||||||
|
|
||||||
|
describe("attachmentMime", () => {
|
||||||
|
test("keeps PDFs when the browser reports the mime", async () => {
|
||||||
|
const file = new File(["%PDF-1.7"], "guide.pdf", { type: "application/pdf" })
|
||||||
|
expect(await attachmentMime(file)).toBe("application/pdf")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("normalizes structured text types to text/plain", async () => {
|
||||||
|
const file = new File(['{"ok":true}\n'], "data.json", { type: "application/json" })
|
||||||
|
expect(await attachmentMime(file)).toBe("text/plain")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("accepts text files even with a misleading browser mime", async () => {
|
||||||
|
const file = new File(["export const x = 1\n"], "main.ts", { type: "video/mp2t" })
|
||||||
|
expect(await attachmentMime(file)).toBe("text/plain")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects binary files", async () => {
|
||||||
|
const file = new File([Uint8Array.of(0, 255, 1, 2)], "blob.bin", { type: "application/octet-stream" })
|
||||||
|
expect(await attachmentMime(file)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("pasteMode", () => {
|
||||||
|
test("uses native paste for short single-line text", () => {
|
||||||
|
expect(pasteMode("hello world")).toBe("native")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses manual paste for multiline text", () => {
|
||||||
|
expect(
|
||||||
|
pasteMode(`{
|
||||||
|
"ok": true
|
||||||
|
}`),
|
||||||
|
).toBe("manual")
|
||||||
|
expect(pasteMode("a\r\nb")).toBe("manual")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses manual paste for large text", () => {
|
||||||
|
expect(pasteMode("x".repeat(8000))).toBe("manual")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,26 +4,28 @@ import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { uuid } from "@/utils/uuid"
|
import { uuid } from "@/utils/uuid"
|
||||||
import { getCursorPosition } from "./editor-dom"
|
import { getCursorPosition } from "./editor-dom"
|
||||||
|
import { attachmentMime } from "./files"
|
||||||
|
import { normalizePaste, pasteMode } from "./paste"
|
||||||
|
|
||||||
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
function dataUrl(file: File, mime: string) {
|
||||||
export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
|
return new Promise<string>((resolve) => {
|
||||||
const LARGE_PASTE_CHARS = 8000
|
const reader = new FileReader()
|
||||||
const LARGE_PASTE_BREAKS = 120
|
reader.addEventListener("error", () => resolve(""))
|
||||||
|
reader.addEventListener("load", () => {
|
||||||
function largePaste(text: string) {
|
const value = typeof reader.result === "string" ? reader.result : ""
|
||||||
if (text.length >= LARGE_PASTE_CHARS) return true
|
const idx = value.indexOf(",")
|
||||||
let breaks = 0
|
if (idx === -1) {
|
||||||
for (const char of text) {
|
resolve(value)
|
||||||
if (char !== "\n") continue
|
return
|
||||||
breaks += 1
|
}
|
||||||
if (breaks >= LARGE_PASTE_BREAKS) return true
|
resolve(`data:${mime};base64,${value.slice(idx + 1)}`)
|
||||||
}
|
})
|
||||||
return false
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type PromptAttachmentsInput = {
|
type PromptAttachmentsInput = {
|
||||||
editor: () => HTMLDivElement | undefined
|
editor: () => HTMLDivElement | undefined
|
||||||
isFocused: () => boolean
|
|
||||||
isDialogActive: () => boolean
|
isDialogActive: () => boolean
|
||||||
setDraggingType: (type: "image" | "@mention" | null) => void
|
setDraggingType: (type: "image" | "@mention" | null) => void
|
||||||
focusEditor: () => void
|
focusEditor: () => void
|
||||||
@@ -35,58 +37,73 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const addImageAttachment = async (file: File) => {
|
const warn = () => {
|
||||||
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
|
showToast({
|
||||||
|
title: language.t("prompt.toast.pasteUnsupported.title"),
|
||||||
const reader = new FileReader()
|
description: language.t("prompt.toast.pasteUnsupported.description"),
|
||||||
reader.onload = () => {
|
})
|
||||||
const editor = input.editor()
|
|
||||||
if (!editor) return
|
|
||||||
const dataUrl = reader.result as string
|
|
||||||
const attachment: ImageAttachmentPart = {
|
|
||||||
type: "image",
|
|
||||||
id: uuid(),
|
|
||||||
filename: file.name,
|
|
||||||
mime: file.type,
|
|
||||||
dataUrl,
|
|
||||||
}
|
|
||||||
const cursorPosition = prompt.cursor() ?? getCursorPosition(editor)
|
|
||||||
prompt.set([...prompt.current(), attachment], cursorPosition)
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeImageAttachment = (id: string) => {
|
const add = async (file: File, toast = true) => {
|
||||||
|
const mime = await attachmentMime(file)
|
||||||
|
if (!mime) {
|
||||||
|
if (toast) warn()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const editor = input.editor()
|
||||||
|
if (!editor) return false
|
||||||
|
|
||||||
|
const url = await dataUrl(file, mime)
|
||||||
|
if (!url) return false
|
||||||
|
|
||||||
|
const attachment: ImageAttachmentPart = {
|
||||||
|
type: "image",
|
||||||
|
id: uuid(),
|
||||||
|
filename: file.name,
|
||||||
|
mime,
|
||||||
|
dataUrl: url,
|
||||||
|
}
|
||||||
|
const cursor = prompt.cursor() ?? getCursorPosition(editor)
|
||||||
|
prompt.set([...prompt.current(), attachment], cursor)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const addAttachment = (file: File) => add(file)
|
||||||
|
|
||||||
|
const addAttachments = async (files: File[], toast = true) => {
|
||||||
|
let found = false
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const ok = await add(file, false)
|
||||||
|
if (ok) found = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found && files.length > 0 && toast) warn()
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeAttachment = (id: string) => {
|
||||||
const current = prompt.current()
|
const current = prompt.current()
|
||||||
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
const next = current.filter((part) => part.type !== "image" || part.id !== id)
|
||||||
prompt.set(next, prompt.cursor())
|
prompt.set(next, prompt.cursor())
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePaste = async (event: ClipboardEvent) => {
|
const handlePaste = async (event: ClipboardEvent) => {
|
||||||
if (!input.isFocused()) return
|
|
||||||
const clipboardData = event.clipboardData
|
const clipboardData = event.clipboardData
|
||||||
if (!clipboardData) return
|
if (!clipboardData) return
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
||||||
const items = Array.from(clipboardData.items)
|
const files = Array.from(clipboardData.items).flatMap((item) => {
|
||||||
const fileItems = items.filter((item) => item.kind === "file")
|
if (item.kind !== "file") return []
|
||||||
const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
|
const file = item.getAsFile()
|
||||||
|
return file ? [file] : []
|
||||||
|
})
|
||||||
|
|
||||||
if (imageItems.length > 0) {
|
if (files.length > 0) {
|
||||||
for (const item of imageItems) {
|
await addAttachments(files)
|
||||||
const file = item.getAsFile()
|
|
||||||
if (file) await addImageAttachment(file)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileItems.length > 0) {
|
|
||||||
showToast({
|
|
||||||
title: language.t("prompt.toast.pasteUnsupported.title"),
|
|
||||||
description: language.t("prompt.toast.pasteUnsupported.description"),
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,23 +113,30 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
if (input.readClipboardImage && !plainText) {
|
if (input.readClipboardImage && !plainText) {
|
||||||
const file = await input.readClipboardImage()
|
const file = await input.readClipboardImage()
|
||||||
if (file) {
|
if (file) {
|
||||||
await addImageAttachment(file)
|
await addAttachment(file)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!plainText) return
|
if (!plainText) return
|
||||||
|
|
||||||
if (largePaste(plainText)) {
|
const text = normalizePaste(plainText)
|
||||||
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
|
|
||||||
|
const put = () => {
|
||||||
|
if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true
|
||||||
input.focusEditor()
|
input.focusEditor()
|
||||||
if (input.addPart({ type: "text", content: plainText, start: 0, end: 0 })) return
|
return input.addPart({ type: "text", content: text, start: 0, end: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
|
if (pasteMode(text) === "manual") {
|
||||||
|
put()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text)
|
||||||
if (inserted) return
|
if (inserted) return
|
||||||
|
|
||||||
input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
put()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGlobalDragOver = (event: DragEvent) => {
|
const handleGlobalDragOver = (event: DragEvent) => {
|
||||||
@@ -153,11 +177,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
const dropped = event.dataTransfer?.files
|
const dropped = event.dataTransfer?.files
|
||||||
if (!dropped) return
|
if (!dropped) return
|
||||||
|
|
||||||
for (const file of Array.from(dropped)) {
|
await addAttachments(Array.from(dropped))
|
||||||
if (ACCEPTED_FILE_TYPES.includes(file.type)) {
|
|
||||||
await addImageAttachment(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -173,8 +193,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
addImageAttachment,
|
addAttachment,
|
||||||
removeImageAttachment,
|
addAttachments,
|
||||||
|
removeAttachment,
|
||||||
handlePaste,
|
handlePaste,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,32 @@ describe("buildRequestParts", () => {
|
|||||||
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps multiple uploaded attachments in order", () => {
|
||||||
|
const result = buildRequestParts({
|
||||||
|
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
|
||||||
|
context: [],
|
||||||
|
images: [
|
||||||
|
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
id: "img_2",
|
||||||
|
filename: "b.pdf",
|
||||||
|
mime: "application/pdf",
|
||||||
|
dataUrl: "data:application/pdf;base64,BBB",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
text: "check these",
|
||||||
|
messageID: "msg_multi",
|
||||||
|
sessionID: "ses_multi",
|
||||||
|
sessionDirectory: "/repo",
|
||||||
|
})
|
||||||
|
|
||||||
|
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
|
||||||
|
|
||||||
|
expect(files).toHaveLength(2)
|
||||||
|
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
|
||||||
|
})
|
||||||
|
|
||||||
test("deduplicates context files when prompt already includes same path", () => {
|
test("deduplicates context files when prompt already includes same path", () => {
|
||||||
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { ACCEPTED_FILE_TYPES, ACCEPTED_IMAGE_TYPES } from "@/constants/file-picker"
|
||||||
|
|
||||||
|
export { ACCEPTED_FILE_TYPES }
|
||||||
|
|
||||||
|
const IMAGE_MIMES = new Set(ACCEPTED_IMAGE_TYPES)
|
||||||
|
const IMAGE_EXTS = new Map([
|
||||||
|
["gif", "image/gif"],
|
||||||
|
["jpeg", "image/jpeg"],
|
||||||
|
["jpg", "image/jpeg"],
|
||||||
|
["png", "image/png"],
|
||||||
|
["webp", "image/webp"],
|
||||||
|
])
|
||||||
|
const TEXT_MIMES = new Set([
|
||||||
|
"application/json",
|
||||||
|
"application/ld+json",
|
||||||
|
"application/toml",
|
||||||
|
"application/x-toml",
|
||||||
|
"application/x-yaml",
|
||||||
|
"application/xml",
|
||||||
|
"application/yaml",
|
||||||
|
])
|
||||||
|
|
||||||
|
const SAMPLE = 4096
|
||||||
|
|
||||||
|
function kind(type: string) {
|
||||||
|
return type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function ext(name: string) {
|
||||||
|
const idx = name.lastIndexOf(".")
|
||||||
|
if (idx === -1) return ""
|
||||||
|
return name.slice(idx + 1).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function textMime(type: string) {
|
||||||
|
if (!type) return false
|
||||||
|
if (type.startsWith("text/")) return true
|
||||||
|
if (TEXT_MIMES.has(type)) return true
|
||||||
|
if (type.endsWith("+json")) return true
|
||||||
|
return type.endsWith("+xml")
|
||||||
|
}
|
||||||
|
|
||||||
|
function textBytes(bytes: Uint8Array) {
|
||||||
|
if (bytes.length === 0) return true
|
||||||
|
let count = 0
|
||||||
|
for (const byte of bytes) {
|
||||||
|
if (byte === 0) return false
|
||||||
|
if (byte < 9 || (byte > 13 && byte < 32)) count += 1
|
||||||
|
}
|
||||||
|
return count / bytes.length <= 0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function attachmentMime(file: File) {
|
||||||
|
const type = kind(file.type)
|
||||||
|
if (IMAGE_MIMES.has(type)) return type
|
||||||
|
if (type === "application/pdf") return type
|
||||||
|
|
||||||
|
const suffix = ext(file.name)
|
||||||
|
const fallback = IMAGE_EXTS.get(suffix) ?? (suffix === "pdf" ? "application/pdf" : undefined)
|
||||||
|
if ((!type || type === "application/octet-stream") && fallback) return fallback
|
||||||
|
|
||||||
|
if (textMime(type)) return "text/plain"
|
||||||
|
const bytes = new Uint8Array(await file.slice(0, SAMPLE).arrayBuffer())
|
||||||
|
if (!textBytes(bytes)) return
|
||||||
|
return "text/plain"
|
||||||
|
}
|
||||||
@@ -126,7 +126,7 @@ describe("prompt-input history", () => {
|
|||||||
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
|
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
|
||||||
const value = "a\nb\nc"
|
const value = "a\nb\nc"
|
||||||
|
|
||||||
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
|
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false)
|
||||||
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
|
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
|
||||||
|
|
||||||
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
|
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
|
||||||
@@ -135,11 +135,14 @@ describe("prompt-input history", () => {
|
|||||||
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
|
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
|
||||||
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
|
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
|
||||||
|
|
||||||
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true)
|
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false)
|
||||||
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
|
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
|
||||||
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
|
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
|
||||||
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
|
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
|
||||||
|
|
||||||
|
expect(canNavigateHistoryAtCursor("up", "", 0)).toBe(true)
|
||||||
|
expect(canNavigateHistoryAtCursor("down", "", 0)).toBe(true)
|
||||||
|
|
||||||
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
|
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
|
||||||
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
|
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
|
||||||
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
|
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function canNavigateHistoryAtCursor(direction: "up" | "down", text: strin
|
|||||||
const atStart = position === 0
|
const atStart = position === 0
|
||||||
const atEnd = position === text.length
|
const atEnd = position === text.length
|
||||||
if (inHistory) return atStart || atEnd
|
if (inHistory) return atStart || atEnd
|
||||||
if (direction === "up") return position === 0
|
if (direction === "up") return position === 0 && text.length === 0
|
||||||
return position === text.length
|
return position === text.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
const LARGE_PASTE_CHARS = 8000
|
||||||
|
const LARGE_PASTE_BREAKS = 120
|
||||||
|
|
||||||
|
function largePaste(text: string) {
|
||||||
|
if (text.length >= LARGE_PASTE_CHARS) return true
|
||||||
|
let breaks = 0
|
||||||
|
for (const char of text) {
|
||||||
|
if (char !== "\n") continue
|
||||||
|
breaks += 1
|
||||||
|
if (breaks >= LARGE_PASTE_BREAKS) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePaste(text: string) {
|
||||||
|
if (!text.includes("\r")) return text
|
||||||
|
return text.replace(/\r\n?/g, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pasteMode(text: string) {
|
||||||
|
if (largePaste(text)) return "manual"
|
||||||
|
if (text.includes("\n") || text.includes("\r")) return "manual"
|
||||||
|
return "native"
|
||||||
|
}
|
||||||
@@ -7,12 +7,17 @@ const createdClients: string[] = []
|
|||||||
const createdSessions: string[] = []
|
const createdSessions: string[] = []
|
||||||
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
|
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
|
||||||
const optimistic: Array<{
|
const optimistic: Array<{
|
||||||
|
directory?: string
|
||||||
|
sessionID?: string
|
||||||
message: {
|
message: {
|
||||||
agent: string
|
agent: string
|
||||||
model: { providerID: string; modelID: string }
|
model: { providerID: string; modelID: string }
|
||||||
variant?: string
|
variant?: string
|
||||||
}
|
}
|
||||||
}> = []
|
}> = []
|
||||||
|
const optimisticSeeded: boolean[] = []
|
||||||
|
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||||
|
const promoted: Array<{ directory: string; sessionID: string }> = []
|
||||||
const sentShell: string[] = []
|
const sentShell: string[] = []
|
||||||
const syncedDirectories: string[] = []
|
const syncedDirectories: string[] = []
|
||||||
|
|
||||||
@@ -28,7 +33,12 @@ const clientFor = (directory: string) => {
|
|||||||
session: {
|
session: {
|
||||||
create: async () => {
|
create: async () => {
|
||||||
createdSessions.push(directory)
|
createdSessions.push(directory)
|
||||||
return { data: { id: `session-${createdSessions.length}` } }
|
return {
|
||||||
|
data: {
|
||||||
|
id: `session-${createdSessions.length}`,
|
||||||
|
title: `New session ${createdSessions.length}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
},
|
},
|
||||||
shell: async () => {
|
shell: async () => {
|
||||||
sentShell.push(directory)
|
sentShell.push(directory)
|
||||||
@@ -77,6 +87,11 @@ beforeAll(async () => {
|
|||||||
agent: {
|
agent: {
|
||||||
current: () => ({ name: "agent" }),
|
current: () => ({ name: "agent" }),
|
||||||
},
|
},
|
||||||
|
session: {
|
||||||
|
promote(directory: string, sessionID: string) {
|
||||||
|
promoted.push({ directory, sessionID })
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -129,9 +144,16 @@ beforeAll(async () => {
|
|||||||
session: {
|
session: {
|
||||||
optimistic: {
|
optimistic: {
|
||||||
add: (value: {
|
add: (value: {
|
||||||
|
directory?: string
|
||||||
|
sessionID?: string
|
||||||
message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
|
message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
|
||||||
}) => {
|
}) => {
|
||||||
optimistic.push(value)
|
optimistic.push(value)
|
||||||
|
optimisticSeeded.push(
|
||||||
|
!!value.directory &&
|
||||||
|
!!value.sessionID &&
|
||||||
|
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
remove: () => undefined,
|
remove: () => undefined,
|
||||||
},
|
},
|
||||||
@@ -144,7 +166,21 @@ beforeAll(async () => {
|
|||||||
useGlobalSync: () => ({
|
useGlobalSync: () => ({
|
||||||
child: (directory: string) => {
|
child: (directory: string) => {
|
||||||
syncedDirectories.push(directory)
|
syncedDirectories.push(directory)
|
||||||
return [{}, () => undefined]
|
storedSessions[directory] ??= []
|
||||||
|
return [
|
||||||
|
{ session: storedSessions[directory] },
|
||||||
|
(...args: unknown[]) => {
|
||||||
|
if (args[0] !== "session") return
|
||||||
|
const next = args[1]
|
||||||
|
if (typeof next === "function") {
|
||||||
|
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Array.isArray(next)) {
|
||||||
|
storedSessions[directory] = next as Array<{ id: string; title?: string }>
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
@@ -170,11 +206,14 @@ beforeEach(() => {
|
|||||||
createdSessions.length = 0
|
createdSessions.length = 0
|
||||||
enabledAutoAccept.length = 0
|
enabledAutoAccept.length = 0
|
||||||
optimistic.length = 0
|
optimistic.length = 0
|
||||||
|
optimisticSeeded.length = 0
|
||||||
|
promoted.length = 0
|
||||||
params = {}
|
params = {}
|
||||||
sentShell.length = 0
|
sentShell.length = 0
|
||||||
syncedDirectories.length = 0
|
syncedDirectories.length = 0
|
||||||
selected = "/repo/worktree-a"
|
selected = "/repo/worktree-a"
|
||||||
variant = undefined
|
variant = undefined
|
||||||
|
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("prompt submit worktree selection", () => {
|
describe("prompt submit worktree selection", () => {
|
||||||
@@ -207,7 +246,12 @@ describe("prompt submit worktree selection", () => {
|
|||||||
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||||
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||||
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
||||||
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
|
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||||
|
expect(promoted).toEqual([
|
||||||
|
{ directory: "/repo/worktree-a", sessionID: "session-1" },
|
||||||
|
{ directory: "/repo/worktree-b", sessionID: "session-2" },
|
||||||
|
])
|
||||||
|
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("applies auto-accept to newly created sessions", async () => {
|
test("applies auto-accept to newly created sessions", async () => {
|
||||||
@@ -271,4 +315,32 @@ describe("prompt submit worktree selection", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("seeds new sessions before optimistic prompts are added", async () => {
|
||||||
|
const submit = createPromptSubmit({
|
||||||
|
info: () => undefined,
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => false,
|
||||||
|
mode: () => "normal",
|
||||||
|
working: () => false,
|
||||||
|
editor: () => undefined,
|
||||||
|
queueScroll: () => undefined,
|
||||||
|
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
|
||||||
|
addToHistory: () => undefined,
|
||||||
|
resetHistoryNavigation: () => undefined,
|
||||||
|
setMode: () => undefined,
|
||||||
|
setPopover: () => undefined,
|
||||||
|
newSessionWorktree: () => selected,
|
||||||
|
onNewSessionWorktreeReset: () => undefined,
|
||||||
|
onSubmit: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = { preventDefault: () => undefined } as unknown as Event
|
||||||
|
|
||||||
|
await submit.handleSubmit(event)
|
||||||
|
|
||||||
|
expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }])
|
||||||
|
expect(optimisticSeeded).toEqual([true])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
import type { Accessor } from "solid-js"
|
import type { Accessor } from "solid-js"
|
||||||
import type { FileSelection } from "@/context/file"
|
import type { FileSelection } from "@/context/file"
|
||||||
@@ -9,7 +10,7 @@ import { useLanguage } from "@/context/language"
|
|||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
@@ -25,6 +26,145 @@ type PendingPrompt = {
|
|||||||
|
|
||||||
const pending = new Map<string, PendingPrompt>()
|
const pending = new Map<string, PendingPrompt>()
|
||||||
|
|
||||||
|
export type FollowupDraft = {
|
||||||
|
sessionID: string
|
||||||
|
sessionDirectory: string
|
||||||
|
prompt: Prompt
|
||||||
|
context: (ContextItem & { key: string })[]
|
||||||
|
agent: string
|
||||||
|
model: { providerID: string; modelID: string }
|
||||||
|
variant?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FollowupSendInput = {
|
||||||
|
client: ReturnType<typeof useSDK>["client"]
|
||||||
|
globalSync: ReturnType<typeof useGlobalSync>
|
||||||
|
sync: ReturnType<typeof useSync>
|
||||||
|
draft: FollowupDraft
|
||||||
|
messageID?: string
|
||||||
|
optimisticBusy?: boolean
|
||||||
|
before?: () => Promise<boolean> | boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
|
||||||
|
|
||||||
|
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
|
||||||
|
|
||||||
|
export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||||
|
const text = draftText(input.draft.prompt)
|
||||||
|
const images = draftImages(input.draft.prompt)
|
||||||
|
const [, setStore] = input.globalSync.child(input.draft.sessionDirectory)
|
||||||
|
|
||||||
|
const setBusy = () => {
|
||||||
|
if (!input.optimisticBusy) return
|
||||||
|
setStore("session_status", input.draft.sessionID, { type: "busy" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const setIdle = () => {
|
||||||
|
if (!input.optimisticBusy) return
|
||||||
|
setStore("session_status", input.draft.sessionID, { type: "idle" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const wait = async () => {
|
||||||
|
const ok = await input.before?.()
|
||||||
|
if (ok === false) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const [head, ...tail] = text.split(" ")
|
||||||
|
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
|
||||||
|
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
|
||||||
|
setBusy()
|
||||||
|
try {
|
||||||
|
if (!(await wait())) {
|
||||||
|
setIdle()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
await input.client.session.command({
|
||||||
|
sessionID: input.draft.sessionID,
|
||||||
|
command: cmd,
|
||||||
|
arguments: tail.join(" "),
|
||||||
|
agent: input.draft.agent,
|
||||||
|
model: `${input.draft.model.providerID}/${input.draft.model.modelID}`,
|
||||||
|
variant: input.draft.variant,
|
||||||
|
parts: images.map((attachment) => ({
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
type: "file" as const,
|
||||||
|
mime: attachment.mime,
|
||||||
|
url: attachment.dataUrl,
|
||||||
|
filename: attachment.filename,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
setIdle()
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageID = input.messageID ?? Identifier.ascending("message")
|
||||||
|
const { requestParts, optimisticParts } = buildRequestParts({
|
||||||
|
prompt: input.draft.prompt,
|
||||||
|
context: input.draft.context,
|
||||||
|
images,
|
||||||
|
text,
|
||||||
|
sessionID: input.draft.sessionID,
|
||||||
|
messageID,
|
||||||
|
sessionDirectory: input.draft.sessionDirectory,
|
||||||
|
})
|
||||||
|
|
||||||
|
const message: Message = {
|
||||||
|
id: messageID,
|
||||||
|
sessionID: input.draft.sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent: input.draft.agent,
|
||||||
|
model: input.draft.model,
|
||||||
|
variant: input.draft.variant,
|
||||||
|
}
|
||||||
|
|
||||||
|
const add = () =>
|
||||||
|
input.sync.session.optimistic.add({
|
||||||
|
directory: input.draft.sessionDirectory,
|
||||||
|
sessionID: input.draft.sessionID,
|
||||||
|
message,
|
||||||
|
parts: optimisticParts,
|
||||||
|
})
|
||||||
|
|
||||||
|
const remove = () =>
|
||||||
|
input.sync.session.optimistic.remove({
|
||||||
|
directory: input.draft.sessionDirectory,
|
||||||
|
sessionID: input.draft.sessionID,
|
||||||
|
messageID,
|
||||||
|
})
|
||||||
|
|
||||||
|
setBusy()
|
||||||
|
add()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!(await wait())) {
|
||||||
|
setIdle()
|
||||||
|
remove()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
await input.client.session.promptAsync({
|
||||||
|
sessionID: input.draft.sessionID,
|
||||||
|
agent: input.draft.agent,
|
||||||
|
model: input.draft.model,
|
||||||
|
messageID,
|
||||||
|
parts: requestParts,
|
||||||
|
variant: input.draft.variant,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
setIdle()
|
||||||
|
remove()
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type PromptSubmitInput = {
|
type PromptSubmitInput = {
|
||||||
info: Accessor<{ id: string } | undefined>
|
info: Accessor<{ id: string } | undefined>
|
||||||
imageAttachments: Accessor<ImageAttachmentPart[]>
|
imageAttachments: Accessor<ImageAttachmentPart[]>
|
||||||
@@ -41,6 +181,9 @@ type PromptSubmitInput = {
|
|||||||
setPopover: (popover: "at" | "slash" | null) => void
|
setPopover: (popover: "at" | "slash" | null) => void
|
||||||
newSessionWorktree?: Accessor<string | undefined>
|
newSessionWorktree?: Accessor<string | undefined>
|
||||||
onNewSessionWorktreeReset?: () => void
|
onNewSessionWorktreeReset?: () => void
|
||||||
|
shouldQueue?: Accessor<boolean>
|
||||||
|
onQueue?: (draft: FollowupDraft) => void
|
||||||
|
onAbort?: () => void
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +225,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const [, setStore] = globalSync.child(sdk.directory)
|
const [, setStore] = globalSync.child(sdk.directory)
|
||||||
setStore("todo", sessionID, [])
|
setStore("todo", sessionID, [])
|
||||||
|
|
||||||
|
input.onAbort?.()
|
||||||
|
|
||||||
const queued = pending.get(sessionID)
|
const queued = pending.get(sessionID)
|
||||||
if (queued) {
|
if (queued) {
|
||||||
queued.abort.abort()
|
queued.abort.abort()
|
||||||
@@ -116,6 +261,26 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearContext = () => {
|
||||||
|
for (const item of prompt.context.items()) {
|
||||||
|
prompt.context.remove(item.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seed = (dir: string, info: Session) => {
|
||||||
|
const [, setStore] = globalSync.child(dir)
|
||||||
|
setStore("session", (list: Session[]) => {
|
||||||
|
const result = Binary.search(list, info.id, (item) => item.id)
|
||||||
|
const next = [...list]
|
||||||
|
if (result.found) {
|
||||||
|
next[result.index] = info
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
next.splice(result.index, 0, info)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (event: Event) => {
|
const handleSubmit = async (event: Event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
@@ -131,6 +296,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
|
|
||||||
const currentModel = local.model.current()
|
const currentModel = local.model.current()
|
||||||
const currentAgent = local.agent.current()
|
const currentAgent = local.agent.current()
|
||||||
|
const variant = local.model.variant.current()
|
||||||
if (!currentModel || !currentAgent) {
|
if (!currentModel || !currentAgent) {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.modelAgentRequired.title"),
|
title: language.t("prompt.toast.modelAgentRequired.title"),
|
||||||
@@ -191,7 +357,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
|
|
||||||
let session = input.info()
|
let session = input.info()
|
||||||
if (!session && isNewSession) {
|
if (!session && isNewSession) {
|
||||||
session = await client.session
|
const created = await client.session
|
||||||
.create()
|
.create()
|
||||||
.then((x) => x.data ?? undefined)
|
.then((x) => x.data ?? undefined)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -201,8 +367,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
})
|
})
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
if (session) {
|
if (created) {
|
||||||
|
seed(sessionDirectory, created)
|
||||||
|
session = created
|
||||||
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
||||||
|
local.session.promote(sessionDirectory, session.id)
|
||||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||||
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||||
}
|
}
|
||||||
@@ -215,14 +384,21 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
input.onSubmit?.()
|
|
||||||
|
|
||||||
const model = {
|
const model = {
|
||||||
modelID: currentModel.id,
|
modelID: currentModel.id,
|
||||||
providerID: currentModel.provider.id,
|
providerID: currentModel.provider.id,
|
||||||
}
|
}
|
||||||
const agent = currentAgent.name
|
const agent = currentAgent.name
|
||||||
const variant = local.model.variant.current()
|
const context = prompt.context.items().slice()
|
||||||
|
const draft: FollowupDraft = {
|
||||||
|
sessionID: session.id,
|
||||||
|
sessionDirectory,
|
||||||
|
prompt: currentPrompt,
|
||||||
|
context,
|
||||||
|
agent,
|
||||||
|
model,
|
||||||
|
variant,
|
||||||
|
}
|
||||||
|
|
||||||
const clearInput = () => {
|
const clearInput = () => {
|
||||||
prompt.reset()
|
prompt.reset()
|
||||||
@@ -243,6 +419,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
|
||||||
|
input.onQueue?.(draft)
|
||||||
|
clearContext()
|
||||||
|
clearInput()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
input.onSubmit?.()
|
||||||
|
|
||||||
if (mode === "shell") {
|
if (mode === "shell") {
|
||||||
clearInput()
|
clearInput()
|
||||||
client.session
|
client.session
|
||||||
@@ -295,48 +480,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = prompt.context.items().slice()
|
|
||||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||||
|
|
||||||
const messageID = Identifier.ascending("message")
|
const messageID = Identifier.ascending("message")
|
||||||
const { requestParts, optimisticParts } = buildRequestParts({
|
|
||||||
prompt: currentPrompt,
|
|
||||||
context,
|
|
||||||
images,
|
|
||||||
text,
|
|
||||||
sessionID: session.id,
|
|
||||||
messageID,
|
|
||||||
sessionDirectory,
|
|
||||||
})
|
|
||||||
|
|
||||||
const optimisticMessage: Message = {
|
const removeOptimisticMessage = () => {
|
||||||
id: messageID,
|
|
||||||
sessionID: session.id,
|
|
||||||
role: "user",
|
|
||||||
time: { created: Date.now() },
|
|
||||||
agent,
|
|
||||||
model,
|
|
||||||
variant,
|
|
||||||
}
|
|
||||||
|
|
||||||
const addOptimisticMessage = () =>
|
|
||||||
sync.session.optimistic.add({
|
|
||||||
directory: sessionDirectory,
|
|
||||||
sessionID: session.id,
|
|
||||||
message: optimisticMessage,
|
|
||||||
parts: optimisticParts,
|
|
||||||
})
|
|
||||||
|
|
||||||
const removeOptimisticMessage = () =>
|
|
||||||
sync.session.optimistic.remove({
|
sync.session.optimistic.remove({
|
||||||
directory: sessionDirectory,
|
directory: sessionDirectory,
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
messageID,
|
messageID,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
removeCommentItems(commentItems)
|
removeCommentItems(commentItems)
|
||||||
clearInput()
|
clearInput()
|
||||||
addOptimisticMessage()
|
|
||||||
|
|
||||||
const waitForWorktree = async () => {
|
const waitForWorktree = async () => {
|
||||||
const worktree = WorktreeState.get(sessionDirectory)
|
const worktree = WorktreeState.get(sessionDirectory)
|
||||||
@@ -393,20 +549,15 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const send = async () => {
|
void sendFollowupDraft({
|
||||||
const ok = await waitForWorktree()
|
client,
|
||||||
if (!ok) return
|
sync,
|
||||||
await client.session.promptAsync({
|
globalSync,
|
||||||
sessionID: session.id,
|
draft,
|
||||||
agent,
|
messageID,
|
||||||
model,
|
optimisticBusy: sessionDirectory === projectDirectory,
|
||||||
messageID,
|
before: waitForWorktree,
|
||||||
parts: requestParts,
|
}).catch((err) => {
|
||||||
variant,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
void send().catch((err) => {
|
|
||||||
pending.delete(session.id)
|
pending.delete(session.id)
|
||||||
if (sessionDirectory === projectDirectory) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
sync.set("session_status", session.id, { type: "idle" })
|
sync.set("session_status", session.id, { type: "idle" })
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type ParentProps,
|
type ParentProps,
|
||||||
Show,
|
Show,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
|
import { useLanguage } from "@/context/language"
|
||||||
import { type ServerConnection, serverName } from "@/context/server"
|
import { type ServerConnection, serverName } from "@/context/server"
|
||||||
import type { ServerHealth } from "@/utils/server-health"
|
import type { ServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ interface ServerRowProps extends ParentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ServerRow(props: ServerRowProps) {
|
export function ServerRow(props: ServerRowProps) {
|
||||||
|
const language = useLanguage()
|
||||||
const [truncated, setTruncated] = createSignal(false)
|
const [truncated, setTruncated] = createSignal(false)
|
||||||
let nameRef: HTMLSpanElement | undefined
|
let nameRef: HTMLSpanElement | undefined
|
||||||
let versionRef: HTMLSpanElement | undefined
|
let versionRef: HTMLSpanElement | undefined
|
||||||
@@ -65,22 +67,26 @@ export function ServerRow(props: ServerRowProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
class="flex-1"
|
class="flex-1 min-w-0"
|
||||||
value={tooltipValue()}
|
value={tooltipValue()}
|
||||||
|
contentStyle={{ "max-width": "none", "white-space": "nowrap" }}
|
||||||
placement="top-start"
|
placement="top-start"
|
||||||
inactive={!truncated() && !props.conn.displayName}
|
inactive={!truncated() && !props.conn.displayName}
|
||||||
>
|
>
|
||||||
<div class={props.class} classList={{ "opacity-50": props.dimmed }}>
|
<div class={props.class} classList={{ "opacity-50": props.dimmed }}>
|
||||||
<div class="flex flex-col items-start">
|
<div class="flex flex-col items-start min-w-0 w-full">
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2 min-w-0 w-full">
|
||||||
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
|
<span ref={nameRef} class={`${props.nameClass ?? "truncate"} min-w-0`}>
|
||||||
{name()}
|
{name()}
|
||||||
</span>
|
</span>
|
||||||
<Show
|
<Show
|
||||||
when={badge()}
|
when={badge()}
|
||||||
fallback={
|
fallback={
|
||||||
<Show when={props.status?.version}>
|
<Show when={props.status?.version}>
|
||||||
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
|
<span
|
||||||
|
ref={versionRef}
|
||||||
|
class={`${props.versionClass ?? "text-text-weak text-14-regular truncate"} min-w-0`}
|
||||||
|
>
|
||||||
v{props.status?.version}
|
v{props.status?.version}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -96,7 +102,7 @@ export function ServerRow(props: ServerRowProps) {
|
|||||||
{conn().http.username ? (
|
{conn().http.username ? (
|
||||||
<span class="text-text-weak">{conn().http.username}</span>
|
<span class="text-text-weak">{conn().http.username}</span>
|
||||||
) : (
|
) : (
|
||||||
<span class="text-text-weaker">no username</span>
|
<span class="text-text-weaker">{language.t("server.row.noUsername")}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{conn().http.password && <span class="text-text-weak">••••••••</span>}
|
{conn().http.password && <span class="text-text-weak">••••••••</span>}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { Match, Show, Switch, createMemo } from "solid-js"
|
|||||||
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
|
||||||
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { useParams } from "@solidjs/router"
|
|
||||||
|
|
||||||
|
import { useFile } from "@/context/file"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
||||||
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
|
|
||||||
interface SessionContextUsageProps {
|
interface SessionContextUsageProps {
|
||||||
variant?: "button" | "indicator"
|
variant?: "button" | "indicator"
|
||||||
@@ -27,14 +29,17 @@ function openSessionContext(args: {
|
|||||||
|
|
||||||
export function SessionContextUsage(props: SessionContextUsageProps) {
|
export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const params = useParams()
|
const file = useFile()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const { params, tabs, view } = useSessionLayout()
|
||||||
|
|
||||||
const variant = createMemo(() => props.variant ?? "button")
|
const variant = createMemo(() => props.variant ?? "button")
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const tabState = createSessionTabs({
|
||||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
tabs,
|
||||||
const view = createMemo(() => layout.view(sessionKey))
|
pathFromTab: file.pathFromTab,
|
||||||
|
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||||
|
})
|
||||||
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
|
||||||
|
|
||||||
const usd = createMemo(
|
const usd = createMemo(
|
||||||
@@ -54,7 +59,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
const openContext = () => {
|
const openContext = () => {
|
||||||
if (!params.id) return
|
if (!params.id) return
|
||||||
|
|
||||||
if (tabs().active() === "context") {
|
if (tabState.activeTab() === "context") {
|
||||||
tabs().close("context")
|
tabs().close("context")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
import { useParams } from "@solidjs/router"
|
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useLayout } from "@/context/layout"
|
|
||||||
import { checksum } from "@opencode-ai/util/encode"
|
import { checksum } from "@opencode-ai/util/encode"
|
||||||
import { findLast } from "@opencode-ai/util/array"
|
import { findLast } from "@opencode-ai/util/array"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
@@ -14,6 +12,7 @@ import { Markdown } from "@opencode-ai/ui/markdown"
|
|||||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||||
import { createSessionContextFormatter } from "./session-context-format"
|
import { createSessionContextFormatter } from "./session-context-format"
|
||||||
@@ -91,13 +90,10 @@ const emptyMessages: Message[] = []
|
|||||||
const emptyUserMessages: UserMessage[] = []
|
const emptyUserMessages: UserMessage[] = []
|
||||||
|
|
||||||
export function SessionContextTab() {
|
export function SessionContextTab() {
|
||||||
const params = useParams()
|
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const layout = useLayout()
|
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const { params, view } = useSessionLayout()
|
||||||
|
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
|
||||||
const view = createMemo(() => layout.view(sessionKey))
|
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
|
|
||||||
const messages = createMemo(
|
const messages = createMemo(
|
||||||
@@ -271,14 +267,14 @@ export function SessionContextTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
class="@container h-full pb-10"
|
class="@container h-full"
|
||||||
viewportRef={(el) => {
|
viewportRef={(el) => {
|
||||||
scroll = el
|
scroll = el
|
||||||
restoreScroll()
|
restoreScroll()
|
||||||
}}
|
}}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
<div class="px-6 pt-4 flex flex-col gap-10">
|
<div class="px-6 pt-4 pb-10 flex flex-col gap-10">
|
||||||
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
|
||||||
<For each={stats}>
|
<For each={stats}>
|
||||||
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
|
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
|
||||||
|
|||||||
@@ -4,23 +4,23 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
|||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||||
import { Popover } from "@opencode-ai/ui/popover"
|
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { TextField } from "@opencode-ai/ui/text-field"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { useParams } from "@solidjs/router"
|
|
||||||
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
|
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Portal } from "solid-js/web"
|
import { Portal } from "solid-js/web"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useTerminal } from "@/context/terminal"
|
||||||
|
import { focusTerminalById } from "@/pages/session/helpers"
|
||||||
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
import { messageAgentColor } from "@/utils/agent"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { StatusPopover } from "../status-popover"
|
import { StatusPopover } from "../status-popover"
|
||||||
@@ -48,74 +48,68 @@ type OS = "macos" | "windows" | "linux" | "unknown"
|
|||||||
const MAC_APPS = [
|
const MAC_APPS = [
|
||||||
{
|
{
|
||||||
id: "vscode",
|
id: "vscode",
|
||||||
label: "VS Code",
|
label: "session.header.open.app.vscode",
|
||||||
icon: "vscode",
|
icon: "vscode",
|
||||||
openWith: "Visual Studio Code",
|
openWith: "Visual Studio Code",
|
||||||
},
|
},
|
||||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
|
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
|
||||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
|
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
|
||||||
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
|
{ id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
|
||||||
{
|
{
|
||||||
id: "antigravity",
|
id: "antigravity",
|
||||||
label: "Antigravity",
|
label: "session.header.open.app.antigravity",
|
||||||
icon: "antigravity",
|
icon: "antigravity",
|
||||||
openWith: "Antigravity",
|
openWith: "Antigravity",
|
||||||
},
|
},
|
||||||
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
|
{ id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
|
||||||
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
|
{ id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
|
||||||
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
|
{ id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
|
||||||
{ id: "warp", label: "Warp", icon: "warp", openWith: "Warp" },
|
{ id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
|
||||||
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
|
{ id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
|
||||||
{
|
{
|
||||||
id: "android-studio",
|
id: "android-studio",
|
||||||
label: "Android Studio",
|
label: "session.header.open.app.androidStudio",
|
||||||
icon: "android-studio",
|
icon: "android-studio",
|
||||||
openWith: "Android Studio",
|
openWith: "Android Studio",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sublime-text",
|
id: "sublime-text",
|
||||||
label: "Sublime Text",
|
label: "session.header.open.app.sublimeText",
|
||||||
icon: "sublime-text",
|
icon: "sublime-text",
|
||||||
openWith: "Sublime Text",
|
openWith: "Sublime Text",
|
||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const WINDOWS_APPS = [
|
const WINDOWS_APPS = [
|
||||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
|
||||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
|
||||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
|
||||||
{
|
{
|
||||||
id: "powershell",
|
id: "powershell",
|
||||||
label: "PowerShell",
|
label: "session.header.open.app.powershell",
|
||||||
icon: "powershell",
|
icon: "powershell",
|
||||||
openWith: "powershell",
|
openWith: "powershell",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "sublime-text",
|
id: "sublime-text",
|
||||||
label: "Sublime Text",
|
label: "session.header.open.app.sublimeText",
|
||||||
icon: "sublime-text",
|
icon: "sublime-text",
|
||||||
openWith: "Sublime Text",
|
openWith: "Sublime Text",
|
||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const LINUX_APPS = [
|
const LINUX_APPS = [
|
||||||
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
|
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
|
||||||
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
|
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
|
||||||
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
|
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
|
||||||
{
|
{
|
||||||
id: "sublime-text",
|
id: "sublime-text",
|
||||||
label: "Sublime Text",
|
label: "session.header.open.app.sublimeText",
|
||||||
icon: "sublime-text",
|
icon: "sublime-text",
|
||||||
openWith: "Sublime Text",
|
openWith: "Sublime Text",
|
||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number]
|
|
||||||
type OpenIcon = OpenApp | "file-explorer"
|
|
||||||
const OPEN_ICON_BASE = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
|
|
||||||
|
|
||||||
const openIconSize = (id: OpenIcon) => (OPEN_ICON_BASE.has(id) ? "size-4" : "size-[19px]")
|
|
||||||
|
|
||||||
const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
|
const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
|
||||||
if (platform.platform === "desktop" && platform.os) return platform.os
|
if (platform.platform === "desktop" && platform.os) return platform.os
|
||||||
if (typeof navigator !== "object") return "unknown"
|
if (typeof navigator !== "object") return "unknown"
|
||||||
@@ -134,101 +128,15 @@ const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function useSessionShare(args: {
|
|
||||||
globalSDK: ReturnType<typeof useGlobalSDK>
|
|
||||||
currentSession: () =>
|
|
||||||
| {
|
|
||||||
share?: {
|
|
||||||
url?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
| undefined
|
|
||||||
sessionID: () => string | undefined
|
|
||||||
projectDirectory: () => string
|
|
||||||
platform: ReturnType<typeof usePlatform>
|
|
||||||
}) {
|
|
||||||
const [state, setState] = createStore({
|
|
||||||
share: false,
|
|
||||||
unshare: false,
|
|
||||||
copied: false,
|
|
||||||
timer: undefined as number | undefined,
|
|
||||||
})
|
|
||||||
const shareUrl = createMemo(() => args.currentSession()?.share?.url)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const url = shareUrl()
|
|
||||||
if (url) return
|
|
||||||
if (state.timer) window.clearTimeout(state.timer)
|
|
||||||
setState({ copied: false, timer: undefined })
|
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (state.timer) window.clearTimeout(state.timer)
|
|
||||||
})
|
|
||||||
|
|
||||||
const shareSession = () => {
|
|
||||||
const sessionID = args.sessionID()
|
|
||||||
if (!sessionID || state.share) return
|
|
||||||
setState("share", true)
|
|
||||||
args.globalSDK.client.session
|
|
||||||
.share({ sessionID, directory: args.projectDirectory() })
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Failed to share session", error)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setState("share", false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const unshareSession = () => {
|
|
||||||
const sessionID = args.sessionID()
|
|
||||||
if (!sessionID || state.unshare) return
|
|
||||||
setState("unshare", true)
|
|
||||||
args.globalSDK.client.session
|
|
||||||
.unshare({ sessionID, directory: args.projectDirectory() })
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Failed to unshare session", error)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setState("unshare", false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const copyLink = (onError: (error: unknown) => void) => {
|
|
||||||
const url = shareUrl()
|
|
||||||
if (!url) return
|
|
||||||
navigator.clipboard
|
|
||||||
.writeText(url)
|
|
||||||
.then(() => {
|
|
||||||
if (state.timer) window.clearTimeout(state.timer)
|
|
||||||
setState("copied", true)
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
setState("copied", false)
|
|
||||||
setState("timer", undefined)
|
|
||||||
}, 3000)
|
|
||||||
setState("timer", timer)
|
|
||||||
})
|
|
||||||
.catch(onError)
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewShare = () => {
|
|
||||||
const url = shareUrl()
|
|
||||||
if (!url) return
|
|
||||||
args.platform.openLink(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
return { state, shareUrl, shareSession, unshareSession, copyLink, viewShare }
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SessionHeader() {
|
export function SessionHeader() {
|
||||||
const globalSDK = useGlobalSDK()
|
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const params = useParams()
|
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const sync = useSync()
|
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const sync = useSync()
|
||||||
|
const terminal = useTerminal()
|
||||||
|
const { params, view } = useSessionLayout()
|
||||||
|
|
||||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||||
const project = createMemo(() => {
|
const project = createMemo(() => {
|
||||||
@@ -242,12 +150,6 @@ export function SessionHeader() {
|
|||||||
return getFilename(projectDirectory())
|
return getFilename(projectDirectory())
|
||||||
})
|
})
|
||||||
const hotkey = createMemo(() => command.keybind("file.open"))
|
const hotkey = createMemo(() => command.keybind("file.open"))
|
||||||
|
|
||||||
const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
|
||||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
|
||||||
const showShare = createMemo(() => shareEnabled() && !!params.id)
|
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
|
||||||
const view = createMemo(() => layout.view(sessionKey))
|
|
||||||
const os = createMemo(() => detectOS(platform))
|
const os = createMemo(() => detectOS(platform))
|
||||||
|
|
||||||
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
|
||||||
@@ -261,9 +163,9 @@ export function SessionHeader() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const fileManager = createMemo(() => {
|
const fileManager = createMemo(() => {
|
||||||
if (os() === "macos") return { label: "Finder", icon: "finder" as const }
|
if (os() === "macos") return { label: "session.header.open.finder", icon: "finder" as const }
|
||||||
if (os() === "windows") return { label: "File Explorer", icon: "file-explorer" as const }
|
if (os() === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const }
|
||||||
return { label: "File Manager", icon: "finder" as const }
|
return { label: "session.header.open.fileManager", icon: "finder" as const }
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -279,10 +181,7 @@ export function SessionHeader() {
|
|||||||
Promise.resolve(platform.checkAppExists?.(app.openWith))
|
Promise.resolve(platform.checkAppExists?.(app.openWith))
|
||||||
.then((value) => Boolean(value))
|
.then((value) => Boolean(value))
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
.then((ok) => {
|
.then((ok) => [app.id, ok] as const),
|
||||||
console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`)
|
|
||||||
return [app.id, ok] as const
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
).then((entries) => {
|
).then((entries) => {
|
||||||
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
|
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
|
||||||
@@ -291,11 +190,23 @@ export function SessionHeader() {
|
|||||||
|
|
||||||
const options = createMemo(() => {
|
const options = createMemo(() => {
|
||||||
return [
|
return [
|
||||||
{ id: "finder", label: fileManager().label, icon: fileManager().icon },
|
{ id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
|
||||||
...apps().filter((app) => exists[app.id]),
|
...apps()
|
||||||
|
.filter((app) => exists[app.id])
|
||||||
|
.map((app) => ({ ...app, label: language.t(app.label) })),
|
||||||
] as const
|
] as const
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const toggleTerminal = () => {
|
||||||
|
const next = !view().terminal.opened()
|
||||||
|
view().terminal.toggle()
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
const id = terminal.active()
|
||||||
|
if (!id) return
|
||||||
|
focusTerminalById(id)
|
||||||
|
}
|
||||||
|
|
||||||
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
|
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
|
||||||
const [menu, setMenu] = createStore({ open: false })
|
const [menu, setMenu] = createStore({ open: false })
|
||||||
const [openRequest, setOpenRequest] = createStore({
|
const [openRequest, setOpenRequest] = createStore({
|
||||||
@@ -310,6 +221,9 @@ export function SessionHeader() {
|
|||||||
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
|
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
|
||||||
)
|
)
|
||||||
const opening = createMemo(() => openRequest.app !== undefined)
|
const opening = createMemo(() => openRequest.app !== undefined)
|
||||||
|
const tint = createMemo(() =>
|
||||||
|
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
|
||||||
|
)
|
||||||
|
|
||||||
const selectApp = (app: OpenApp) => {
|
const selectApp = (app: OpenApp) => {
|
||||||
if (!options().some((item) => item.id === app)) return
|
if (!options().some((item) => item.id === app)) return
|
||||||
@@ -348,14 +262,6 @@ export function SessionHeader() {
|
|||||||
.catch((err: unknown) => showRequestError(language, err))
|
.catch((err: unknown) => showRequestError(language, err))
|
||||||
}
|
}
|
||||||
|
|
||||||
const share = useSessionShare({
|
|
||||||
globalSDK,
|
|
||||||
currentSession,
|
|
||||||
sessionID: () => params.id,
|
|
||||||
projectDirectory,
|
|
||||||
platform,
|
|
||||||
})
|
|
||||||
|
|
||||||
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
|
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
|
||||||
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
|
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
|
||||||
|
|
||||||
@@ -368,12 +274,11 @@ export function SessionHeader() {
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="small"
|
size="small"
|
||||||
class="hidden md:flex w-[240px] max-w-full min-w-0 pl-0.5 pr-2 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
|
class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
|
||||||
onClick={() => command.trigger("file.open")}
|
onClick={() => command.trigger("file.open")}
|
||||||
aria-label={language.t("session.header.searchFiles")}
|
aria-label={language.t("session.header.searchFiles")}
|
||||||
>
|
>
|
||||||
<div class="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible">
|
<div class="flex min-w-0 flex-1 items-center overflow-visible">
|
||||||
<Icon name="magnifying-glass" size="small" class="icon-base shrink-0 size-4" />
|
|
||||||
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
|
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
|
||||||
{language.t("session.header.search.placeholder", {
|
{language.t("session.header.search.placeholder", {
|
||||||
project: name(),
|
project: name(),
|
||||||
@@ -383,7 +288,9 @@ export function SessionHeader() {
|
|||||||
|
|
||||||
<Show when={hotkey()}>
|
<Show when={hotkey()}>
|
||||||
{(keybind) => (
|
{(keybind) => (
|
||||||
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0">{keybind()}</Keybind>
|
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
|
||||||
|
{keybind()}
|
||||||
|
</Keybind>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -394,7 +301,6 @@ export function SessionHeader() {
|
|||||||
{(mount) => (
|
{(mount) => (
|
||||||
<Portal mount={mount()}>
|
<Portal mount={mount()}>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<StatusPopover />
|
|
||||||
<Show when={projectDirectory()}>
|
<Show when={projectDirectory()}>
|
||||||
<div class="hidden xl:flex items-center">
|
<div class="hidden xl:flex items-center">
|
||||||
<Show
|
<Show
|
||||||
@@ -419,7 +325,7 @@ export function SessionHeader() {
|
|||||||
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
|
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none disabled:!cursor-default"
|
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
|
||||||
classList={{
|
classList={{
|
||||||
"bg-surface-raised-base-active": opening(),
|
"bg-surface-raised-base-active": opening(),
|
||||||
}}
|
}}
|
||||||
@@ -427,17 +333,12 @@ export function SessionHeader() {
|
|||||||
disabled={opening()}
|
disabled={opening()}
|
||||||
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
|
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
|
||||||
>
|
>
|
||||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
|
||||||
<Show
|
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
|
||||||
when={opening()}
|
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
|
||||||
fallback={<AppIcon id={current().icon} class={openIconSize(current().icon)} />}
|
|
||||||
>
|
|
||||||
<Spinner class="size-3.5 text-icon-base" />
|
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-12-regular text-text-strong">{language.t("common.open")}</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
<div class="self-stretch w-px bg-border-weak-base" />
|
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
gutter={4}
|
gutter={4}
|
||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
@@ -449,17 +350,20 @@ export function SessionHeader() {
|
|||||||
icon="chevron-down"
|
icon="chevron-down"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
disabled={opening()}
|
disabled={opening()}
|
||||||
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
|
class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
|
||||||
classList={{
|
classList={{
|
||||||
"bg-surface-raised-base-active": opening(),
|
"bg-surface-raised-base-active": opening(),
|
||||||
}}
|
}}
|
||||||
aria-label={language.t("session.header.open.menu")}
|
aria-label={language.t("session.header.open.menu")}
|
||||||
/>
|
/>
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content>
|
<DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
|
||||||
<DropdownMenu.Group>
|
<DropdownMenu.Group>
|
||||||
<DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
|
<DropdownMenu.GroupLabel class="!px-1 !py-1">
|
||||||
|
{language.t("session.header.openIn")}
|
||||||
|
</DropdownMenu.GroupLabel>
|
||||||
<DropdownMenu.RadioGroup
|
<DropdownMenu.RadioGroup
|
||||||
|
class="mt-1"
|
||||||
value={current().id}
|
value={current().id}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (!OPEN_APPS.includes(value as OpenApp)) return
|
if (!OPEN_APPS.includes(value as OpenApp)) return
|
||||||
@@ -476,8 +380,8 @@ export function SessionHeader() {
|
|||||||
openDir(o.id)
|
openDir(o.id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
|
||||||
<AppIcon id={o.icon} class={openIconSize(o.icon)} />
|
<AppIcon id={o.icon} />
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
|
||||||
<DropdownMenu.ItemIndicator>
|
<DropdownMenu.ItemIndicator>
|
||||||
@@ -510,146 +414,27 @@ export function SessionHeader() {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={showShare()}>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<Popover
|
|
||||||
title={language.t("session.share.popover.title")}
|
|
||||||
description={
|
|
||||||
share.shareUrl()
|
|
||||||
? language.t("session.share.popover.description.shared")
|
|
||||||
: language.t("session.share.popover.description.unshared")
|
|
||||||
}
|
|
||||||
gutter={4}
|
|
||||||
placement="bottom-end"
|
|
||||||
shift={-64}
|
|
||||||
class="rounded-xl [&_[data-slot=popover-close-button]]:hidden"
|
|
||||||
triggerAs={Button}
|
|
||||||
triggerProps={{
|
|
||||||
variant: "ghost",
|
|
||||||
class:
|
|
||||||
"rounded-md h-[24px] px-3 border border-border-weak-base bg-surface-panel shadow-none data-[expanded]:bg-surface-base-active",
|
|
||||||
classList: {
|
|
||||||
"rounded-r-none": share.shareUrl() !== undefined,
|
|
||||||
"border-r-0": share.shareUrl() !== undefined,
|
|
||||||
},
|
|
||||||
style: { scale: 1 },
|
|
||||||
}}
|
|
||||||
trigger={<span class="text-12-regular">{language.t("session.share.action.share")}</span>}
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<Show
|
|
||||||
when={share.shareUrl()}
|
|
||||||
fallback={
|
|
||||||
<div class="flex">
|
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
variant="primary"
|
|
||||||
class="w-1/2"
|
|
||||||
onClick={share.shareSession}
|
|
||||||
disabled={share.state.share}
|
|
||||||
>
|
|
||||||
{share.state.share
|
|
||||||
? language.t("session.share.action.publishing")
|
|
||||||
: language.t("session.share.action.publish")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<TextField
|
|
||||||
value={share.shareUrl() ?? ""}
|
|
||||||
readOnly
|
|
||||||
copyable
|
|
||||||
copyKind="link"
|
|
||||||
tabIndex={-1}
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<div class="grid grid-cols-2 gap-2">
|
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
variant="secondary"
|
|
||||||
class="w-full shadow-none border border-border-weak-base"
|
|
||||||
onClick={share.unshareSession}
|
|
||||||
disabled={share.state.unshare}
|
|
||||||
>
|
|
||||||
{share.state.unshare
|
|
||||||
? language.t("session.share.action.unpublishing")
|
|
||||||
: language.t("session.share.action.unpublish")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
variant="primary"
|
|
||||||
class="w-full"
|
|
||||||
onClick={share.viewShare}
|
|
||||||
disabled={share.state.unshare}
|
|
||||||
>
|
|
||||||
{language.t("session.share.action.view")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
<Show when={share.shareUrl()} fallback={<div aria-hidden="true" />}>
|
|
||||||
<Tooltip
|
|
||||||
value={
|
|
||||||
share.state.copied
|
|
||||||
? language.t("session.share.copy.copied")
|
|
||||||
: language.t("session.share.copy.copyLink")
|
|
||||||
}
|
|
||||||
placement="top"
|
|
||||||
gutter={8}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
icon={share.state.copied ? "check" : "link"}
|
|
||||||
variant="ghost"
|
|
||||||
class="rounded-l-none h-[24px] border border-border-weak-base bg-surface-panel shadow-none"
|
|
||||||
onClick={() => share.copyLink((error) => showRequestError(language, error))}
|
|
||||||
disabled={share.state.unshare}
|
|
||||||
aria-label={
|
|
||||||
share.state.copied
|
|
||||||
? language.t("session.share.copy.copied")
|
|
||||||
: language.t("session.share.copy.copyLink")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<div class="hidden md:flex items-center gap-1 shrink-0">
|
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||||
<TooltipKeybind
|
<StatusPopover />
|
||||||
title={language.t("command.terminal.toggle")}
|
</Tooltip>
|
||||||
keybind={command.keybind("terminal.toggle")}
|
<TooltipKeybind
|
||||||
|
title={language.t("command.terminal.toggle")}
|
||||||
|
keybind={command.keybind("terminal.toggle")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
|
||||||
|
onClick={toggleTerminal}
|
||||||
|
aria-label={language.t("command.terminal.toggle")}
|
||||||
|
aria-expanded={view().terminal.opened()}
|
||||||
|
aria-controls="terminal-panel"
|
||||||
>
|
>
|
||||||
<Button
|
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
|
||||||
variant="ghost"
|
</Button>
|
||||||
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
</TooltipKeybind>
|
||||||
onClick={() => view().terminal.toggle()}
|
|
||||||
aria-label={language.t("command.terminal.toggle")}
|
|
||||||
aria-expanded={view().terminal.opened()}
|
|
||||||
aria-controls="terminal-panel"
|
|
||||||
>
|
|
||||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
|
||||||
<Icon
|
|
||||||
size="small"
|
|
||||||
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
|
|
||||||
class="group-hover/terminal-toggle:hidden"
|
|
||||||
/>
|
|
||||||
<Icon
|
|
||||||
size="small"
|
|
||||||
name="layout-bottom-partial"
|
|
||||||
class="hidden group-hover/terminal-toggle:inline-block"
|
|
||||||
/>
|
|
||||||
<Icon
|
|
||||||
size="small"
|
|
||||||
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
|
|
||||||
class="hidden group-active/terminal-toggle:inline-block"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
|
|
||||||
|
<div class="hidden md:flex items-center gap-1 shrink-0">
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
title={language.t("command.review.toggle")}
|
title={language.t("command.review.toggle")}
|
||||||
keybind={command.keybind("review.toggle")}
|
keybind={command.keybind("review.toggle")}
|
||||||
@@ -662,23 +447,7 @@ export function SessionHeader() {
|
|||||||
aria-expanded={view().reviewPanel.opened()}
|
aria-expanded={view().reviewPanel.opened()}
|
||||||
aria-controls="review-panel"
|
aria-controls="review-panel"
|
||||||
>
|
>
|
||||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
|
||||||
<Icon
|
|
||||||
size="small"
|
|
||||||
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-right"}
|
|
||||||
class="group-hover/review-toggle:hidden"
|
|
||||||
/>
|
|
||||||
<Icon
|
|
||||||
size="small"
|
|
||||||
name="layout-right-partial"
|
|
||||||
class="hidden group-hover/review-toggle:inline-block"
|
|
||||||
/>
|
|
||||||
<Icon
|
|
||||||
size="small"
|
|
||||||
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-partial"}
|
|
||||||
class="hidden group-active/review-toggle:inline-block"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ const ROOT_CLASS = "size-full flex flex-col"
|
|||||||
|
|
||||||
interface NewSessionViewProps {
|
interface NewSessionViewProps {
|
||||||
worktree: string
|
worktree: string
|
||||||
onWorktreeChange: (value: string) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewSessionView(props: NewSessionViewProps) {
|
export function NewSessionView(props: NewSessionViewProps) {
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
|||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title"
|
||||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { focusTerminalById } from "@/pages/session/helpers"
|
||||||
|
|
||||||
export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element {
|
export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element {
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
@@ -22,15 +24,12 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
|||||||
})
|
})
|
||||||
let input: HTMLInputElement | undefined
|
let input: HTMLInputElement | undefined
|
||||||
let blurFrame: number | undefined
|
let blurFrame: number | undefined
|
||||||
|
let editRequested = false
|
||||||
|
|
||||||
const isDefaultTitle = () => {
|
const isDefaultTitle = () => {
|
||||||
const number = props.terminal.titleNumber
|
const number = props.terminal.titleNumber
|
||||||
if (!Number.isFinite(number) || number <= 0) return false
|
if (!Number.isFinite(number) || number <= 0) return false
|
||||||
const match = props.terminal.title.match(/^Terminal (\d+)$/)
|
return isDefaultTerminalTitle(props.terminal.title, number)
|
||||||
if (!match) return false
|
|
||||||
const parsed = Number(match[1])
|
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) return false
|
|
||||||
return parsed === number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const label = () => {
|
const label = () => {
|
||||||
@@ -53,21 +52,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
|||||||
|
|
||||||
const focus = () => {
|
const focus = () => {
|
||||||
if (store.editing) return
|
if (store.editing) return
|
||||||
|
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||||
if (document.activeElement instanceof HTMLElement) {
|
focusTerminalById(props.terminal.id)
|
||||||
document.activeElement.blur()
|
|
||||||
}
|
|
||||||
const wrapper = document.getElementById(`terminal-wrapper-${props.terminal.id}`)
|
|
||||||
const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
|
|
||||||
if (!element) return
|
|
||||||
|
|
||||||
const textarea = element.querySelector("textarea") as HTMLTextAreaElement
|
|
||||||
if (textarea) {
|
|
||||||
textarea.focus()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
element.focus()
|
|
||||||
element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const edit = (e?: Event) => {
|
const edit = (e?: Event) => {
|
||||||
@@ -183,8 +169,14 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
|
|||||||
left: `${store.menuPosition.x}px`,
|
left: `${store.menuPosition.x}px`,
|
||||||
top: `${store.menuPosition.y}px`,
|
top: `${store.menuPosition.y}px`,
|
||||||
}}
|
}}
|
||||||
|
onCloseAutoFocus={(e) => {
|
||||||
|
if (!editRequested) return
|
||||||
|
e.preventDefault()
|
||||||
|
editRequested = false
|
||||||
|
requestAnimationFrame(() => edit())
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.Item onSelect={edit}>
|
<DropdownMenu.Item onSelect={() => (editRequested = true)}>
|
||||||
<Icon name="edit" class="w-4 h-4 mr-2" />
|
<Icon name="edit" class="w-4 h-4 mr-2" />
|
||||||
{language.t("common.rename")}
|
{language.t("common.rename")}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Component } from "solid-js"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
|
|
||||||
export const SettingsAgents: Component = () => {
|
|
||||||
// TODO: Replace this placeholder with full agents settings controls.
|
|
||||||
const language = useLanguage()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col h-full overflow-y-auto">
|
|
||||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.agents.title")}</h2>
|
|
||||||
<p class="text-14-regular text-text-weak">{language.t("settings.agents.description")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Component } from "solid-js"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
|
|
||||||
export const SettingsCommands: Component = () => {
|
|
||||||
// TODO: Replace this placeholder with full commands settings controls.
|
|
||||||
const language = useLanguage()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col h-full overflow-y-auto">
|
|
||||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.commands.title")}</h2>
|
|
||||||
<p class="text-14-regular text-text-weak">{language.t("settings.commands.description")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,26 +1,41 @@
|
|||||||
import { Component, Show, createMemo, createResource, type JSX } from "solid-js"
|
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Select } from "@opencode-ai/ui/select"
|
import { Select } from "@opencode-ai/ui/select"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSettings, monoFontFamily } from "@/context/settings"
|
import { useSettings, monoFontFamily } from "@/context/settings"
|
||||||
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
|
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||||
import { Link } from "./link"
|
import { Link } from "./link"
|
||||||
|
import { SettingsList } from "./settings-list"
|
||||||
|
|
||||||
let demoSoundState = {
|
let demoSoundState = {
|
||||||
cleanup: undefined as (() => void) | undefined,
|
cleanup: undefined as (() => void) | undefined,
|
||||||
timeout: undefined as NodeJS.Timeout | undefined,
|
timeout: undefined as NodeJS.Timeout | undefined,
|
||||||
|
run: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
type ThemeOption = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
|
||||||
|
|
||||||
|
function loadFont() {
|
||||||
|
font ??= import("@opencode-ai/ui/font-loader")
|
||||||
|
return font
|
||||||
}
|
}
|
||||||
|
|
||||||
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
||||||
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
||||||
const stopDemoSound = () => {
|
const stopDemoSound = () => {
|
||||||
|
demoSoundState.run += 1
|
||||||
if (demoSoundState.cleanup) {
|
if (demoSoundState.cleanup) {
|
||||||
demoSoundState.cleanup()
|
demoSoundState.cleanup()
|
||||||
}
|
}
|
||||||
@@ -28,12 +43,19 @@ const stopDemoSound = () => {
|
|||||||
demoSoundState.cleanup = undefined
|
demoSoundState.cleanup = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const playDemoSound = (src: string | undefined) => {
|
const playDemoSound = (id: string | undefined) => {
|
||||||
stopDemoSound()
|
stopDemoSound()
|
||||||
if (!src) return
|
if (!id) return
|
||||||
|
|
||||||
|
const run = ++demoSoundState.run
|
||||||
demoSoundState.timeout = setTimeout(() => {
|
demoSoundState.timeout = setTimeout(() => {
|
||||||
demoSoundState.cleanup = playSound(src)
|
void playSoundById(id).then((cleanup) => {
|
||||||
|
if (demoSoundState.run !== run) {
|
||||||
|
cleanup?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
demoSoundState.cleanup = cleanup
|
||||||
|
})
|
||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +65,10 @@ export const SettingsGeneral: Component = () => {
|
|||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void theme.loadThemes()
|
||||||
|
})
|
||||||
|
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
checking: false,
|
checking: false,
|
||||||
})
|
})
|
||||||
@@ -103,9 +129,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
.finally(() => setStore("checking", false))
|
.finally(() => setStore("checking", false))
|
||||||
}
|
}
|
||||||
|
|
||||||
const themeOptions = createMemo(() =>
|
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
|
||||||
Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
|
|
||||||
)
|
|
||||||
|
|
||||||
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
|
||||||
{ value: "system", label: language.t("theme.scheme.system") },
|
{ value: "system", label: language.t("theme.scheme.system") },
|
||||||
@@ -113,6 +137,11 @@ export const SettingsGeneral: Component = () => {
|
|||||||
{ value: "dark", label: language.t("theme.scheme.dark") },
|
{ value: "dark", label: language.t("theme.scheme.dark") },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const followupOptions = createMemo((): { value: "queue" | "steer"; label: string }[] => [
|
||||||
|
{ value: "queue", label: language.t("settings.general.row.followup.option.queue") },
|
||||||
|
{ value: "steer", label: language.t("settings.general.row.followup.option.steer") },
|
||||||
|
])
|
||||||
|
|
||||||
const languageOptions = createMemo(() =>
|
const languageOptions = createMemo(() =>
|
||||||
language.locales.map((locale) => ({
|
language.locales.map((locale) => ({
|
||||||
value: locale,
|
value: locale,
|
||||||
@@ -137,7 +166,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
] as const
|
] as const
|
||||||
const fontOptionsList = [...fontOptions]
|
const fontOptionsList = [...fontOptions]
|
||||||
|
|
||||||
const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const
|
const noneSound = { id: "none", label: "sound.option.none" } as const
|
||||||
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||||
|
|
||||||
const soundSelectProps = (
|
const soundSelectProps = (
|
||||||
@@ -152,7 +181,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
|
||||||
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
|
||||||
if (!option) return
|
if (!option) return
|
||||||
playDemoSound(option.src)
|
playDemoSound(option.id === "none" ? undefined : option.id)
|
||||||
},
|
},
|
||||||
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
|
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
|
||||||
if (!option) return
|
if (!option) return
|
||||||
@@ -163,18 +192,16 @@ export const SettingsGeneral: Component = () => {
|
|||||||
}
|
}
|
||||||
setEnabled(true)
|
setEnabled(true)
|
||||||
set(option.id)
|
set(option.id)
|
||||||
playDemoSound(option.src)
|
playDemoSound(option.id)
|
||||||
},
|
},
|
||||||
variant: "secondary" as const,
|
variant: "secondary" as const,
|
||||||
size: "small" as const,
|
size: "small" as const,
|
||||||
triggerVariant: "settings" as const,
|
triggerVariant: "settings" as const,
|
||||||
})
|
})
|
||||||
|
|
||||||
const AppearanceSection = () => (
|
const GeneralSection = () => (
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
|
<SettingsList>
|
||||||
|
|
||||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={language.t("settings.general.row.language.title")}
|
title={language.t("settings.general.row.language.title")}
|
||||||
description={language.t("settings.general.row.language.description")}
|
description={language.t("settings.general.row.language.description")}
|
||||||
@@ -193,8 +220,70 @@ export const SettingsGeneral: Component = () => {
|
|||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={language.t("settings.general.row.appearance.title")}
|
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||||
description={language.t("settings.general.row.appearance.description")}
|
description={language.t("settings.general.row.reasoningSummaries.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-feed-reasoning-summaries">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.showReasoningSummaries()}
|
||||||
|
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
||||||
|
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-feed-shell-tool-parts-expanded">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.shellToolPartsExpanded()}
|
||||||
|
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.editToolPartsExpanded.title")}
|
||||||
|
description={language.t("settings.general.row.editToolPartsExpanded.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-feed-edit-tool-parts-expanded">
|
||||||
|
<Switch
|
||||||
|
checked={settings.general.editToolPartsExpanded()}
|
||||||
|
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.followup.title")}
|
||||||
|
description={language.t("settings.general.row.followup.description")}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
data-action="settings-followup"
|
||||||
|
options={followupOptions()}
|
||||||
|
current={followupOptions().find((o) => o.value === settings.general.followup())}
|
||||||
|
value={(o) => o.value}
|
||||||
|
label={(o) => o.label}
|
||||||
|
onSelect={(option) => option && settings.general.setFollowup(option.value)}
|
||||||
|
variant="secondary"
|
||||||
|
size="small"
|
||||||
|
triggerVariant="settings"
|
||||||
|
triggerStyle={{ "min-width": "180px" }}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsList>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const AppearanceSection = () => (
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
|
||||||
|
|
||||||
|
<SettingsList>
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("settings.general.row.colorScheme.title")}
|
||||||
|
description={language.t("settings.general.row.colorScheme.description")}
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
data-action="settings-color-scheme"
|
data-action="settings-color-scheme"
|
||||||
@@ -211,6 +300,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
triggerVariant="settings"
|
triggerVariant="settings"
|
||||||
|
triggerStyle={{ "min-width": "220px" }}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
@@ -254,6 +344,9 @@ export const SettingsGeneral: Component = () => {
|
|||||||
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
|
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
|
||||||
value={(o) => o.value}
|
value={(o) => o.value}
|
||||||
label={(o) => language.t(o.label)}
|
label={(o) => language.t(o.label)}
|
||||||
|
onHighlight={(option) => {
|
||||||
|
void loadFont().then((x) => x.ensureMonoFont(option?.value))
|
||||||
|
}}
|
||||||
onSelect={(option) => option && settings.appearance.setFont(option.value)}
|
onSelect={(option) => option && settings.appearance.setFont(option.value)}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -267,51 +360,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
)}
|
)}
|
||||||
</Select>
|
</Select>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</div>
|
</SettingsList>
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
const FeedSection = () => (
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.feed")}</h3>
|
|
||||||
|
|
||||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
|
||||||
description={language.t("settings.general.row.reasoningSummaries.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-feed-reasoning-summaries">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.showReasoningSummaries()}
|
|
||||||
onChange={(checked) => settings.general.setShowReasoningSummaries(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
|
|
||||||
description={language.t("settings.general.row.shellToolPartsExpanded.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-feed-shell-tool-parts-expanded">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.shellToolPartsExpanded()}
|
|
||||||
onChange={(checked) => settings.general.setShellToolPartsExpanded(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.editToolPartsExpanded.title")}
|
|
||||||
description={language.t("settings.general.row.editToolPartsExpanded.description")}
|
|
||||||
>
|
|
||||||
<div data-action="settings-feed-edit-tool-parts-expanded">
|
|
||||||
<Switch
|
|
||||||
checked={settings.general.editToolPartsExpanded()}
|
|
||||||
onChange={(checked) => settings.general.setEditToolPartsExpanded(checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -319,7 +368,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
|
||||||
|
|
||||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
<SettingsList>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={language.t("settings.general.notifications.agent.title")}
|
title={language.t("settings.general.notifications.agent.title")}
|
||||||
description={language.t("settings.general.notifications.agent.description")}
|
description={language.t("settings.general.notifications.agent.description")}
|
||||||
@@ -355,7 +404,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</div>
|
</SettingsList>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -363,7 +412,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
|
||||||
|
|
||||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
<SettingsList>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={language.t("settings.general.sounds.agent.title")}
|
title={language.t("settings.general.sounds.agent.title")}
|
||||||
description={language.t("settings.general.sounds.agent.description")}
|
description={language.t("settings.general.sounds.agent.description")}
|
||||||
@@ -408,7 +457,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</div>
|
</SettingsList>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -416,7 +465,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
|
||||||
|
|
||||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
<SettingsList>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={language.t("settings.updates.row.startup.title")}
|
title={language.t("settings.updates.row.startup.title")}
|
||||||
description={language.t("settings.updates.row.startup.description")}
|
description={language.t("settings.updates.row.startup.description")}
|
||||||
@@ -452,7 +501,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
: language.t("settings.updates.action.checkNow")}
|
: language.t("settings.updates.action.checkNow")}
|
||||||
</Button>
|
</Button>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</div>
|
</SettingsList>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -465,9 +514,9 @@ export const SettingsGeneral: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-8 w-full">
|
<div class="flex flex-col gap-8 w-full">
|
||||||
<AppearanceSection />
|
<GeneralSection />
|
||||||
|
|
||||||
<FeedSection />
|
<AppearanceSection />
|
||||||
|
|
||||||
<NotificationsSection />
|
<NotificationsSection />
|
||||||
|
|
||||||
@@ -482,7 +531,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.desktop.section.wsl")}</h3>
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.desktop.section.wsl")}</h3>
|
||||||
|
|
||||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
<SettingsList>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={language.t("settings.desktop.wsl.title")}
|
title={language.t("settings.desktop.wsl.title")}
|
||||||
description={language.t("settings.desktop.wsl.description")}
|
description={language.t("settings.desktop.wsl.description")}
|
||||||
@@ -495,7 +544,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</div>
|
</SettingsList>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -515,7 +564,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
|
||||||
|
|
||||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
<SettingsList>
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={
|
title={
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -533,7 +582,7 @@ export const SettingsGeneral: Component = () => {
|
|||||||
<Switch checked={value() === "wayland"} onChange={onChange} />
|
<Switch checked={value() === "wayland"} onChange={onChange} />
|
||||||
</div>
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</div>
|
</SettingsList>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -551,12 +600,12 @@ interface SettingsRowProps {
|
|||||||
|
|
||||||
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
<div class="flex flex-wrap items-center gap-4 py-3 border-b border-border-weak-base last:border-none sm:flex-nowrap">
|
||||||
<div class="flex flex-col gap-0.5 min-w-0">
|
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
<span class="text-14-medium text-text-strong">{props.title}</span>
|
<span class="text-14-medium text-text-strong">{props.title}</span>
|
||||||
<span class="text-12-regular text-text-weak">{props.description}</span>
|
<span class="text-12-regular text-text-weak">{props.description}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-shrink-0">{props.children}</div>
|
<div class="flex w-full justify-end sm:w-auto sm:shrink-0">{props.children}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import fuzzysort from "fuzzysort"
|
|||||||
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { SettingsList } from "./settings-list"
|
||||||
|
|
||||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||||
const PALETTE_ID = "command.palette"
|
const PALETTE_ID = "command.palette"
|
||||||
@@ -238,7 +239,7 @@ function useKeyCapture(input: {
|
|||||||
showToast({
|
showToast({
|
||||||
title: input.language.t("settings.shortcuts.conflict.title"),
|
title: input.language.t("settings.shortcuts.conflict.title"),
|
||||||
description: input.language.t("settings.shortcuts.conflict.description", {
|
description: input.language.t("settings.shortcuts.conflict.description", {
|
||||||
keybind: formatKeybind(next),
|
keybind: formatKeybind(next, input.language.t),
|
||||||
titles: [...conflicts.values()].join(", "),
|
titles: [...conflicts.values()].join(", "),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -406,7 +407,7 @@ export const SettingsKeybinds: Component = () => {
|
|||||||
<Show when={(filtered().get(group) ?? []).length > 0}>
|
<Show when={(filtered().get(group) ?? []).length > 0}>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t(groupKey[group])}</h3>
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t(groupKey[group])}</h3>
|
||||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
<SettingsList>
|
||||||
<For each={filtered().get(group) ?? []}>
|
<For each={filtered().get(group) ?? []}>
|
||||||
{(id) => (
|
{(id) => (
|
||||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||||
@@ -432,7 +433,7 @@ export const SettingsKeybinds: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</SettingsList>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { type Component, type JSX } from "solid-js"
|
||||||
|
|
||||||
|
export const SettingsList: Component<{ children: JSX.Element }> = (props) => {
|
||||||
|
return <div class="bg-surface-base px-4 rounded-lg">{props.children}</div>
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Component } from "solid-js"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
|
|
||||||
export const SettingsMcp: Component = () => {
|
|
||||||
// TODO: Replace this placeholder with full MCP settings controls.
|
|
||||||
const language = useLanguage()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col h-full overflow-y-auto">
|
|
||||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.mcp.title")}</h2>
|
|
||||||
<p class="text-14-regular text-text-weak">{language.t("settings.mcp.description")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,7 @@ import { type Component, For, Show } from "solid-js"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useModels } from "@/context/models"
|
import { useModels } from "@/context/models"
|
||||||
import { popularProviders } from "@/hooks/use-providers"
|
import { popularProviders } from "@/hooks/use-providers"
|
||||||
|
import { SettingsList } from "./settings-list"
|
||||||
|
|
||||||
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
|
||||||
|
|
||||||
@@ -100,7 +101,7 @@ export const SettingsModels: Component = () => {
|
|||||||
<ProviderIcon id={group.category} class="size-5 shrink-0 icon-strong-base" />
|
<ProviderIcon id={group.category} class="size-5 shrink-0 icon-strong-base" />
|
||||||
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
|
<span class="text-14-medium text-text-strong">{group.items[0].provider.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
<SettingsList>
|
||||||
<For each={group.items}>
|
<For each={group.items}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const key = { providerID: item.provider.id, modelID: item.id }
|
const key = { providerID: item.provider.id, modelID: item.id }
|
||||||
@@ -124,7 +125,7 @@ export const SettingsModels: Component = () => {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</SettingsList>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -1,230 +0,0 @@
|
|||||||
import { Select } from "@opencode-ai/ui/select"
|
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
|
||||||
import { Component, For, createMemo, type JSX } from "solid-js"
|
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
|
||||||
import { useLanguage } from "@/context/language"
|
|
||||||
|
|
||||||
type PermissionAction = "allow" | "ask" | "deny"
|
|
||||||
|
|
||||||
type PermissionObject = Record<string, PermissionAction>
|
|
||||||
type PermissionValue = PermissionAction | PermissionObject | string[] | undefined
|
|
||||||
type PermissionMap = Record<string, PermissionValue>
|
|
||||||
|
|
||||||
type PermissionItem = {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACTIONS = [
|
|
||||||
{ value: "allow", label: "settings.permissions.action.allow" },
|
|
||||||
{ value: "ask", label: "settings.permissions.action.ask" },
|
|
||||||
{ value: "deny", label: "settings.permissions.action.deny" },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const ITEMS = [
|
|
||||||
{
|
|
||||||
id: "read",
|
|
||||||
title: "settings.permissions.tool.read.title",
|
|
||||||
description: "settings.permissions.tool.read.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "edit",
|
|
||||||
title: "settings.permissions.tool.edit.title",
|
|
||||||
description: "settings.permissions.tool.edit.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "glob",
|
|
||||||
title: "settings.permissions.tool.glob.title",
|
|
||||||
description: "settings.permissions.tool.glob.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "grep",
|
|
||||||
title: "settings.permissions.tool.grep.title",
|
|
||||||
description: "settings.permissions.tool.grep.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "list",
|
|
||||||
title: "settings.permissions.tool.list.title",
|
|
||||||
description: "settings.permissions.tool.list.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "bash",
|
|
||||||
title: "settings.permissions.tool.bash.title",
|
|
||||||
description: "settings.permissions.tool.bash.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "task",
|
|
||||||
title: "settings.permissions.tool.task.title",
|
|
||||||
description: "settings.permissions.tool.task.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "skill",
|
|
||||||
title: "settings.permissions.tool.skill.title",
|
|
||||||
description: "settings.permissions.tool.skill.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "lsp",
|
|
||||||
title: "settings.permissions.tool.lsp.title",
|
|
||||||
description: "settings.permissions.tool.lsp.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "todoread",
|
|
||||||
title: "settings.permissions.tool.todoread.title",
|
|
||||||
description: "settings.permissions.tool.todoread.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "todowrite",
|
|
||||||
title: "settings.permissions.tool.todowrite.title",
|
|
||||||
description: "settings.permissions.tool.todowrite.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "webfetch",
|
|
||||||
title: "settings.permissions.tool.webfetch.title",
|
|
||||||
description: "settings.permissions.tool.webfetch.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "websearch",
|
|
||||||
title: "settings.permissions.tool.websearch.title",
|
|
||||||
description: "settings.permissions.tool.websearch.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "codesearch",
|
|
||||||
title: "settings.permissions.tool.codesearch.title",
|
|
||||||
description: "settings.permissions.tool.codesearch.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "external_directory",
|
|
||||||
title: "settings.permissions.tool.external_directory.title",
|
|
||||||
description: "settings.permissions.tool.external_directory.description",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "doom_loop",
|
|
||||||
title: "settings.permissions.tool.doom_loop.title",
|
|
||||||
description: "settings.permissions.tool.doom_loop.description",
|
|
||||||
},
|
|
||||||
] as const
|
|
||||||
|
|
||||||
const VALID_ACTIONS = new Set<PermissionAction>(["allow", "ask", "deny"])
|
|
||||||
|
|
||||||
function toMap(value: unknown): PermissionMap {
|
|
||||||
if (value && typeof value === "object" && !Array.isArray(value)) return value as PermissionMap
|
|
||||||
|
|
||||||
const action = getAction(value)
|
|
||||||
if (action) return { "*": action }
|
|
||||||
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAction(value: unknown): PermissionAction | undefined {
|
|
||||||
if (typeof value === "string" && VALID_ACTIONS.has(value as PermissionAction)) return value as PermissionAction
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRuleDefault(value: unknown): PermissionAction | undefined {
|
|
||||||
const action = getAction(value)
|
|
||||||
if (action) return action
|
|
||||||
|
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return
|
|
||||||
|
|
||||||
return getAction((value as Record<string, unknown>)["*"])
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsPermissions: Component = () => {
|
|
||||||
const globalSync = useGlobalSync()
|
|
||||||
const language = useLanguage()
|
|
||||||
|
|
||||||
const actions = createMemo(
|
|
||||||
(): Array<{ value: PermissionAction; label: string }> =>
|
|
||||||
ACTIONS.map((action) => ({
|
|
||||||
value: action.value,
|
|
||||||
label: language.t(action.label),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
|
|
||||||
const permission = createMemo(() => {
|
|
||||||
return toMap(globalSync.data.config.permission)
|
|
||||||
})
|
|
||||||
|
|
||||||
const actionFor = (id: string): PermissionAction => {
|
|
||||||
const value = permission()[id]
|
|
||||||
const direct = getRuleDefault(value)
|
|
||||||
if (direct) return direct
|
|
||||||
|
|
||||||
const wildcard = getRuleDefault(permission()["*"])
|
|
||||||
if (wildcard) return wildcard
|
|
||||||
|
|
||||||
return "allow"
|
|
||||||
}
|
|
||||||
|
|
||||||
const setPermission = async (id: string, action: PermissionAction) => {
|
|
||||||
const before = globalSync.data.config.permission
|
|
||||||
const map = toMap(before)
|
|
||||||
const existing = map[id]
|
|
||||||
|
|
||||||
const nextValue =
|
|
||||||
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action
|
|
||||||
|
|
||||||
const rollback = (err: unknown) => {
|
|
||||||
globalSync.set("config", "permission", before)
|
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
|
||||||
showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message })
|
|
||||||
}
|
|
||||||
|
|
||||||
globalSync.set("config", "permission", { ...map, [id]: nextValue })
|
|
||||||
globalSync.updateConfig({ permission: { [id]: nextValue } }).catch(rollback)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
|
|
||||||
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
|
|
||||||
<div class="flex flex-col gap-1 px-4 py-8 sm:p-8 max-w-[720px]">
|
|
||||||
<h2 class="text-16-medium text-text-strong">{language.t("settings.permissions.title")}</h2>
|
|
||||||
<p class="text-14-regular text-text-weak">{language.t("settings.permissions.description")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-6 px-4 py-6 sm:p-8 sm:pt-6 max-w-[720px]">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<h3 class="text-14-medium text-text-strong">{language.t("settings.permissions.section.tools")}</h3>
|
|
||||||
<div class="border border-border-weak-base rounded-lg overflow-hidden">
|
|
||||||
<For each={ITEMS}>
|
|
||||||
{(item) => (
|
|
||||||
<SettingsRow title={language.t(item.title)} description={language.t(item.description)}>
|
|
||||||
<Select
|
|
||||||
options={actions()}
|
|
||||||
current={actions().find((o) => o.value === actionFor(item.id))}
|
|
||||||
value={(o) => o.value}
|
|
||||||
label={(o) => o.label}
|
|
||||||
onSelect={(option) => option && setPermission(item.id, option.value)}
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
triggerVariant="settings"
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
)}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingsRowProps {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
children: JSX.Element
|
|
||||||
}
|
|
||||||
|
|
||||||
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
|
||||||
return (
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
|
|
||||||
<div class="flex flex-col gap-0.5 min-w-0">
|
|
||||||
<span class="text-14-medium text-text-strong">{props.title}</span>
|
|
||||||
<span class="text-12-regular text-text-weak">{props.description}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex-shrink-0">{props.children}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,7 @@ import { useGlobalSync } from "@/context/global-sync"
|
|||||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||||
|
import { SettingsList } from "./settings-list"
|
||||||
|
|
||||||
type ProviderSource = "env" | "api" | "config" | "custom"
|
type ProviderSource = "env" | "api" | "config" | "custom"
|
||||||
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
|
||||||
@@ -136,7 +137,7 @@ export const SettingsProviders: Component = () => {
|
|||||||
<div class="flex flex-col gap-8 max-w-[720px]">
|
<div class="flex flex-col gap-8 max-w-[720px]">
|
||||||
<div class="flex flex-col gap-1" data-component="connected-providers-section">
|
<div class="flex flex-col gap-1" data-component="connected-providers-section">
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3>
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3>
|
||||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
<SettingsList>
|
||||||
<Show
|
<Show
|
||||||
when={connected().length > 0}
|
when={connected().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -169,12 +170,12 @@ export const SettingsProviders: Component = () => {
|
|||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</SettingsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.popular")}</h3>
|
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.popular")}</h3>
|
||||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
<SettingsList>
|
||||||
<For each={popular()}>
|
<For each={popular()}>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
|
<div class="flex flex-wrap items-center justify-between gap-4 min-h-16 py-3 border-b border-border-weak-base last:border-none">
|
||||||
@@ -232,7 +233,7 @@ export const SettingsProviders: Component = () => {
|
|||||||
{language.t("common.connect")}
|
{language.t("common.connect")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SettingsList>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Icon } from "@opencode-ai/ui/icon"
|
|||||||
import { Popover } from "@opencode-ai/ui/popover"
|
import { Popover } from "@opencode-ai/ui/popover"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { useNavigate } from "@solidjs/router"
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||||
@@ -14,8 +15,7 @@ import { usePlatform } from "@/context/platform"
|
|||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||||
import { DialogSelectServer } from "./dialog-select-server"
|
|
||||||
|
|
||||||
const pollMs = 10_000
|
const pollMs = 10_000
|
||||||
|
|
||||||
@@ -53,10 +53,15 @@ const listServersByHealth = (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => {
|
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
|
||||||
|
const checkServerHealth = useCheckServerHealth()
|
||||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
if (!enabled()) {
|
||||||
|
setStatus(reconcile({}))
|
||||||
|
return
|
||||||
|
}
|
||||||
const list = servers()
|
const list = servers()
|
||||||
let dead = false
|
let dead = false
|
||||||
|
|
||||||
@@ -64,7 +69,7 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typ
|
|||||||
const results: Record<string, ServerHealth> = {}
|
const results: Record<string, ServerHealth> = {}
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
list.map(async (conn) => {
|
list.map(async (conn) => {
|
||||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher)
|
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (dead) return
|
if (dead) return
|
||||||
@@ -85,15 +90,17 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typ
|
|||||||
const useDefaultServerKey = (
|
const useDefaultServerKey = (
|
||||||
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
||||||
) => {
|
) => {
|
||||||
const [url, setUrl] = createSignal<string | undefined>()
|
const [state, setState] = createStore({
|
||||||
const [tick, setTick] = createSignal(0)
|
url: undefined as string | undefined,
|
||||||
|
tick: 0,
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
tick()
|
state.tick
|
||||||
let dead = false
|
let dead = false
|
||||||
const result = get?.()
|
const result = get?.()
|
||||||
if (!result) {
|
if (!result) {
|
||||||
setUrl(undefined)
|
setState("url", undefined)
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
dead = true
|
dead = true
|
||||||
})
|
})
|
||||||
@@ -103,7 +110,7 @@ const useDefaultServerKey = (
|
|||||||
if (result instanceof Promise) {
|
if (result instanceof Promise) {
|
||||||
void result.then((next) => {
|
void result.then((next) => {
|
||||||
if (dead) return
|
if (dead) return
|
||||||
setUrl(next ? normalizeServerUrl(next) : undefined)
|
setState("url", next ? normalizeServerUrl(next) : undefined)
|
||||||
})
|
})
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
dead = true
|
dead = true
|
||||||
@@ -111,7 +118,7 @@ const useDefaultServerKey = (
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setUrl(normalizeServerUrl(result))
|
setState("url", normalizeServerUrl(result))
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
dead = true
|
dead = true
|
||||||
})
|
})
|
||||||
@@ -119,56 +126,51 @@ const useDefaultServerKey = (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
key: () => {
|
key: () => {
|
||||||
const u = url()
|
const u = state.url
|
||||||
if (!u) return
|
if (!u) return
|
||||||
return ServerConnection.key({ type: "http", http: { url: u } })
|
return ServerConnection.key({ type: "http", http: { url: u } })
|
||||||
},
|
},
|
||||||
refresh: () => setTick((value) => value + 1),
|
refresh: () => setState("tick", (value) => value + 1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const useMcpToggle = (input: {
|
const useMcpToggleMutation = () => {
|
||||||
sync: ReturnType<typeof useSync>
|
const sync = useSync()
|
||||||
sdk: ReturnType<typeof useSDK>
|
const sdk = useSDK()
|
||||||
language: ReturnType<typeof useLanguage>
|
const language = useLanguage()
|
||||||
}) => {
|
|
||||||
const [loading, setLoading] = createSignal<string | null>(null)
|
|
||||||
|
|
||||||
const toggle = async (name: string) => {
|
return useMutation(() => ({
|
||||||
if (loading()) return
|
mutationFn: async (name: string) => {
|
||||||
setLoading(name)
|
const status = sync.data.mcp[name]
|
||||||
|
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
|
||||||
try {
|
const result = await sdk.client.mcp.status()
|
||||||
const status = input.sync.data.mcp[name]
|
if (result.data) sync.set("mcp", result.data)
|
||||||
await (status?.status === "connected"
|
},
|
||||||
? input.sdk.client.mcp.disconnect({ name })
|
onError: (err) => {
|
||||||
: input.sdk.client.mcp.connect({ name }))
|
|
||||||
const result = await input.sdk.client.mcp.status()
|
|
||||||
if (result.data) input.sync.set("mcp", result.data)
|
|
||||||
} catch (err) {
|
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: input.language.t("common.requestFailed"),
|
title: language.t("common.requestFailed"),
|
||||||
description: err instanceof Error ? err.message : String(err),
|
description: err instanceof Error ? err.message : String(err),
|
||||||
})
|
})
|
||||||
} finally {
|
},
|
||||||
setLoading(null)
|
}))
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { loading, toggle }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatusPopover() {
|
export function StatusPopover() {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const fetcher = platform.fetch ?? globalThis.fetch
|
const [shown, setShown] = createSignal(false)
|
||||||
|
let dialogRun = 0
|
||||||
|
let dialogDead = false
|
||||||
|
onCleanup(() => {
|
||||||
|
dialogDead = true
|
||||||
|
dialogRun += 1
|
||||||
|
})
|
||||||
const servers = createMemo(() => {
|
const servers = createMemo(() => {
|
||||||
const current = server.current
|
const current = server.current
|
||||||
const list = server.list
|
const list = server.list
|
||||||
@@ -176,10 +178,10 @@ export function StatusPopover() {
|
|||||||
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
||||||
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
||||||
})
|
})
|
||||||
const health = useServerHealth(servers, fetcher)
|
const health = useServerHealth(servers, shown)
|
||||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||||
const mcp = useMcpToggle({ sync, sdk, language })
|
const toggleMcp = useMcpToggleMutation()
|
||||||
const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl)
|
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||||
@@ -199,18 +201,23 @@ export function StatusPopover() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
|
open={shown()}
|
||||||
|
onOpenChange={setShown}
|
||||||
triggerAs={Button}
|
triggerAs={Button}
|
||||||
triggerProps={{
|
triggerProps={{
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
class: "titlebar-icon w-6 h-6 p-0 box-border",
|
class: "titlebar-icon w-8 h-6 p-0 box-border",
|
||||||
"aria-label": language.t("status.popover.trigger"),
|
"aria-label": language.t("status.popover.trigger"),
|
||||||
style: { scale: 1 },
|
style: { scale: 1 },
|
||||||
}}
|
}}
|
||||||
trigger={
|
trigger={
|
||||||
<div class="flex size-4 items-center justify-center">
|
<div class="relative size-4">
|
||||||
|
<div class="badge-mask-tight size-4 flex items-center justify-center">
|
||||||
|
<Icon name={shown() ? "status-active" : "status"} size="small" />
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"size-1.5 rounded-full": true,
|
"absolute -top-px -right-px size-1.5 rounded-full": true,
|
||||||
"bg-icon-success-base": overallHealthy(),
|
"bg-icon-success-base": overallHealthy(),
|
||||||
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
|
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
|
||||||
"bg-border-weak-base": server.healthy() === undefined,
|
"bg-border-weak-base": server.healthy() === undefined,
|
||||||
@@ -269,8 +276,8 @@ export function StatusPopover() {
|
|||||||
aria-disabled={isBlocked()}
|
aria-disabled={isBlocked()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isBlocked()) return
|
if (isBlocked()) return
|
||||||
server.setActive(key)
|
|
||||||
navigate("/")
|
navigate("/")
|
||||||
|
queueMicrotask(() => server.setActive(key))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ServerHealthIndicator health={health[key]} />
|
<ServerHealthIndicator health={health[key]} />
|
||||||
@@ -302,7 +309,13 @@ export function StatusPopover() {
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
class="mt-3 self-start h-8 px-3 py-1.5"
|
class="mt-3 self-start h-8 px-3 py-1.5"
|
||||||
onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)}
|
onClick={() => {
|
||||||
|
const run = ++dialogRun
|
||||||
|
void import("./dialog-select-server").then((x) => {
|
||||||
|
if (dialogDead || dialogRun !== run) return
|
||||||
|
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
|
||||||
|
})
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{language.t("status.popover.action.manageServers")}
|
{language.t("status.popover.action.manageServers")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -329,8 +342,11 @@ export function StatusPopover() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||||
onClick={() => mcp.toggle(name)}
|
onClick={() => {
|
||||||
disabled={mcp.loading() === name}
|
if (toggleMcp.isPending) return
|
||||||
|
toggleMcp.mutate(name)
|
||||||
|
}}
|
||||||
|
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
@@ -346,8 +362,11 @@ export function StatusPopover() {
|
|||||||
<div onClick={(event) => event.stopPropagation()}>
|
<div onClick={(event) => event.stopPropagation()}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={enabled()}
|
checked={enabled()}
|
||||||
disabled={mcp.loading() === name}
|
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||||
onChange={() => mcp.toggle(name)}
|
onChange={() => {
|
||||||
|
if (toggleMcp.isPending) return
|
||||||
|
toggleMcp.mutate(name)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme"
|
import { withAlpha } from "@opencode-ai/ui/theme/color"
|
||||||
|
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||||
|
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
|
||||||
|
import type { HexColor } from "@opencode-ai/ui/theme/types"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
|
||||||
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
|
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
|
||||||
@@ -10,6 +13,7 @@ import { useSDK } from "@/context/sdk"
|
|||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
import { monoFontFamily, useSettings } from "@/context/settings"
|
import { monoFontFamily, useSettings } from "@/context/settings"
|
||||||
import type { LocalPTY } from "@/context/terminal"
|
import type { LocalPTY } from "@/context/terminal"
|
||||||
|
import { terminalAttr, terminalProbe } from "@/testing/terminal"
|
||||||
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
|
||||||
import { terminalWriter } from "@/utils/terminal-writer"
|
import { terminalWriter } from "@/utils/terminal-writer"
|
||||||
|
|
||||||
@@ -17,6 +21,7 @@ const TOGGLE_TERMINAL_ID = "terminal.toggle"
|
|||||||
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
|
||||||
export interface TerminalProps extends ComponentProps<"div"> {
|
export interface TerminalProps extends ComponentProps<"div"> {
|
||||||
pty: LocalPTY
|
pty: LocalPTY
|
||||||
|
autoFocus?: boolean
|
||||||
onSubmit?: () => void
|
onSubmit?: () => void
|
||||||
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
|
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
|
||||||
onConnect?: () => void
|
onConnect?: () => void
|
||||||
@@ -63,6 +68,13 @@ const debugTerminal = (...values: unknown[]) => {
|
|||||||
console.debug("[terminal]", ...values)
|
console.debug("[terminal]", ...values)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const errorName = (err: unknown) => {
|
||||||
|
if (!err || typeof err !== "object") return
|
||||||
|
if (!("name" in err)) return
|
||||||
|
const errorName = err.name
|
||||||
|
return typeof errorName === "string" ? errorName : undefined
|
||||||
|
}
|
||||||
|
|
||||||
const useTerminalUiBindings = (input: {
|
const useTerminalUiBindings = (input: {
|
||||||
container: HTMLDivElement
|
container: HTMLDivElement
|
||||||
term: Term
|
term: Term
|
||||||
@@ -156,9 +168,16 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
|
const directory = sdk.directory
|
||||||
|
const client = sdk.client
|
||||||
|
const url = sdk.url
|
||||||
|
const auth = server.current?.http
|
||||||
|
const username = auth?.username ?? "opencode"
|
||||||
|
const password = auth?.password ?? ""
|
||||||
let container!: HTMLDivElement
|
let container!: HTMLDivElement
|
||||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
|
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
||||||
const id = local.pty.id
|
const id = local.pty.id
|
||||||
|
const probe = terminalProbe(id)
|
||||||
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
|
||||||
const restoreSize =
|
const restoreSize =
|
||||||
restore &&
|
restore &&
|
||||||
@@ -186,7 +205,11 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const start =
|
const start =
|
||||||
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
|
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
|
||||||
let cursor = start ?? 0
|
let cursor = start ?? 0
|
||||||
|
let seek = start !== undefined ? start : restore ? -1 : 0
|
||||||
let output: ReturnType<typeof terminalWriter> | undefined
|
let output: ReturnType<typeof terminalWriter> | undefined
|
||||||
|
let drop: VoidFunction | undefined
|
||||||
|
let reconn: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let tries = 0
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
if (!cleanups.length) return
|
if (!cleanups.length) return
|
||||||
@@ -201,7 +224,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pushSize = (cols: number, rows: number) => {
|
const pushSize = (cols: number, rows: number) => {
|
||||||
return sdk.client.pty
|
return client.pty
|
||||||
.update({
|
.update({
|
||||||
ptyID: id,
|
ptyID: id,
|
||||||
size: { cols, rows },
|
size: { cols, rows },
|
||||||
@@ -325,6 +348,9 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
probe.init()
|
||||||
|
cleanups.push(() => probe.drop())
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const loaded = await loadGhostty()
|
const loaded = await loadGhostty()
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
@@ -352,7 +378,13 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
}
|
}
|
||||||
ghostty = g
|
ghostty = g
|
||||||
term = t
|
term = t
|
||||||
output = terminalWriter((data, done) => t.write(data, done))
|
output = terminalWriter((data, done) =>
|
||||||
|
t.write(data, () => {
|
||||||
|
probe.render(data)
|
||||||
|
probe.settle()
|
||||||
|
done?.()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
t.attachCustomKeyEventHandler((event) => {
|
t.attachCustomKeyEventHandler((event) => {
|
||||||
const key = event.key.toLowerCase()
|
const key = event.key.toLowerCase()
|
||||||
@@ -386,7 +418,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
handleLinkClick,
|
handleLinkClick,
|
||||||
})
|
})
|
||||||
|
|
||||||
focusTerminal()
|
if (local.autoFocus !== false) focusTerminal()
|
||||||
|
|
||||||
if (typeof document !== "undefined" && document.fonts) {
|
if (typeof document !== "undefined" && document.fonts) {
|
||||||
document.fonts.ready.then(scheduleFit)
|
document.fonts.ready.then(scheduleFit)
|
||||||
@@ -440,89 +472,136 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
startResize()
|
startResize()
|
||||||
}
|
}
|
||||||
|
|
||||||
// t.onScroll((ydisp) => {
|
|
||||||
// console.log("Scroll position:", ydisp)
|
|
||||||
// })
|
|
||||||
|
|
||||||
const once = { value: false }
|
const once = { value: false }
|
||||||
let closing = false
|
|
||||||
|
|
||||||
const url = new URL(sdk.url + `/pty/${id}/connect`)
|
|
||||||
url.searchParams.set("directory", sdk.directory)
|
|
||||||
url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0))
|
|
||||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
|
|
||||||
url.username = server.current?.http.username ?? ""
|
|
||||||
url.password = server.current?.http.password ?? ""
|
|
||||||
|
|
||||||
const socket = new WebSocket(url)
|
|
||||||
socket.binaryType = "arraybuffer"
|
|
||||||
ws = socket
|
|
||||||
|
|
||||||
const handleOpen = () => {
|
|
||||||
local.onConnect?.()
|
|
||||||
scheduleSize(t.cols, t.rows)
|
|
||||||
}
|
|
||||||
socket.addEventListener("open", handleOpen)
|
|
||||||
if (socket.readyState === WebSocket.OPEN) handleOpen()
|
|
||||||
|
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
const handleMessage = (event: MessageEvent) => {
|
|
||||||
if (disposed) return
|
|
||||||
if (closing) return
|
|
||||||
if (event.data instanceof ArrayBuffer) {
|
|
||||||
const bytes = new Uint8Array(event.data)
|
|
||||||
if (bytes[0] !== 0) return
|
|
||||||
const json = decoder.decode(bytes.subarray(1))
|
|
||||||
try {
|
|
||||||
const meta = JSON.parse(json) as { cursor?: unknown }
|
|
||||||
const next = meta?.cursor
|
|
||||||
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
|
|
||||||
cursor = next
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
debugTerminal("invalid websocket control frame", err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = typeof event.data === "string" ? event.data : ""
|
const fail = (err: unknown) => {
|
||||||
if (!data) return
|
|
||||||
output?.push(data)
|
|
||||||
cursor += data.length
|
|
||||||
}
|
|
||||||
socket.addEventListener("message", handleMessage)
|
|
||||||
|
|
||||||
const handleError = (error: Event) => {
|
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
if (closing) return
|
|
||||||
if (once.value) return
|
if (once.value) return
|
||||||
once.value = true
|
once.value = true
|
||||||
console.error("WebSocket error:", error)
|
local.onConnectError?.(err)
|
||||||
local.onConnectError?.(error)
|
|
||||||
}
|
}
|
||||||
socket.addEventListener("error", handleError)
|
|
||||||
|
|
||||||
const handleClose = (event: CloseEvent) => {
|
const gone = () =>
|
||||||
|
client.pty
|
||||||
|
.get({ ptyID: id })
|
||||||
|
.then(() => false)
|
||||||
|
.catch((err) => {
|
||||||
|
if (errorName(err) === "NotFoundError") return true
|
||||||
|
debugTerminal("failed to inspect terminal session", err)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
const retry = (err: unknown) => {
|
||||||
if (disposed) return
|
if (disposed) return
|
||||||
if (closing) return
|
if (reconn !== undefined) return
|
||||||
// Normal closure (code 1000) means PTY process exited - server event handles cleanup
|
|
||||||
// For other codes (network issues, server restart), trigger error handler
|
|
||||||
if (event.code !== 1000) {
|
|
||||||
if (once.value) return
|
|
||||||
once.value = true
|
|
||||||
local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
socket.addEventListener("close", handleClose)
|
|
||||||
|
|
||||||
cleanups.push(() => {
|
const ms = Math.min(250 * 2 ** Math.min(tries, 4), 4_000)
|
||||||
closing = true
|
reconn = setTimeout(async () => {
|
||||||
socket.removeEventListener("open", handleOpen)
|
reconn = undefined
|
||||||
socket.removeEventListener("message", handleMessage)
|
if (disposed) return
|
||||||
socket.removeEventListener("error", handleError)
|
if (await gone()) {
|
||||||
socket.removeEventListener("close", handleClose)
|
if (disposed) return
|
||||||
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000)
|
fail(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (disposed) return
|
||||||
|
tries += 1
|
||||||
|
open()
|
||||||
|
}, ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
if (disposed) return
|
||||||
|
drop?.()
|
||||||
|
|
||||||
|
const next = new URL(url + `/pty/${id}/connect`)
|
||||||
|
next.searchParams.set("directory", directory)
|
||||||
|
next.searchParams.set("cursor", String(seek))
|
||||||
|
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
||||||
|
next.username = username
|
||||||
|
next.password = password
|
||||||
|
|
||||||
|
const socket = new WebSocket(next)
|
||||||
|
socket.binaryType = "arraybuffer"
|
||||||
|
ws = socket
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
if (disposed) return
|
||||||
|
tries = 0
|
||||||
|
probe.connect()
|
||||||
|
local.onConnect?.()
|
||||||
|
scheduleSize(t.cols, t.rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMessage = (event: MessageEvent) => {
|
||||||
|
if (disposed) return
|
||||||
|
if (event.data instanceof ArrayBuffer) {
|
||||||
|
const bytes = new Uint8Array(event.data)
|
||||||
|
if (bytes[0] !== 0) return
|
||||||
|
const json = decoder.decode(bytes.subarray(1))
|
||||||
|
try {
|
||||||
|
const meta = JSON.parse(json) as { cursor?: unknown }
|
||||||
|
const next = meta?.cursor
|
||||||
|
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
|
||||||
|
cursor = next
|
||||||
|
seek = next
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
debugTerminal("invalid websocket control frame", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = typeof event.data === "string" ? event.data : ""
|
||||||
|
if (!data) return
|
||||||
|
output?.push(data)
|
||||||
|
cursor += data.length
|
||||||
|
seek = cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleError = (error: Event) => {
|
||||||
|
if (disposed) return
|
||||||
|
debugTerminal("websocket error", error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
socket.removeEventListener("open", handleOpen)
|
||||||
|
socket.removeEventListener("message", handleMessage)
|
||||||
|
socket.removeEventListener("error", handleError)
|
||||||
|
socket.removeEventListener("close", handleClose)
|
||||||
|
if (ws === socket) ws = undefined
|
||||||
|
if (drop === stop) drop = undefined
|
||||||
|
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = (event: CloseEvent) => {
|
||||||
|
if (ws === socket) ws = undefined
|
||||||
|
if (drop === stop) drop = undefined
|
||||||
|
socket.removeEventListener("open", handleOpen)
|
||||||
|
socket.removeEventListener("message", handleMessage)
|
||||||
|
socket.removeEventListener("error", handleError)
|
||||||
|
socket.removeEventListener("close", handleClose)
|
||||||
|
if (disposed) return
|
||||||
|
if (event.code === 1000) return
|
||||||
|
retry(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code })))
|
||||||
|
}
|
||||||
|
|
||||||
|
drop = stop
|
||||||
|
socket.addEventListener("open", handleOpen)
|
||||||
|
socket.addEventListener("message", handleMessage)
|
||||||
|
socket.addEventListener("error", handleError)
|
||||||
|
socket.addEventListener("close", handleClose)
|
||||||
|
}
|
||||||
|
|
||||||
|
probe.control({
|
||||||
|
disconnect: () => {
|
||||||
|
if (!ws) return
|
||||||
|
ws.close(4_000, "e2e")
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
open()
|
||||||
}
|
}
|
||||||
|
|
||||||
void run().catch((err) => {
|
void run().catch((err) => {
|
||||||
@@ -540,6 +619,8 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
disposed = true
|
disposed = true
|
||||||
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
|
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
|
||||||
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
|
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
|
||||||
|
if (reconn !== undefined) clearTimeout(reconn)
|
||||||
|
drop?.()
|
||||||
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
|
if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000)
|
||||||
|
|
||||||
const finalize = () => {
|
const finalize = () => {
|
||||||
@@ -559,6 +640,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
<div
|
<div
|
||||||
ref={container}
|
ref={container}
|
||||||
data-component="terminal"
|
data-component="terminal"
|
||||||
|
{...{ [terminalAttr]: id }}
|
||||||
data-prevent-autofocus
|
data-prevent-autofocus
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
style={{ "background-color": terminalColors().background }}
|
style={{ "background-color": terminalColors().background }}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { createEffect, createMemo, Show, untrack } from "solid-js"
|
import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||||
import { useTheme } from "@opencode-ai/ui/theme"
|
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||||
|
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
@@ -58,6 +58,12 @@ export function Titlebar() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const path = () => `${location.pathname}${location.search}${location.hash}`
|
const path = () => `${location.pathname}${location.search}${location.hash}`
|
||||||
|
const creating = createMemo(() => {
|
||||||
|
if (!params.dir) return false
|
||||||
|
if (params.id) return false
|
||||||
|
const parts = location.pathname.replace(/\/+$/, "").split("/")
|
||||||
|
return parts.at(-1) === "session"
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const current = path()
|
const current = path()
|
||||||
@@ -71,6 +77,7 @@ export function Titlebar() {
|
|||||||
|
|
||||||
const canBack = createMemo(() => history.index > 0)
|
const canBack = createMemo(() => history.index > 0)
|
||||||
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
const canForward = createMemo(() => history.index < history.stack.length - 1)
|
||||||
|
const hasProjects = createMemo(() => layout.projects.list().length > 0)
|
||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
const next = backPath(history)
|
const next = backPath(history)
|
||||||
@@ -206,63 +213,77 @@ export function Titlebar() {
|
|||||||
aria-label={language.t("command.sidebar.toggle")}
|
aria-label={language.t("command.sidebar.toggle")}
|
||||||
aria-expanded={layout.sidebar.opened()}
|
aria-expanded={layout.sidebar.opened()}
|
||||||
>
|
>
|
||||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
|
||||||
<Icon
|
|
||||||
size="small"
|
|
||||||
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-left"}
|
|
||||||
class="group-hover/sidebar-toggle:hidden"
|
|
||||||
/>
|
|
||||||
<Icon size="small" name="layout-left-partial" class="hidden group-hover/sidebar-toggle:inline-block" />
|
|
||||||
<Icon
|
|
||||||
size="small"
|
|
||||||
name={layout.sidebar.opened() ? "layout-left" : "layout-left-partial"}
|
|
||||||
class="hidden group-active/sidebar-toggle:inline-block"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
<div class="hidden xl:flex items-center shrink-0">
|
<div class="hidden xl:flex items-center shrink-0">
|
||||||
<Show when={params.dir}>
|
<Show when={params.dir}>
|
||||||
<TooltipKeybind
|
<div
|
||||||
placement="bottom"
|
class="flex items-center shrink-0 w-8 mr-1"
|
||||||
title={language.t("command.session.new")}
|
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
|
||||||
keybind={command.keybind("session.new")}
|
|
||||||
openDelay={2000}
|
|
||||||
>
|
>
|
||||||
<Button
|
<div
|
||||||
variant="ghost"
|
class="transition-opacity"
|
||||||
icon="new-session"
|
classList={{
|
||||||
class="titlebar-icon w-8 h-6 p-0 box-border"
|
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
|
||||||
onClick={() => {
|
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
|
||||||
if (!params.dir) return
|
|
||||||
navigate(`/${params.dir}/session`)
|
|
||||||
}}
|
}}
|
||||||
aria-label={language.t("command.session.new")}
|
>
|
||||||
/>
|
<TooltipKeybind
|
||||||
</TooltipKeybind>
|
placement="bottom"
|
||||||
|
title={language.t("command.session.new")}
|
||||||
|
keybind={command.keybind("session.new")}
|
||||||
|
openDelay={2000}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
icon={creating() ? "new-session-active" : "new-session"}
|
||||||
|
class="titlebar-icon w-8 h-6 p-0 box-border"
|
||||||
|
disabled={layout.sidebar.opened()}
|
||||||
|
tabIndex={layout.sidebar.opened() ? -1 : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
if (!params.dir) return
|
||||||
|
navigate(`/${params.dir}/session`)
|
||||||
|
}}
|
||||||
|
aria-label={language.t("command.session.new")}
|
||||||
|
aria-current={creating() ? "page" : undefined}
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<Show when={hasProjects()}>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-0 transition-transform"
|
||||||
|
classList={{
|
||||||
|
"translate-x-0": !layout.sidebar.opened(),
|
||||||
|
"-translate-x-[36px]": layout.sidebar.opened(),
|
||||||
|
"duration-180 ease-out": !layout.sidebar.opened(),
|
||||||
|
"duration-180 ease-in": layout.sidebar.opened(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
icon="chevron-left"
|
||||||
|
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||||
|
disabled={!canBack()}
|
||||||
|
onClick={back}
|
||||||
|
aria-label={language.t("common.goBack")}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
icon="chevron-right"
|
||||||
|
class="titlebar-icon w-6 h-6 p-0 box-border"
|
||||||
|
disabled={!canForward()}
|
||||||
|
onClick={forward}
|
||||||
|
aria-label={language.t("common.goForward")}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
<div class="flex items-center gap-0" classList={{ "ml-1": !!params.dir }}>
|
|
||||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
icon="chevron-left"
|
|
||||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
|
||||||
disabled={!canBack()}
|
|
||||||
onClick={back}
|
|
||||||
aria-label={language.t("common.goBack")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
icon="chevron-right"
|
|
||||||
class="titlebar-icon w-6 h-6 p-0 box-border"
|
|
||||||
disabled={!canForward()}
|
|
||||||
onClick={forward}
|
|
||||||
aria-label={language.t("common.goForward")}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||||
@@ -282,7 +303,7 @@ export function Titlebar() {
|
|||||||
>
|
>
|
||||||
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
|
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
|
||||||
<Show when={windows()}>
|
<Show when={windows()}>
|
||||||
<div class="w-6 shrink-0" />
|
{!tauriApi() && <div class="w-36 shrink-0" />}
|
||||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||||
|
|
||||||
|
export const ACCEPTED_FILE_TYPES = [
|
||||||
|
...ACCEPTED_IMAGE_TYPES,
|
||||||
|
"application/pdf",
|
||||||
|
"text/*",
|
||||||
|
"application/json",
|
||||||
|
"application/ld+json",
|
||||||
|
"application/toml",
|
||||||
|
"application/x-toml",
|
||||||
|
"application/x-yaml",
|
||||||
|
"application/xml",
|
||||||
|
"application/yaml",
|
||||||
|
".c",
|
||||||
|
".cc",
|
||||||
|
".cjs",
|
||||||
|
".conf",
|
||||||
|
".cpp",
|
||||||
|
".css",
|
||||||
|
".csv",
|
||||||
|
".cts",
|
||||||
|
".env",
|
||||||
|
".go",
|
||||||
|
".gql",
|
||||||
|
".graphql",
|
||||||
|
".h",
|
||||||
|
".hh",
|
||||||
|
".hpp",
|
||||||
|
".htm",
|
||||||
|
".html",
|
||||||
|
".ini",
|
||||||
|
".java",
|
||||||
|
".js",
|
||||||
|
".json",
|
||||||
|
".jsx",
|
||||||
|
".log",
|
||||||
|
".md",
|
||||||
|
".mdx",
|
||||||
|
".mjs",
|
||||||
|
".mts",
|
||||||
|
".py",
|
||||||
|
".rb",
|
||||||
|
".rs",
|
||||||
|
".sass",
|
||||||
|
".scss",
|
||||||
|
".sh",
|
||||||
|
".sql",
|
||||||
|
".toml",
|
||||||
|
".ts",
|
||||||
|
".tsx",
|
||||||
|
".txt",
|
||||||
|
".xml",
|
||||||
|
".yaml",
|
||||||
|
".yml",
|
||||||
|
".zsh",
|
||||||
|
]
|
||||||
|
|
||||||
|
const MIME_EXT = new Map([
|
||||||
|
["image/png", "png"],
|
||||||
|
["image/jpeg", "jpg"],
|
||||||
|
["image/gif", "gif"],
|
||||||
|
["image/webp", "webp"],
|
||||||
|
["application/pdf", "pdf"],
|
||||||
|
["application/json", "json"],
|
||||||
|
["application/ld+json", "jsonld"],
|
||||||
|
["application/toml", "toml"],
|
||||||
|
["application/x-toml", "toml"],
|
||||||
|
["application/x-yaml", "yaml"],
|
||||||
|
["application/xml", "xml"],
|
||||||
|
["application/yaml", "yaml"],
|
||||||
|
])
|
||||||
|
|
||||||
|
const TEXT_EXT = ["txt", "text", "md", "markdown", "log", "csv"]
|
||||||
|
|
||||||
|
export const ACCEPTED_FILE_EXTENSIONS = Array.from(
|
||||||
|
new Set(
|
||||||
|
ACCEPTED_FILE_TYPES.flatMap((item) => {
|
||||||
|
if (item.startsWith(".")) return [item.slice(1)]
|
||||||
|
if (item === "text/*") return TEXT_EXT
|
||||||
|
const out = MIME_EXT.get(item)
|
||||||
|
return out ? [out] : []
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).sort()
|
||||||
|
|
||||||
|
export function filePickerFilters(ext?: string[]) {
|
||||||
|
if (!ext || ext.length === 0) return undefined
|
||||||
|
return [{ name: "Files", extensions: ext }]
|
||||||
|
}
|
||||||
@@ -32,6 +32,25 @@ describe("command keybind helpers", () => {
|
|||||||
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
|
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("matchKeybind supports bracket keys", () => {
|
||||||
|
const keybinds = parseKeybind("mod+alt+[, mod+alt+]")
|
||||||
|
const prev = keybinds[0]
|
||||||
|
const next = keybinds[1]
|
||||||
|
|
||||||
|
expect(
|
||||||
|
matchKeybind(
|
||||||
|
keybinds,
|
||||||
|
new KeyboardEvent("keydown", { key: "[", ctrlKey: prev?.ctrl, metaKey: prev?.meta, altKey: true }),
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
matchKeybind(
|
||||||
|
keybinds,
|
||||||
|
new KeyboardEvent("keydown", { key: "]", ctrlKey: next?.ctrl, metaKey: next?.meta, altKey: true }),
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
test("formatKeybind returns human readable output", () => {
|
test("formatKeybind returns human readable output", () => {
|
||||||
const display = formatKeybind("ctrl+alt+arrowup")
|
const display = formatKeybind("ctrl+alt+arrowup")
|
||||||
|
|
||||||
@@ -40,4 +59,11 @@ describe("command keybind helpers", () => {
|
|||||||
expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
|
expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
|
||||||
expect(formatKeybind("none")).toBe("")
|
expect(formatKeybind("none")).toBe("")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("formatKeybind prefers the first combo", () => {
|
||||||
|
const display = formatKeybind("mod+k,mod+p")
|
||||||
|
|
||||||
|
expect(display.includes("K") || display.includes("k")).toBe(true)
|
||||||
|
expect(display.includes("P") || display.includes("p")).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { dict as en } from "@/i18n/en"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
|
||||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||||
@@ -13,6 +14,27 @@ const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
|
|||||||
const SUGGESTED_PREFIX = "suggested."
|
const SUGGESTED_PREFIX = "suggested."
|
||||||
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
|
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"])
|
||||||
|
|
||||||
|
type KeyLabel =
|
||||||
|
| "common.key.ctrl"
|
||||||
|
| "common.key.alt"
|
||||||
|
| "common.key.shift"
|
||||||
|
| "common.key.meta"
|
||||||
|
| "common.key.space"
|
||||||
|
| "common.key.backspace"
|
||||||
|
| "common.key.enter"
|
||||||
|
| "common.key.tab"
|
||||||
|
| "common.key.delete"
|
||||||
|
| "common.key.home"
|
||||||
|
| "common.key.end"
|
||||||
|
| "common.key.pageUp"
|
||||||
|
| "common.key.pageDown"
|
||||||
|
| "common.key.insert"
|
||||||
|
| "common.key.esc"
|
||||||
|
|
||||||
|
function keyText(key: KeyLabel, t?: (key: KeyLabel) => string) {
|
||||||
|
return t ? t(key) : en[key]
|
||||||
|
}
|
||||||
|
|
||||||
function actionId(id: string) {
|
function actionId(id: string) {
|
||||||
if (!id.startsWith(SUGGESTED_PREFIX)) return id
|
if (!id.startsWith(SUGGESTED_PREFIX)) return id
|
||||||
return id.slice(SUGGESTED_PREFIX.length)
|
return id.slice(SUGGESTED_PREFIX.length)
|
||||||
@@ -145,7 +167,7 @@ export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatKeybind(config: string): string {
|
export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string {
|
||||||
if (!config || config === "none") return ""
|
if (!config || config === "none") return ""
|
||||||
|
|
||||||
const keybinds = parseKeybind(config)
|
const keybinds = parseKeybind(config)
|
||||||
@@ -154,10 +176,10 @@ export function formatKeybind(config: string): string {
|
|||||||
const kb = keybinds[0]
|
const kb = keybinds[0]
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
|
||||||
if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
|
if (kb.ctrl) parts.push(IS_MAC ? "⌃" : keyText("common.key.ctrl", t))
|
||||||
if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
|
if (kb.alt) parts.push(IS_MAC ? "⌥" : keyText("common.key.alt", t))
|
||||||
if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
|
if (kb.shift) parts.push(IS_MAC ? "⇧" : keyText("common.key.shift", t))
|
||||||
if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
|
if (kb.meta) parts.push(IS_MAC ? "⌘" : keyText("common.key.meta", t))
|
||||||
|
|
||||||
if (kb.key) {
|
if (kb.key) {
|
||||||
const keys: Record<string, string> = {
|
const keys: Record<string, string> = {
|
||||||
@@ -167,10 +189,29 @@ export function formatKeybind(config: string): string {
|
|||||||
arrowright: "→",
|
arrowright: "→",
|
||||||
comma: ",",
|
comma: ",",
|
||||||
plus: "+",
|
plus: "+",
|
||||||
space: "Space",
|
}
|
||||||
|
const named: Record<string, KeyLabel> = {
|
||||||
|
backspace: "common.key.backspace",
|
||||||
|
delete: "common.key.delete",
|
||||||
|
end: "common.key.end",
|
||||||
|
enter: "common.key.enter",
|
||||||
|
esc: "common.key.esc",
|
||||||
|
escape: "common.key.esc",
|
||||||
|
home: "common.key.home",
|
||||||
|
insert: "common.key.insert",
|
||||||
|
pagedown: "common.key.pageDown",
|
||||||
|
pageup: "common.key.pageUp",
|
||||||
|
space: "common.key.space",
|
||||||
|
tab: "common.key.tab",
|
||||||
}
|
}
|
||||||
const key = kb.key.toLowerCase()
|
const key = kb.key.toLowerCase()
|
||||||
const displayKey = keys[key] ?? (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1))
|
const displayKey =
|
||||||
|
keys[key] ??
|
||||||
|
(named[key]
|
||||||
|
? keyText(named[key], t)
|
||||||
|
: key.length === 1
|
||||||
|
? key.toUpperCase()
|
||||||
|
: key.charAt(0).toUpperCase() + key.slice(1))
|
||||||
parts.push(displayKey)
|
parts.push(displayKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,9 +238,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
})
|
})
|
||||||
const warnedDuplicates = new Set<string>()
|
const warnedDuplicates = new Set<string>()
|
||||||
|
|
||||||
|
type CommandCatalog = Record<string, CommandCatalogItem>
|
||||||
const [catalog, setCatalog, _, catalogReady] = persisted(
|
const [catalog, setCatalog, _, catalogReady] = persisted(
|
||||||
Persist.global("command.catalog.v1"),
|
Persist.global("command.catalog.v1"),
|
||||||
createStore<Record<string, CommandCatalogItem>>({}),
|
createStore<CommandCatalog>({}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const bind = (id: string, def: KeybindConfig | undefined) => {
|
const bind = (id: string, def: KeybindConfig | undefined) => {
|
||||||
@@ -218,7 +260,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
if (seen.has(opt.id)) {
|
if (seen.has(opt.id)) {
|
||||||
if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
|
if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
|
||||||
warnedDuplicates.add(opt.id)
|
warnedDuplicates.add(opt.id)
|
||||||
console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`)
|
console.warn(`[command] duplicate command id "${opt.id}" registered; keeping first entry`)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -233,16 +275,19 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!catalogReady()) return
|
if (!catalogReady()) return
|
||||||
|
|
||||||
for (const opt of registered()) {
|
setCatalog(
|
||||||
const id = actionId(opt.id)
|
registered().reduce((acc, opt) => {
|
||||||
setCatalog(id, {
|
const id = actionId(opt.id)
|
||||||
title: opt.title,
|
acc[id] = {
|
||||||
description: opt.description,
|
title: opt.title,
|
||||||
category: opt.category,
|
description: opt.description,
|
||||||
keybind: opt.keybind,
|
category: opt.category,
|
||||||
slash: opt.slash,
|
keybind: opt.keybind,
|
||||||
})
|
slash: opt.slash,
|
||||||
}
|
}
|
||||||
|
return acc
|
||||||
|
}, {} as CommandCatalog),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))
|
const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))
|
||||||
@@ -364,17 +409,17 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
},
|
},
|
||||||
keybind(id: string) {
|
keybind(id: string) {
|
||||||
if (id === PALETTE_ID) {
|
if (id === PALETTE_ID) {
|
||||||
return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
|
return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND, language.t)
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = actionId(id)
|
const base = actionId(id)
|
||||||
const option = options().find((x) => actionId(x.id) === base)
|
const option = options().find((x) => actionId(x.id) === base)
|
||||||
if (option?.keybind) return formatKeybind(option.keybind)
|
if (option?.keybind) return formatKeybind(option.keybind, language.t)
|
||||||
|
|
||||||
const meta = catalog[base]
|
const meta = catalog[base]
|
||||||
const config = bind(base, meta?.keybind)
|
const config = bind(base, meta?.keybind)
|
||||||
if (!config) return ""
|
if (!config) return ""
|
||||||
return formatKeybind(config)
|
return formatKeybind(config, language.t)
|
||||||
},
|
},
|
||||||
show: showPalette,
|
show: showPalette,
|
||||||
keybinds(enabled: boolean) {
|
keybinds(enabled: boolean) {
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ export {
|
|||||||
touchFileContent,
|
touchFileContent,
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorMessage(error: unknown) {
|
function errorMessage(error: unknown, fallback: string) {
|
||||||
if (error instanceof Error && error.message) return error.message
|
if (error instanceof Error && error.message) return error.message
|
||||||
if (typeof error === "string" && error) return error
|
if (typeof error === "string" && error) return error
|
||||||
return "Unknown error"
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||||
@@ -184,7 +184,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
|||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
if (scope() !== directory) return
|
if (scope() !== directory) return
|
||||||
setLoadError(file, errorMessage(e))
|
setLoadError(file, errorMessage(e, language.t("error.chain.unknown")))
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
inflight.delete(key)
|
inflight.delete(key)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
|||||||
import { batch, onCleanup } from "solid-js"
|
import { batch, onCleanup } from "solid-js"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { createSdkForServer } from "@/utils/server"
|
import { createSdkForServer } from "@/utils/server"
|
||||||
|
import { useLanguage } from "./language"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { useServer } from "./server"
|
import { useServer } from "./server"
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ const abortError = z.object({
|
|||||||
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
|
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
|
||||||
name: "GlobalSDK",
|
name: "GlobalSDK",
|
||||||
init: () => {
|
init: () => {
|
||||||
|
const language = useLanguage()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
@@ -30,7 +32,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
const currentServer = server.current
|
const currentServer = server.current
|
||||||
if (!currentServer) throw new Error("No server available")
|
if (!currentServer) throw new Error(language.t("error.globalSDK.noServerAvailable"))
|
||||||
|
|
||||||
const eventSdk = createSdkForServer({
|
const eventSdk = createSdkForServer({
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
@@ -218,7 +220,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
event: emitter,
|
event: emitter,
|
||||||
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
|
||||||
const s = server.current
|
const s = server.current
|
||||||
if (!s) throw new Error("Server not available")
|
if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))
|
||||||
return createSdkForServer({
|
return createSdkForServer({
|
||||||
server: s.http,
|
server: s.http,
|
||||||
fetch: platform.fetch,
|
fetch: platform.fetch,
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import {
|
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||||
canDisposeDirectory,
|
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||||
estimateRootSessionTotal,
|
|
||||||
loadRootSessionsWithFallback,
|
|
||||||
pickDirectoriesToEvict,
|
|
||||||
} from "./global-sync"
|
|
||||||
|
|
||||||
describe("pickDirectoriesToEvict", () => {
|
describe("pickDirectoriesToEvict", () => {
|
||||||
test("keeps pinned stores and evicts idle stores", () => {
|
test("keeps pinned stores and evicts idle stores", () => {
|
||||||
|
|||||||
@@ -9,17 +9,7 @@ import type {
|
|||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import {
|
import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
|
||||||
createContext,
|
|
||||||
getOwner,
|
|
||||||
Match,
|
|
||||||
onCleanup,
|
|
||||||
onMount,
|
|
||||||
type ParentProps,
|
|
||||||
Switch,
|
|
||||||
untrack,
|
|
||||||
useContext,
|
|
||||||
} from "solid-js"
|
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
@@ -29,6 +19,7 @@ import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
|||||||
import { createChildStoreManager } from "./global-sync/child-store"
|
import { createChildStoreManager } from "./global-sync/child-store"
|
||||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
|
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
|
||||||
import { createRefreshQueue } from "./global-sync/queue"
|
import { createRefreshQueue } from "./global-sync/queue"
|
||||||
|
import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch"
|
||||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||||
import { trimSessions } from "./global-sync/session-trim"
|
import { trimSessions } from "./global-sync/session-trim"
|
||||||
import type { ProjectMeta } from "./global-sync/types"
|
import type { ProjectMeta } from "./global-sync/types"
|
||||||
@@ -79,6 +70,8 @@ function createGlobalSync() {
|
|||||||
|
|
||||||
let active = true
|
let active = true
|
||||||
let projectWritten = false
|
let projectWritten = false
|
||||||
|
let bootedAt = 0
|
||||||
|
let bootingRoot = false
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
active = false
|
active = false
|
||||||
@@ -161,7 +154,9 @@ function createGlobalSync() {
|
|||||||
queue.clear(directory)
|
queue.clear(directory)
|
||||||
sessionMeta.delete(directory)
|
sessionMeta.delete(directory)
|
||||||
sdkCache.delete(directory)
|
sdkCache.delete(directory)
|
||||||
|
clearSessionPrefetchDirectory(directory)
|
||||||
},
|
},
|
||||||
|
translate: language.t,
|
||||||
})
|
})
|
||||||
|
|
||||||
const sdkFor = (directory: string) => {
|
const sdkFor = (directory: string) => {
|
||||||
@@ -255,6 +250,11 @@ function createGlobalSync() {
|
|||||||
const sdk = sdkFor(directory)
|
const sdk = sdkFor(directory)
|
||||||
await bootstrapDirectory({
|
await bootstrapDirectory({
|
||||||
directory,
|
directory,
|
||||||
|
global: {
|
||||||
|
config: globalStore.config,
|
||||||
|
project: globalStore.project,
|
||||||
|
provider: globalStore.provider,
|
||||||
|
},
|
||||||
sdk,
|
sdk,
|
||||||
store: child[0],
|
store: child[0],
|
||||||
setStore: child[1],
|
setStore: child[1],
|
||||||
@@ -275,15 +275,20 @@ function createGlobalSync() {
|
|||||||
const unsub = globalSDK.event.listen((e) => {
|
const unsub = globalSDK.event.listen((e) => {
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const event = e.details
|
const event = e.details
|
||||||
|
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||||
|
|
||||||
if (directory === "global") {
|
if (directory === "global") {
|
||||||
applyGlobalEvent({
|
applyGlobalEvent({
|
||||||
event,
|
event,
|
||||||
project: globalStore.project,
|
project: globalStore.project,
|
||||||
refresh: queue.refresh,
|
refresh: () => {
|
||||||
|
if (recent) return
|
||||||
|
queue.refresh()
|
||||||
|
},
|
||||||
setGlobalProject: setProjects,
|
setGlobalProject: setProjects,
|
||||||
})
|
})
|
||||||
if (event.type === "server.connected" || event.type === "global.disposed") {
|
if (event.type === "server.connected" || event.type === "global.disposed") {
|
||||||
|
if (recent) return
|
||||||
for (const directory of Object.keys(children.children)) {
|
for (const directory of Object.keys(children.children)) {
|
||||||
queue.push(directory)
|
queue.push(directory)
|
||||||
}
|
}
|
||||||
@@ -322,17 +327,19 @@ function createGlobalSync() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
await bootstrapGlobal({
|
bootingRoot = true
|
||||||
globalSDK: globalSDK.client,
|
try {
|
||||||
connectErrorTitle: language.t("dialog.server.add.error"),
|
await bootstrapGlobal({
|
||||||
connectErrorDescription: language.t("error.globalSync.connectFailed", {
|
globalSDK: globalSDK.client,
|
||||||
url: globalSDK.url,
|
requestFailedTitle: language.t("common.requestFailed"),
|
||||||
}),
|
translate: language.t,
|
||||||
requestFailedTitle: language.t("common.requestFailed"),
|
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
||||||
translate: language.t,
|
setGlobalStore: setBootStore,
|
||||||
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
|
})
|
||||||
setGlobalStore: setBootStore,
|
bootedAt = Date.now()
|
||||||
})
|
} finally {
|
||||||
|
bootingRoot = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -375,6 +382,7 @@ function createGlobalSync() {
|
|||||||
return globalStore.error
|
return globalStore.error
|
||||||
},
|
},
|
||||||
child: children.child,
|
child: children.child,
|
||||||
|
peek: children.peek,
|
||||||
bootstrap,
|
bootstrap,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
project: projectApi,
|
project: projectApi,
|
||||||
@@ -388,13 +396,7 @@ const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
|
|||||||
|
|
||||||
export function GlobalSyncProvider(props: ParentProps) {
|
export function GlobalSyncProvider(props: ParentProps) {
|
||||||
const value = createGlobalSync()
|
const value = createGlobalSync()
|
||||||
return (
|
return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
|
||||||
<Switch>
|
|
||||||
<Match when={value.ready}>
|
|
||||||
<GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGlobalSync() {
|
export function useGlobalSync() {
|
||||||
@@ -402,6 +404,3 @@ export function useGlobalSync() {
|
|||||||
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
|
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
|
||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|
||||||
export { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
|
||||||
export { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { retry } from "@opencode-ai/util/retry"
|
|||||||
import { batch } from "solid-js"
|
import { batch } from "solid-js"
|
||||||
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import { cmp, normalizeProviderList } from "./utils"
|
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
@@ -31,73 +31,102 @@ type GlobalStore = {
|
|||||||
reload: undefined | "pending" | "complete"
|
reload: undefined | "pending" | "complete"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function waitForPaint() {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
let done = false
|
||||||
|
const finish = () => {
|
||||||
|
if (done) return
|
||||||
|
done = true
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
const timer = setTimeout(finish, 50)
|
||||||
|
if (typeof requestAnimationFrame !== "function") return
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
clearTimeout(timer)
|
||||||
|
finish()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function errors(list: PromiseSettledResult<unknown>[]) {
|
||||||
|
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAll(list: Array<() => Promise<unknown>>) {
|
||||||
|
return Promise.allSettled(list.map((item) => item()))
|
||||||
|
}
|
||||||
|
|
||||||
|
function showErrors(input: {
|
||||||
|
errors: unknown[]
|
||||||
|
title: string
|
||||||
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
|
formatMoreCount: (count: number) => string
|
||||||
|
}) {
|
||||||
|
if (input.errors.length === 0) return
|
||||||
|
const message = formatServerError(input.errors[0], input.translate)
|
||||||
|
const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: input.title,
|
||||||
|
description: message + more,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function bootstrapGlobal(input: {
|
export async function bootstrapGlobal(input: {
|
||||||
globalSDK: OpencodeClient
|
globalSDK: OpencodeClient
|
||||||
connectErrorTitle: string
|
|
||||||
connectErrorDescription: string
|
|
||||||
requestFailedTitle: string
|
requestFailedTitle: string
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
formatMoreCount: (count: number) => string
|
formatMoreCount: (count: number) => string
|
||||||
setGlobalStore: SetStoreFunction<GlobalStore>
|
setGlobalStore: SetStoreFunction<GlobalStore>
|
||||||
}) {
|
}) {
|
||||||
const health = await input.globalSDK.global
|
const fast = [
|
||||||
.health()
|
() =>
|
||||||
.then((x) => x.data)
|
retry(() =>
|
||||||
.catch(() => undefined)
|
input.globalSDK.path.get().then((x) => {
|
||||||
if (!health?.healthy) {
|
input.setGlobalStore("path", x.data!)
|
||||||
showToast({
|
}),
|
||||||
variant: "error",
|
),
|
||||||
title: input.connectErrorTitle,
|
() =>
|
||||||
description: input.connectErrorDescription,
|
retry(() =>
|
||||||
})
|
input.globalSDK.global.config.get().then((x) => {
|
||||||
input.setGlobalStore("ready", true)
|
input.setGlobalStore("config", x.data!)
|
||||||
return
|
}),
|
||||||
}
|
),
|
||||||
|
() =>
|
||||||
const tasks = [
|
retry(() =>
|
||||||
retry(() =>
|
input.globalSDK.provider.list().then((x) => {
|
||||||
input.globalSDK.path.get().then((x) => {
|
input.setGlobalStore("provider", normalizeProviderList(x.data!))
|
||||||
input.setGlobalStore("path", x.data!)
|
}),
|
||||||
}),
|
),
|
||||||
),
|
|
||||||
retry(() =>
|
|
||||||
input.globalSDK.global.config.get().then((x) => {
|
|
||||||
input.setGlobalStore("config", x.data!)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
retry(() =>
|
|
||||||
input.globalSDK.project.list().then((x) => {
|
|
||||||
const projects = (x.data ?? [])
|
|
||||||
.filter((p) => !!p?.id)
|
|
||||||
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => cmp(a.id, b.id))
|
|
||||||
input.setGlobalStore("project", projects)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
retry(() =>
|
|
||||||
input.globalSDK.provider.list().then((x) => {
|
|
||||||
input.setGlobalStore("provider", normalizeProviderList(x.data!))
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
retry(() =>
|
|
||||||
input.globalSDK.provider.auth().then((x) => {
|
|
||||||
input.setGlobalStore("provider_auth", x.data ?? {})
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const results = await Promise.allSettled(tasks)
|
const slow = [
|
||||||
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
|
() =>
|
||||||
if (errors.length) {
|
retry(() =>
|
||||||
const message = formatServerError(errors[0], input.translate)
|
input.globalSDK.project.list().then((x) => {
|
||||||
const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : ""
|
const projects = (x.data ?? [])
|
||||||
showToast({
|
.filter((p) => !!p?.id)
|
||||||
variant: "error",
|
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
|
||||||
title: input.requestFailedTitle,
|
.slice()
|
||||||
description: message + more,
|
.sort((a, b) => cmp(a.id, b.id))
|
||||||
})
|
input.setGlobalStore("project", projects)
|
||||||
}
|
}),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
showErrors({
|
||||||
|
errors: errors(await runAll(fast)),
|
||||||
|
title: input.requestFailedTitle,
|
||||||
|
translate: input.translate,
|
||||||
|
formatMoreCount: input.formatMoreCount,
|
||||||
|
})
|
||||||
|
await waitForPaint()
|
||||||
|
showErrors({
|
||||||
|
errors: errors(await runAll(slow)),
|
||||||
|
title: input.requestFailedTitle,
|
||||||
|
translate: input.translate,
|
||||||
|
formatMoreCount: input.formatMoreCount,
|
||||||
|
})
|
||||||
input.setGlobalStore("ready", true)
|
input.setGlobalStore("ready", true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +140,10 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
|
|||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function projectID(directory: string, projects: Project[]) {
|
||||||
|
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
|
||||||
|
}
|
||||||
|
|
||||||
export async function bootstrapDirectory(input: {
|
export async function bootstrapDirectory(input: {
|
||||||
directory: string
|
directory: string
|
||||||
sdk: OpencodeClient
|
sdk: OpencodeClient
|
||||||
@@ -119,88 +152,130 @@ export async function bootstrapDirectory(input: {
|
|||||||
vcsCache: VcsCache
|
vcsCache: VcsCache
|
||||||
loadSessions: (directory: string) => Promise<void> | void
|
loadSessions: (directory: string) => Promise<void> | void
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
}) {
|
global: {
|
||||||
if (input.store.status !== "complete") input.setStore("status", "loading")
|
config: Config
|
||||||
|
project: Project[]
|
||||||
const blockingRequests = {
|
provider: ProviderListResponse
|
||||||
project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
|
|
||||||
provider: () =>
|
|
||||||
input.sdk.provider.list().then((x) => {
|
|
||||||
input.setStore("provider", normalizeProviderList(x.data!))
|
|
||||||
}),
|
|
||||||
agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
|
|
||||||
config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
|
|
||||||
}
|
}
|
||||||
|
}) {
|
||||||
|
const loading = input.store.status !== "complete"
|
||||||
|
const seededProject = projectID(input.directory, input.global.project)
|
||||||
|
if (seededProject) input.setStore("project", seededProject)
|
||||||
|
if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
|
||||||
|
input.setStore("provider", input.global.provider)
|
||||||
|
}
|
||||||
|
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
||||||
|
input.setStore("config", input.global.config)
|
||||||
|
}
|
||||||
|
if (loading) input.setStore("status", "partial")
|
||||||
|
|
||||||
try {
|
const fast = [
|
||||||
await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
|
() =>
|
||||||
} catch (err) {
|
seededProject
|
||||||
console.error("Failed to bootstrap instance", err)
|
? Promise.resolve()
|
||||||
|
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
|
||||||
|
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
|
||||||
|
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
||||||
|
() =>
|
||||||
|
retry(() =>
|
||||||
|
input.sdk.path.get().then((x) => {
|
||||||
|
input.setStore("path", x.data!)
|
||||||
|
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
|
||||||
|
if (next) input.setStore("project", next)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
||||||
|
() =>
|
||||||
|
retry(() =>
|
||||||
|
input.sdk.vcs.get().then((x) => {
|
||||||
|
const next = x.data ?? input.store.vcs
|
||||||
|
input.setStore("vcs", next)
|
||||||
|
if (next) input.vcsCache.setStore("value", next)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
||||||
|
() =>
|
||||||
|
retry(() =>
|
||||||
|
input.sdk.permission.list().then((x) => {
|
||||||
|
const grouped = groupBySession(
|
||||||
|
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||||
|
)
|
||||||
|
batch(() => {
|
||||||
|
for (const sessionID of Object.keys(input.store.permission)) {
|
||||||
|
if (grouped[sessionID]) continue
|
||||||
|
input.setStore("permission", sessionID, [])
|
||||||
|
}
|
||||||
|
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||||
|
input.setStore(
|
||||||
|
"permission",
|
||||||
|
sessionID,
|
||||||
|
reconcile(
|
||||||
|
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||||
|
{ key: "id" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() =>
|
||||||
|
retry(() =>
|
||||||
|
input.sdk.question.list().then((x) => {
|
||||||
|
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||||
|
batch(() => {
|
||||||
|
for (const sessionID of Object.keys(input.store.question)) {
|
||||||
|
if (grouped[sessionID]) continue
|
||||||
|
input.setStore("question", sessionID, [])
|
||||||
|
}
|
||||||
|
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||||
|
input.setStore(
|
||||||
|
"question",
|
||||||
|
sessionID,
|
||||||
|
reconcile(
|
||||||
|
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||||
|
{ key: "id" },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
const slow = [
|
||||||
|
() =>
|
||||||
|
retry(() =>
|
||||||
|
input.sdk.provider.list().then((x) => {
|
||||||
|
input.setStore("provider", normalizeProviderList(x.data!))
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
|
() => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))),
|
||||||
|
() => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))),
|
||||||
|
]
|
||||||
|
|
||||||
|
const errs = errors(await runAll(fast))
|
||||||
|
if (errs.length > 0) {
|
||||||
|
console.error("Failed to bootstrap instance", errs[0])
|
||||||
const project = getFilename(input.directory)
|
const project = getFilename(input.directory)
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: `Failed to reload ${project}`,
|
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||||
description: formatServerError(err, input.translate),
|
description: formatServerError(errs[0], input.translate),
|
||||||
})
|
})
|
||||||
input.setStore("status", "partial")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.store.status !== "complete") input.setStore("status", "partial")
|
await waitForPaint()
|
||||||
|
const slowErrs = errors(await runAll(slow))
|
||||||
|
if (slowErrs.length > 0) {
|
||||||
|
console.error("Failed to finish bootstrap instance", slowErrs[0])
|
||||||
|
const project = getFilename(input.directory)
|
||||||
|
showToast({
|
||||||
|
variant: "error",
|
||||||
|
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||||
|
description: formatServerError(slowErrs[0], input.translate),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
Promise.all([
|
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
|
||||||
input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
|
|
||||||
input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
|
|
||||||
input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
|
|
||||||
input.loadSessions(input.directory),
|
|
||||||
input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
|
|
||||||
input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
|
|
||||||
input.sdk.vcs.get().then((x) => {
|
|
||||||
const next = x.data ?? input.store.vcs
|
|
||||||
input.setStore("vcs", next)
|
|
||||||
if (next?.branch) input.vcsCache.setStore("value", next)
|
|
||||||
}),
|
|
||||||
input.sdk.permission.list().then((x) => {
|
|
||||||
const grouped = groupBySession(
|
|
||||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
|
||||||
)
|
|
||||||
batch(() => {
|
|
||||||
for (const sessionID of Object.keys(input.store.permission)) {
|
|
||||||
if (grouped[sessionID]) continue
|
|
||||||
input.setStore("permission", sessionID, [])
|
|
||||||
}
|
|
||||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
|
||||||
input.setStore(
|
|
||||||
"permission",
|
|
||||||
sessionID,
|
|
||||||
reconcile(
|
|
||||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
|
||||||
{ key: "id" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
input.sdk.question.list().then((x) => {
|
|
||||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
|
||||||
batch(() => {
|
|
||||||
for (const sessionID of Object.keys(input.store.question)) {
|
|
||||||
if (grouped[sessionID]) continue
|
|
||||||
input.setStore("question", sessionID, [])
|
|
||||||
}
|
|
||||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
|
||||||
input.setStore(
|
|
||||||
"question",
|
|
||||||
sessionID,
|
|
||||||
reconcile(
|
|
||||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
|
||||||
{ key: "id" },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
]).then(() => {
|
|
||||||
input.setStore("status", "complete")
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user