Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a53f8e084 | ||
|
|
6f5b2f786e | ||
|
|
7910ce5d36 | ||
|
|
6ad171dba9 | ||
|
|
556703f8ab | ||
|
|
cb5674edc7 | ||
|
|
b99de4118e | ||
|
|
040700dbc4 | ||
|
|
4d5da9697e | ||
|
|
a28648f530 | ||
|
|
4d81e2d4d9 | ||
|
|
21e72cbf42 | ||
|
|
5f277d1e62 | ||
|
|
d67e877e28 | ||
|
|
d4e51e04b3 | ||
|
|
070c1679e4 | ||
|
|
406d216cd2 | ||
|
|
5dc8b4ef29 | ||
|
|
2f41d89163 | ||
|
|
b2eae867a1 | ||
|
|
3c2fda4d91 | ||
|
|
2678ceb45e | ||
|
|
58a4cd00b6 | ||
|
|
0faa191b6d | ||
|
|
58cf092105 | ||
|
|
0ff8bfe1d9 | ||
|
|
ceb79c786a | ||
|
|
b1a15d559b | ||
|
|
124a8abf9b | ||
|
|
85c2bb342b | ||
|
|
4c57e39466 | ||
|
|
0cdd4e4e16 | ||
|
|
a9b01be0c2 | ||
|
|
528daf5490 | ||
|
|
0e176d3ac3 | ||
|
|
27f359852e | ||
|
|
173128d431 | ||
|
|
e8ee1e239f | ||
|
|
656fa191c1 | ||
|
|
6b9f8fb9b3 | ||
|
|
f77e5cf8fb | ||
|
|
e6cdc21f2d | ||
|
|
1fe8d4d7ad | ||
|
|
e44320980d | ||
|
|
f5d7fe3072 | ||
|
|
835a27cf51 | ||
|
|
85afaaa13d | ||
|
|
490615169e | ||
|
|
bb232247d0 | ||
|
|
94c128f73b | ||
|
|
613562f504 | ||
|
|
9c4325bcf8 | ||
|
|
ad08fd57df | ||
|
|
54ba59d3e1 | ||
|
|
a4330a225d | ||
|
|
69ddc91c35 | ||
|
|
4c4aed5a87 | ||
|
|
5a40158abf | ||
|
|
4dce485854 | ||
|
|
5ec5d1dace | ||
|
|
d2c765e2b3 | ||
|
|
d036c57d59 | ||
|
|
e7493e2204 | ||
|
|
3500bf64b8 | ||
|
|
4f982ddb94 | ||
|
|
ff3bb7424d | ||
|
|
89d6f60d25 | ||
|
|
ee18c9976e | ||
|
|
794532928f | ||
|
|
7b773c65ec | ||
|
|
e53aa79dc6 | ||
|
|
d9a97249c0 | ||
|
|
86cef16940 | ||
|
|
ce38997c76 | ||
|
|
7e10c728d4 | ||
|
|
3627c67cf2 | ||
|
|
2518fd81f6 | ||
|
|
39ef7fc90e | ||
|
|
37ae0a4051 | ||
|
|
b312928e9f | ||
|
|
2f2856e20a | ||
|
|
831eb6881b | ||
|
|
f20ee2fad2 | ||
|
|
8b9710e56c | ||
|
|
c6262f9d40 | ||
|
|
b749fa90f2 | ||
|
|
8a51cbd253 | ||
|
|
399b8f0701 | ||
|
|
3742e42fdf | ||
|
|
0388ec6862 | ||
|
|
366b8a8034 | ||
|
|
ef9bc4ec9e | ||
|
|
5838b58913 | ||
|
|
2712244ad3 | ||
|
|
6388cbaf92 | ||
|
|
5cc61e1b53 | ||
|
|
0243be86a7 | ||
|
|
9154cd64e7 | ||
|
|
c71d1bde5e | ||
|
|
f27ef595f6 | ||
|
|
34328828ae | ||
|
|
18fb19da3b | ||
|
|
849e1ac543 | ||
|
|
656a8d8f55 | ||
|
|
b976f339e8 | ||
|
|
7d7837e5b6 | ||
|
|
1db292f4df | ||
|
|
49a3a9fe36 | ||
|
|
e51ed460a6 | ||
|
|
d15c2ce349 | ||
|
|
5cc4bb4089 | ||
|
|
6e9e027886 | ||
|
|
f9a3d129a4 | ||
|
|
c53d1d3ad8 | ||
|
|
f386137fba | ||
|
|
c797b60069 | ||
|
|
a139e9297d | ||
|
|
050f99ec54 | ||
|
|
23ed652901 | ||
|
|
13a68f3de3 | ||
|
|
fdad35aaa7 | ||
|
|
a2ce4eb650 | ||
|
|
8fa04986cf | ||
|
|
a5710ed3e1 | ||
|
|
2efdc9df93 | ||
|
|
0c245886fe | ||
|
|
f03288b411 | ||
|
|
09388c98f3 | ||
|
|
ae25c1e7b7 | ||
|
|
0813c14cc6 | ||
|
|
b5151c421f | ||
|
|
e66fd079db | ||
|
|
207ebf4b8c | ||
|
|
12d862dbd3 | ||
|
|
981353793d | ||
|
|
426dcfa3b0 | ||
|
|
69cb49f7cc | ||
|
|
e30678a088 | ||
|
|
771b29a857 | ||
|
|
e6d1aae33a | ||
|
|
9dc8ac4734 | ||
|
|
fdd037ba20 | ||
|
|
523f792b48 | ||
|
|
2230c3c401 | ||
|
|
1b494e5087 | ||
|
|
9c43893a0f | ||
|
|
6dfe19b445 | ||
|
|
a965a06259 | ||
|
|
0654f28c72 | ||
|
|
a32b76dee0 | ||
|
|
a52d640c8c | ||
|
|
218869cf45 | ||
|
|
e99d7a4292 | ||
|
|
f0beb38f91 | ||
|
|
66fcab7b08 | ||
|
|
641e1781a2 | ||
|
|
490b95efe7 | ||
|
|
ba1edea0ab | ||
|
|
73c9b685a7 | ||
|
|
99d8aab0ac | ||
|
|
7dd6369952 | ||
|
|
06f60af1e9 | ||
|
|
66d0beba6f | ||
|
|
6b99dd50b6 | ||
|
|
c53c9d4e4e | ||
|
|
bbd0f3a252 | ||
|
|
b7e208b4f1 | ||
|
|
be9b4d1bcd | ||
|
|
5b5b791d75 | ||
|
|
0b7a5b1e7b | ||
|
|
28bb16ca2a | ||
|
|
8a95be492d | ||
|
|
c42c5a0cc6 | ||
|
|
b2c2478d9d | ||
|
|
1a9af8acb6 | ||
|
|
4c7fe60493 | ||
|
|
c108f304c6 | ||
|
|
2b8acfa0e2 | ||
|
|
b83282b940 | ||
|
|
c4fd677785 | ||
|
|
770cb66628 | ||
|
|
b0bc3d87f5 | ||
|
|
a2634337b8 | ||
|
|
7417c869fc | ||
|
|
091cf25de8 | ||
|
|
7a071eff5c | ||
|
|
7da24ebf5d | ||
|
|
d6e0f47361 | ||
|
|
95385eb652 | ||
|
|
a71b11caca | ||
|
|
e9568999c3 | ||
|
|
5e699c9426 | ||
|
|
e0ca52ed1f | ||
|
|
1d9dcd2a27 | ||
|
|
eeeb21ff86 | ||
|
|
2094e8b255 | ||
|
|
e1cf761d29 | ||
|
|
f64bb91257 | ||
|
|
eb9eb5e334 | ||
|
|
d4d1292a0e | ||
|
|
b7605add58 | ||
|
|
6c7d968c44 | ||
|
|
326c70184d | ||
|
|
aec6ca71fa | ||
|
|
c04da45be5 | ||
|
|
74effa8eec | ||
|
|
cb411248bf | ||
|
|
46d7d2fdc0 | ||
|
|
d68afcaa55 | ||
|
|
bf35a865ba | ||
|
|
6733a5a822 | ||
|
|
7e28098365 | ||
|
|
ae5c9ed3dd | ||
|
|
a9bf1c0505 | ||
|
|
dad248832d | ||
|
|
6e89d3e597 | ||
|
|
3ebba02d04 | ||
|
|
cf425d114e | ||
|
|
39691e5174 | ||
|
|
adaee66364 | ||
|
|
a6978167ae | ||
|
|
80c36c788c | ||
|
|
76cdc668e8 | ||
|
|
2ba1ecabc9 | ||
|
|
e3b6d84b57 | ||
|
|
0638e49b02 | ||
|
|
2c58964a6b | ||
|
|
9507b0eace | ||
|
|
4da199697b | ||
|
|
9cccaa693a | ||
|
|
bb37e908ad | ||
|
|
d802e28381 | ||
|
|
7665b8e30d | ||
|
|
a3d4ea0de1 | ||
|
|
152df2428d | ||
|
|
1a420a1a71 | ||
|
|
4c185c70f2 | ||
|
|
6f9e5335dc | ||
|
|
6c9ae5ce9f | ||
|
|
8cbe7b4a01 | ||
|
|
07348d14a2 | ||
|
|
5f40bd42f8 | ||
|
|
0e5edef51e | ||
|
|
3448118be8 | ||
|
|
2bb3dc585b | ||
|
|
27baa2d65c | ||
|
|
62909e917a | ||
|
|
a60e715fc6 | ||
|
|
161734fb95 | ||
|
|
4e26b0aec7 | ||
|
|
6531cfc521 | ||
|
|
6ddd13c6ac | ||
|
|
7948de1612 | ||
|
|
f363904feb | ||
|
|
85ff05670a | ||
|
|
324230806e | ||
|
|
7f7e622426 | ||
|
|
27447bab26 | ||
|
|
45ac20b8aa | ||
|
|
218330aec1 | ||
|
|
67fa7903c3 | ||
|
|
cd3a09c6a7 | ||
|
|
f8685a4d53 | ||
|
|
6cbb1ef1c2 | ||
|
|
0b825ca383 | ||
|
|
22a4c5a77e | ||
|
|
29dbfc25e5 | ||
|
|
40fc406424 | ||
|
|
6f23271741 | ||
|
|
b7198c28c8 | ||
|
|
de6a6af5ab | ||
|
|
0f1f55a24c | ||
|
|
744c38cc7c | ||
|
|
e9de2505f6 | ||
|
|
22fcde926f | ||
|
|
b42a63b882 | ||
|
|
ca5a7378de | ||
|
|
c6187ee40f | ||
|
|
d94c516402 | ||
|
|
61795d794e | ||
|
|
7c215c0d02 | ||
|
|
715b844c2a | ||
|
|
1b0d65f80e | ||
|
|
faf501200e | ||
|
|
e3267413c2 | ||
|
|
18cad10647 | ||
|
|
a69742ccb2 | ||
|
|
64b21135f9 | ||
|
|
e482405cdc | ||
|
|
ad56338108 | ||
|
|
2ccf21de99 | ||
|
|
dd4ad5f2c5 | ||
|
|
eb71856733 | ||
|
|
d7569a5625 | ||
|
|
0a2aa8688d | ||
|
|
e44cdaf887 | ||
|
|
5709561917 | ||
|
|
9909f94048 | ||
|
|
e8f67ddbcc | ||
|
|
0541d756a6 | ||
|
|
a2d3d62db3 | ||
|
|
850fd9419e | ||
|
|
db3eddc51f | ||
|
|
5dcf3301e0 | ||
|
|
5cf235fa6c | ||
|
|
e4f0825c56 | ||
|
|
3ebebe0a96 | ||
|
|
2a0be8316b | ||
|
|
7f37acdaaa | ||
|
|
e79d41c70e | ||
|
|
109ea1709b | ||
|
|
9a42927268 | ||
|
|
e66d829d18 | ||
|
|
c4ffd93caa | ||
|
|
c78e7e1a28 | ||
|
|
e3a787a7a3 | ||
|
|
12f4315d9d | ||
|
|
d80334b2bc | ||
|
|
c2f5abe759 | ||
|
|
74ebb4147f | ||
|
|
1663c11f40 | ||
|
|
b1c166edf4 | ||
|
|
3c8ce4ab90 | ||
|
|
502dbb65fc | ||
|
|
9d427c1ef8 | ||
|
|
70c6fcfbbf | ||
|
|
6f90c3d73a | ||
|
|
3310c25dd1 | ||
|
|
b751bd0373 | ||
|
|
c2091acd8c | ||
|
|
fd4d3094bf | ||
|
|
10c325810b | ||
|
|
fa45422bf9 | ||
|
|
da82d4035a | ||
|
|
70b6a05235 | ||
|
|
cbf0570489 | ||
|
|
356b5d4601 | ||
|
|
7305fc044d | ||
|
|
1e2da60162 | ||
|
|
e4af1bb422 | ||
|
|
5e8742f431 | ||
|
|
18850c4f91 | ||
|
|
48412f75ac | ||
|
|
6deb27e852 | ||
|
|
b985ea344b | ||
|
|
1233ebcce1 | ||
|
|
881ca86432 | ||
|
|
6aa4928e9e | ||
|
|
9f150b0776 | ||
|
|
7e3e85ba59 | ||
|
|
e41b53504f | ||
|
|
96d6fb78da | ||
|
|
fd6f7133c5 | ||
|
|
98c75be7e1 | ||
|
|
b5dc6e670a | ||
|
|
9d7852b5c3 | ||
|
|
78069369e2 | ||
|
|
1cd77b1072 | ||
|
|
8176bafc55 | ||
|
|
0a3a3216db | ||
|
|
633a3ba03a | ||
|
|
d60696ded8 | ||
|
|
4c2aa4ab90 | ||
|
|
51e6000194 | ||
|
|
bf2cc3aa2f | ||
|
|
4b9e19f72f | ||
|
|
be20f865ac | ||
|
|
7bfbb1fcf8 | ||
|
|
b1bfecb71d | ||
|
|
cf78855165 | ||
|
|
a692e6fdd4 | ||
|
|
d1938a472d | ||
|
|
c0483affa6 | ||
|
|
ae0f69e1fa | ||
|
|
90270c615d | ||
|
|
6b7e6bde4d | ||
|
|
b15fb21191 | ||
|
|
c8866e60ba | ||
|
|
f5eade1d2b | ||
|
|
438610aa64 | ||
|
|
c4c0b23bff | ||
|
|
38704acacd | ||
|
|
4d968ebd64 | ||
|
|
b88e8e0e0b | ||
|
|
3ee1653f40 | ||
|
|
fcd733e3d6 | ||
|
|
cec16dfe95 | ||
|
|
114eb42444 | ||
|
|
e1e18c7abd | ||
|
|
971bd30516 | ||
|
|
2a2082233d | ||
|
|
267d2c82de | ||
|
|
0b8c1f1f7d | ||
|
|
2eb1d4cb9a | ||
|
|
d2a8f44c22 | ||
|
|
1f1f36aac1 | ||
|
|
7f851da15e | ||
|
|
a3bdb974b3 | ||
|
|
46d678fce9 | ||
|
|
1f2348c1ef | ||
|
|
f347194e31 | ||
|
|
7ff2710ce3 | ||
|
|
c12ce2ffff | ||
|
|
a94f564ff0 | ||
|
|
6ef3af73df | ||
|
|
e5ae6c51b0 | ||
|
|
9d76ef6c66 | ||
|
|
e49e781cb8 | ||
|
|
78cea89e0e | ||
|
|
3dc10a1c16 | ||
|
|
157920b2fb | ||
|
|
967313234a | ||
|
|
dfa0281117 | ||
|
|
4a94096994 | ||
|
|
3407ded9d0 | ||
|
|
a325c9af8f | ||
|
|
dc8c011510 | ||
|
|
1f108bc401 | ||
|
|
6b3118883c | ||
|
|
0da8af8a28 | ||
|
|
2a4ed49551 | ||
|
|
7528419172 | ||
|
|
8c739b4a7d | ||
|
|
f2100dcfd8 | ||
|
|
b0b88f6792 | ||
|
|
e9a7c71141 | ||
|
|
4205fbd2aa | ||
|
|
fc52e4b2d3 | ||
|
|
9a6bfeb782 | ||
|
|
fa119423ec | ||
|
|
bf442a50c0 | ||
|
|
09e1b98bc6 | ||
|
|
37d42595cf | ||
|
|
adabad19f1 | ||
|
|
7a74be3b47 | ||
|
|
c95febb1d5 | ||
|
|
9736fce8fc | ||
|
|
05d77b7d47 | ||
|
|
8c484a05b8 | ||
|
|
a0b3bbffd5 | ||
|
|
270d084cb1 | ||
|
|
9312867565 | ||
|
|
7e6a007c35 | ||
|
|
5745ee87ba | ||
|
|
08f056d412 | ||
|
|
96ca0de3bc | ||
|
|
b4d0090e00 | ||
|
|
05ac0a73e1 | ||
|
|
7453e78b35 | ||
|
|
bb8a1718a6 | ||
|
|
6b021658ad |
@@ -31,6 +31,10 @@ runs:
|
|||||||
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
|
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
|
||||||
bun-download-url: ${{ steps.bun-url.outputs.url }}
|
bun-download-url: ${{ steps.bun-url.outputs.url }}
|
||||||
|
|
||||||
|
- name: Install 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: bun install
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -47,60 +47,26 @@ jobs:
|
|||||||
echo "EOF"
|
echo "EOF"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Install OpenCode
|
||||||
|
if: steps.changes.outputs.has_changes == 'true'
|
||||||
|
run: curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
- name: Sync locale docs with OpenCode
|
- name: Sync locale docs with OpenCode
|
||||||
if: steps.changes.outputs.has_changes == 'true'
|
if: steps.changes.outputs.has_changes == 'true'
|
||||||
uses: sst/opencode/github@latest
|
|
||||||
env:
|
env:
|
||||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
|
||||||
OPENCODE_CONFIG_CONTENT: |
|
OPENCODE_CONFIG_CONTENT: |
|
||||||
{
|
{
|
||||||
"permission": {
|
"permission": {
|
||||||
"*": "deny",
|
"*": "deny",
|
||||||
"read": {
|
"read": "allow",
|
||||||
"*": "deny",
|
"edit": "allow",
|
||||||
"packages/web/src/content/docs": "allow",
|
"glob": "allow",
|
||||||
"packages/web/src/content/docs/*": "allow",
|
"task": "allow"
|
||||||
"packages/web/src/content/docs/*.mdx": "allow",
|
|
||||||
"packages/web/src/content/docs/*/*.mdx": "allow",
|
|
||||||
".opencode": "allow",
|
|
||||||
".opencode/agent": "allow",
|
|
||||||
".opencode/glossary": "allow",
|
|
||||||
".opencode/agent/translator.md": "allow",
|
|
||||||
".opencode/glossary/*.md": "allow"
|
|
||||||
},
|
|
||||||
"edit": {
|
|
||||||
"*": "deny",
|
|
||||||
"packages/web/src/content/docs/*/*.mdx": "allow"
|
|
||||||
},
|
|
||||||
"glob": {
|
|
||||||
"*": "deny",
|
|
||||||
"packages/web/src/content/docs*": "allow",
|
|
||||||
".opencode/glossary*": "allow"
|
|
||||||
},
|
|
||||||
"task": {
|
|
||||||
"*": "deny",
|
|
||||||
"translator": "allow"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agent": {
|
|
||||||
"translator": {
|
|
||||||
"permission": {
|
|
||||||
"*": "deny",
|
|
||||||
"read": {
|
|
||||||
"*": "deny",
|
|
||||||
".opencode/agent/translator.md": "allow",
|
|
||||||
".opencode/glossary/*.md": "allow"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
with:
|
run: |
|
||||||
model: opencode/gpt-5.3-codex
|
opencode run --agent docs --model opencode/gpt-5.3-codex <<'EOF'
|
||||||
agent: docs
|
|
||||||
use_github_token: true
|
|
||||||
prompt: |
|
|
||||||
Update localized docs to match the latest English docs changes.
|
Update localized docs to match the latest English docs changes.
|
||||||
|
|
||||||
Changed English doc files:
|
Changed English doc files:
|
||||||
@@ -118,6 +84,7 @@ jobs:
|
|||||||
7. Keep locale docs structure aligned with their corresponding English pages.
|
7. Keep locale docs structure aligned with their corresponding English pages.
|
||||||
8. Do not modify English source docs in packages/web/src/content/docs/*.mdx.
|
8. Do not modify English source docs in packages/web/src/content/docs/*.mdx.
|
||||||
9. If no locale updates are needed, make no changes.
|
9. If no locale updates are needed, make no changes.
|
||||||
|
EOF
|
||||||
|
|
||||||
- name: Commit and push locale docs updates
|
- name: Commit and push locale docs updates
|
||||||
if: steps.changes.outputs.has_changes == 'true'
|
if: steps.changes.outputs.has_changes == 'true'
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: opencode-cli
|
name: opencode-cli
|
||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ needs.version.outputs.version }}
|
version: ${{ needs.version.outputs.version }}
|
||||||
|
|
||||||
@@ -240,11 +239,131 @@ jobs:
|
|||||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||||
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
|
||||||
|
|
||||||
|
build-electron:
|
||||||
|
needs:
|
||||||
|
- build-cli
|
||||||
|
- version
|
||||||
|
continue-on-error: false
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
settings:
|
||||||
|
- host: macos-latest
|
||||||
|
target: x86_64-apple-darwin
|
||||||
|
platform_flag: --mac --x64
|
||||||
|
- host: macos-latest
|
||||||
|
target: aarch64-apple-darwin
|
||||||
|
platform_flag: --mac --arm64
|
||||||
|
- host: "blacksmith-4vcpu-windows-2025"
|
||||||
|
target: x86_64-pc-windows-msvc
|
||||||
|
platform_flag: --win
|
||||||
|
- host: "blacksmith-4vcpu-ubuntu-2404"
|
||||||
|
target: x86_64-unknown-linux-gnu
|
||||||
|
platform_flag: --linux
|
||||||
|
- host: "blacksmith-4vcpu-ubuntu-2404"
|
||||||
|
target: aarch64-unknown-linux-gnu
|
||||||
|
platform_flag: --linux
|
||||||
|
runs-on: ${{ matrix.settings.host }}
|
||||||
|
# if: github.ref_name == 'beta'
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- uses: apple-actions/import-codesign-certs@v2
|
||||||
|
if: runner.os == 'macOS'
|
||||||
|
with:
|
||||||
|
keychain: build
|
||||||
|
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Setup Apple API Key
|
||||||
|
if: runner.os == 'macOS'
|
||||||
|
run: echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
|
||||||
|
|
||||||
|
- uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
|
||||||
|
- name: Cache apt packages
|
||||||
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/apt-cache
|
||||||
|
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-${{ hashFiles('.github/workflows/publish.yml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-
|
||||||
|
|
||||||
|
- name: Install dependencies (ubuntu only)
|
||||||
|
if: contains(matrix.settings.host, 'ubuntu')
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" rpm
|
||||||
|
sudo chmod -R a+rw ~/apt-cache
|
||||||
|
|
||||||
|
- name: Setup git committer
|
||||||
|
id: committer
|
||||||
|
uses: ./.github/actions/setup-git-committer
|
||||||
|
with:
|
||||||
|
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||||
|
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||||
|
|
||||||
|
- name: Prepare
|
||||||
|
run: bun ./scripts/prepare.ts
|
||||||
|
working-directory: packages/desktop-electron
|
||||||
|
env:
|
||||||
|
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||||
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
|
RUST_TARGET: ${{ matrix.settings.target }}
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: bun run build
|
||||||
|
working-directory: packages/desktop-electron
|
||||||
|
env:
|
||||||
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
|
|
||||||
|
- name: Package and publish
|
||||||
|
if: needs.version.outputs.release
|
||||||
|
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish always --config electron-builder.config.ts
|
||||||
|
working-directory: packages/desktop-electron
|
||||||
|
timeout-minutes: 60
|
||||||
|
env:
|
||||||
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
|
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
|
CSC_LINK: ${{ secrets.APPLE_CERTIFICATE }}
|
||||||
|
CSC_KEY_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||||
|
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
|
||||||
|
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY }}
|
||||||
|
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||||
|
|
||||||
|
- name: Package (no publish)
|
||||||
|
if: ${{ !needs.version.outputs.release }}
|
||||||
|
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish never --config electron-builder.config.ts
|
||||||
|
working-directory: packages/desktop-electron
|
||||||
|
timeout-minutes: 60
|
||||||
|
env:
|
||||||
|
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: opencode-electron-${{ matrix.settings.target }}
|
||||||
|
path: packages/desktop-electron/dist/*
|
||||||
|
|
||||||
|
- uses: actions/upload-artifact@v4
|
||||||
|
if: needs.version.outputs.release
|
||||||
|
with:
|
||||||
|
name: latest-yml-${{ matrix.settings.target }}
|
||||||
|
path: packages/desktop-electron/dist/latest*.yml
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs:
|
needs:
|
||||||
- version
|
- version
|
||||||
- build-cli
|
- build-cli
|
||||||
- build-tauri
|
- build-tauri
|
||||||
|
- build-electron
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
@@ -281,6 +400,12 @@ jobs:
|
|||||||
name: opencode-cli
|
name: opencode-cli
|
||||||
path: packages/opencode/dist
|
path: packages/opencode/dist
|
||||||
|
|
||||||
|
- uses: actions/download-artifact@v4
|
||||||
|
if: needs.version.outputs.release
|
||||||
|
with:
|
||||||
|
pattern: latest-yml-*
|
||||||
|
path: /tmp/latest-yml
|
||||||
|
|
||||||
- name: Cache apt packages (AUR)
|
- name: Cache apt packages (AUR)
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@@ -308,3 +433,4 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
|
||||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||||
NPM_CONFIG_PROVENANCE: false
|
NPM_CONFIG_PROVENANCE: false
|
||||||
|
LATEST_YML_DIR: /tmp/latest-yml
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
name: storybook
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/storybook.yml"
|
||||||
|
- "package.json"
|
||||||
|
- "bun.lock"
|
||||||
|
- "packages/storybook/**"
|
||||||
|
- "packages/ui/**"
|
||||||
|
pull_request:
|
||||||
|
branches: [dev]
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/storybook.yml"
|
||||||
|
- "package.json"
|
||||||
|
- "bun.lock"
|
||||||
|
- "packages/storybook/**"
|
||||||
|
- "packages/ui/**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: storybook build
|
||||||
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
|
- name: Build Storybook
|
||||||
|
run: bun --cwd packages/storybook build
|
||||||
@@ -6,6 +6,14 @@ on:
|
|||||||
- dev
|
- dev
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ 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 }})
|
||||||
@@ -86,18 +94,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"
|
|
||||||
|
|||||||
@@ -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.1-pro
|
model: opencode/gemini-3-pro
|
||||||
---
|
---
|
||||||
|
|
||||||
You are a professional translator and localization specialist.
|
You are a professional translator and localization specialist.
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# tr Glossary
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- PR #15835: https://github.com/anomalyco/opencode/pull/15835
|
||||||
|
|
||||||
|
## Do Not Translate (Locale Additions)
|
||||||
|
|
||||||
|
- `OpenCode` (preserve casing in prose, docs, and UI copy)
|
||||||
|
- Keep lowercase `opencode` in commands, package names, paths, URLs, and other exact identifiers
|
||||||
|
- `<TAB>` stays the literal key token in code blocks; use `Tab` for the nearby explanatory label in prose
|
||||||
|
- Commands, flags, file paths, and code literals (keep exactly as written)
|
||||||
|
|
||||||
|
## Preferred Terms
|
||||||
|
|
||||||
|
These are PR-backed wording preferences and may evolve.
|
||||||
|
|
||||||
|
| English / Context | Preferred | Notes |
|
||||||
|
| ------------------------- | --------------------------------------- | ------------------------------------------------------------- |
|
||||||
|
| available in beta | `beta olarak mevcut` | Prefer this over `beta olarak kullanılabilir` |
|
||||||
|
| privacy-first | `Gizlilik öncelikli tasarlandı` | Prefer this over `Önce gizlilik için tasarlandı` |
|
||||||
|
| connect your local models | `yerel modellerinizi bağlayabilirsiniz` | Use the fuller, more direct action phrase |
|
||||||
|
| `<TAB>` key label | `Tab` | Use `Tab` in prose; keep `<TAB>` in literal UI or code blocks |
|
||||||
|
| cross-platform | `cross-platform (tüm platformlarda)` | Keep the English term, add a short clarification when helpful |
|
||||||
|
|
||||||
|
## Guidance
|
||||||
|
|
||||||
|
- Prefer natural Turkish phrasing over literal translation
|
||||||
|
- Merge broken sentence fragments into one clear sentence when the source is a single thought
|
||||||
|
- Keep product naming consistent: `OpenCode` in prose, `opencode` only for exact technical identifiers
|
||||||
|
- When an English technical term is intentionally kept, add a short Turkish clarification only if it improves readability
|
||||||
|
|
||||||
|
## Avoid
|
||||||
|
|
||||||
|
- Avoid `beta olarak kullanılabilir` when `beta olarak mevcut` fits
|
||||||
|
- Avoid `Önce gizlilik için tasarlandı`; use the more natural reviewed wording instead
|
||||||
|
- Avoid `Sekme` for the translated key label in prose when referring to `<TAB>`
|
||||||
|
- Avoid changing `opencode` to `OpenCode` inside commands, URLs, package names, or code literals
|
||||||
@@ -5,6 +5,11 @@
|
|||||||
"options": {},
|
"options": {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"permission": {
|
||||||
|
"edit": {
|
||||||
|
"packages/opencode/migration/*": "deny",
|
||||||
|
},
|
||||||
|
},
|
||||||
"mcp": {},
|
"mcp": {},
|
||||||
"tools": {
|
"tools": {
|
||||||
"github-triage": false,
|
"github-triage": false,
|
||||||
|
|||||||
@@ -1,6 +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}`, {
|
||||||
@@ -24,7 +23,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 sst/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,11 @@
|
|||||||
/// <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 +39,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 +72,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.)
|
|
||||||
@@ -20,6 +20,17 @@
|
|||||||
|
|
||||||
Prefer single word names for variables and functions. Only use multiple words if necessary.
|
Prefer single word names for variables and functions. Only use multiple words if necessary.
|
||||||
|
|
||||||
|
### Naming Enforcement (Read This)
|
||||||
|
|
||||||
|
THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE.
|
||||||
|
|
||||||
|
- Use single word names by default for new locals, params, and helper functions.
|
||||||
|
- Multi-word names are allowed only when a single word would be unclear or ambiguous.
|
||||||
|
- Do not introduce new camelCase compounds when a short single-word alternative is clear.
|
||||||
|
- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible.
|
||||||
|
- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`.
|
||||||
|
- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// Good
|
// Good
|
||||||
const foo = 1
|
const foo = 1
|
||||||
@@ -111,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.
|
||||||
|
|||||||
+4
-1
@@ -27,13 +27,16 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a>
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -34,7 +34,9 @@
|
|||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a>
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+4
-1
@@ -27,13 +27,16 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a>
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -34,7 +34,9 @@
|
|||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a>
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+4
-1
@@ -27,13 +27,16 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a>
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+4
-1
@@ -27,13 +27,16 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a>
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+4
-1
@@ -27,13 +27,16 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a>
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+4
-1
@@ -27,13 +27,16 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a>
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="https://opencode.ai">
|
||||||
|
<picture>
|
||||||
|
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
|
||||||
|
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
|
||||||
|
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p align="center">Ο πράκτορας τεχνητής νοημοσύνης ανοικτού κώδικα για προγραμματισμό.</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||||
|
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||||
|
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="README.md">English</a> |
|
||||||
|
<a href="README.zh.md">简体中文</a> |
|
||||||
|
<a href="README.zht.md">繁體中文</a> |
|
||||||
|
<a href="README.ko.md">한국어</a> |
|
||||||
|
<a href="README.de.md">Deutsch</a> |
|
||||||
|
<a href="README.es.md">Español</a> |
|
||||||
|
<a href="README.fr.md">Français</a> |
|
||||||
|
<a href="README.it.md">Italiano</a> |
|
||||||
|
<a href="README.da.md">Dansk</a> |
|
||||||
|
<a href="README.ja.md">日本語</a> |
|
||||||
|
<a href="README.pl.md">Polski</a> |
|
||||||
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
|
<a href="README.ar.md">العربية</a> |
|
||||||
|
<a href="README.no.md">Norsk</a> |
|
||||||
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a> |
|
||||||
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[](https://opencode.ai)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Εγκατάσταση
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# YOLO
|
||||||
|
curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
|
# Διαχειριστές πακέτων
|
||||||
|
npm i -g opencode-ai@latest # ή bun/pnpm/yarn
|
||||||
|
scoop install opencode # Windows
|
||||||
|
choco install opencode # Windows
|
||||||
|
brew install anomalyco/tap/opencode # macOS και Linux (προτείνεται, πάντα ενημερωμένο)
|
||||||
|
brew install opencode # macOS και Linux (επίσημος τύπος brew, λιγότερο συχνές ενημερώσεις)
|
||||||
|
sudo pacman -S opencode # Arch Linux (Σταθερό)
|
||||||
|
paru -S opencode-bin # Arch Linux (Τελευταία έκδοση από AUR)
|
||||||
|
mise use -g opencode # Οποιοδήποτε λειτουργικό σύστημα
|
||||||
|
nix run nixpkgs#opencode # ή github:anomalyco/opencode με βάση την πιο πρόσφατη αλλαγή από το dev branch
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Αφαίρεσε παλαιότερες εκδόσεις από τη 0.1.x πριν από την εγκατάσταση.
|
||||||
|
|
||||||
|
### Εφαρμογή Desktop (BETA)
|
||||||
|
|
||||||
|
Το OpenCode είναι επίσης διαθέσιμο ως εφαρμογή. Κατέβασε το απευθείας από τη [σελίδα εκδόσεων](https://github.com/anomalyco/opencode/releases) ή το [opencode.ai/download](https://opencode.ai/download).
|
||||||
|
|
||||||
|
| Πλατφόρμα | Λήψη |
|
||||||
|
| --------------------- | ------------------------------------- |
|
||||||
|
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
|
||||||
|
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
|
||||||
|
| Windows | `opencode-desktop-windows-x64.exe` |
|
||||||
|
| Linux | `.deb`, `.rpm`, ή AppImage |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS (Homebrew)
|
||||||
|
brew install --cask opencode-desktop
|
||||||
|
# Windows (Scoop)
|
||||||
|
scoop bucket add extras; scoop install extras/opencode-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Κατάλογος Εγκατάστασης
|
||||||
|
|
||||||
|
Το script εγκατάστασης τηρεί την ακόλουθη σειρά προτεραιότητας για τη διαδρομή εγκατάστασης:
|
||||||
|
|
||||||
|
1. `$OPENCODE_INSTALL_DIR` - Προσαρμοσμένος κατάλογος εγκατάστασης
|
||||||
|
2. `$XDG_BIN_DIR` - Διαδρομή συμβατή με τις προδιαγραφές XDG Base Directory
|
||||||
|
3. `$HOME/bin` - Τυπικός κατάλογος εκτελέσιμων αρχείων χρήστη (εάν υπάρχει ή μπορεί να δημιουργηθεί)
|
||||||
|
4. `$HOME/.opencode/bin` - Προεπιλεγμένη εφεδρική διαδρομή
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Παραδείγματα
|
||||||
|
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
|
||||||
|
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### Πράκτορες
|
||||||
|
|
||||||
|
Το OpenCode περιλαμβάνει δύο ενσωματωμένους πράκτορες μεταξύ των οποίων μπορείτε να εναλλάσσεστε με το πλήκτρο `Tab`.
|
||||||
|
|
||||||
|
- **build** - Προεπιλεγμένος πράκτορας με πλήρη πρόσβαση για εργασία πάνω σε κώδικα
|
||||||
|
- **plan** - Πράκτορας μόνο ανάγνωσης για ανάλυση και εξερεύνηση κώδικα
|
||||||
|
- Αρνείται την επεξεργασία αρχείων από προεπιλογή
|
||||||
|
- Ζητά άδεια πριν εκτελέσει εντολές bash
|
||||||
|
- Ιδανικός για εξερεύνηση άγνωστων αρχείων πηγαίου κώδικα ή σχεδιασμό αλλαγών
|
||||||
|
|
||||||
|
Περιλαμβάνεται επίσης ένας **general** υποπράκτορας για σύνθετες αναζητήσεις και πολυβηματικές διεργασίες.
|
||||||
|
Χρησιμοποιείται εσωτερικά και μπορεί να κληθεί χρησιμοποιώντας `@general` στα μηνύματα.
|
||||||
|
|
||||||
|
Μάθετε περισσότερα για τους [πράκτορες](https://opencode.ai/docs/agents).
|
||||||
|
|
||||||
|
### Οδηγός Χρήσης
|
||||||
|
|
||||||
|
Για περισσότερες πληροφορίες σχετικά με τη ρύθμιση του OpenCode, [**πλοηγήσου στον οδηγό χρήσης μας**](https://opencode.ai/docs).
|
||||||
|
|
||||||
|
### Συνεισφορά
|
||||||
|
|
||||||
|
Εάν ενδιαφέρεσαι να συνεισφέρεις στο OpenCode, διαβάστε τα [οδηγό χρήσης συνεισφοράς](./CONTRIBUTING.md) πριν υποβάλεις ένα pull request.
|
||||||
|
|
||||||
|
### Δημιουργία πάνω στο OpenCode
|
||||||
|
|
||||||
|
Εάν εργάζεσαι σε ένα έργο σχετικό με το OpenCode και χρησιμοποιείτε το "opencode" ως μέρος του ονόματός του, για παράδειγμα "opencode-dashboard" ή "opencode-mobile", πρόσθεσε μια σημείωση στο README σας για να διευκρινίσεις ότι δεν είναι κατασκευασμένο από την ομάδα του OpenCode και δεν έχει καμία σχέση με εμάς.
|
||||||
|
|
||||||
|
### Συχνές Ερωτήσεις
|
||||||
|
|
||||||
|
#### Πώς διαφέρει αυτό από το Claude Code;
|
||||||
|
|
||||||
|
Είναι πολύ παρόμοιο με το Claude Code ως προς τις δυνατότητες. Ακολουθούν οι βασικές διαφορές:
|
||||||
|
|
||||||
|
- 100% ανοιχτού κώδικα
|
||||||
|
- Δεν είναι συνδεδεμένο με κανέναν πάροχο. Αν και συνιστούμε τα μοντέλα που παρέχουμε μέσω του [OpenCode Zen](https://opencode.ai/zen), το OpenCode μπορεί να χρησιμοποιηθεί με Claude, OpenAI, Google, ή ακόμα και τοπικά μοντέλα. Καθώς τα μοντέλα εξελίσσονται, τα κενά μεταξύ τους θα κλείσουν και οι τιμές θα μειωθούν, οπότε είναι σημαντικό να είσαι ανεξάρτητος από τον πάροχο.
|
||||||
|
- Out-of-the-box υποστήριξη LSP
|
||||||
|
- Εστίαση στο TUI. Το OpenCode είναι κατασκευασμένο από χρήστες που χρησιμοποιούν neovim και τους δημιουργούς του [terminal.shop](https://terminal.shop)· θα εξαντλήσουμε τα όρια του τι είναι δυνατό στο terminal.
|
||||||
|
- Αρχιτεκτονική client/server. Αυτό, για παράδειγμα, μπορεί να επιτρέψει στο OpenCode να τρέχει στον υπολογιστή σου ενώ το χειρίζεσαι εξ αποστάσεως από μια εφαρμογή κινητού, που σημαίνει ότι το TUI frontend είναι μόνο ένας από τους πιθανούς clients.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Γίνε μέλος της κοινότητάς μας** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||||
+4
-1
@@ -27,13 +27,16 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a>
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+4
-1
@@ -27,13 +27,16 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a>
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+4
-1
@@ -27,13 +27,16 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a>
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
@@ -34,7 +34,9 @@
|
|||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a>
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
@@ -126,7 +128,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
|
|||||||
|
|
||||||
#### How is this different from Claude Code?
|
#### How is this different from Claude Code?
|
||||||
|
|
||||||
It's very similar to Claude Code in terms of capability. Here are the key differences:
|
It's very similar to Claude Code in terms of capability. Here are the key differences::
|
||||||
|
|
||||||
- 100% open source
|
- 100% open source
|
||||||
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
|
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
|
||||||
|
|||||||
+4
-1
@@ -27,13 +27,16 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a>
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+4
-1
@@ -27,13 +27,16 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a>
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+4
-1
@@ -27,13 +27,16 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a>
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+4
-1
@@ -27,13 +27,16 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a>
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+4
-1
@@ -27,13 +27,16 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a>
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+3
-1
@@ -34,7 +34,9 @@
|
|||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a>
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="https://opencode.ai">
|
||||||
|
<picture>
|
||||||
|
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
|
||||||
|
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
|
||||||
|
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p align="center">Trợ lý lập trình AI mã nguồn mở.</p>
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||||
|
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||||
|
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="README.md">English</a> |
|
||||||
|
<a href="README.zh.md">简体中文</a> |
|
||||||
|
<a href="README.zht.md">繁體中文</a> |
|
||||||
|
<a href="README.ko.md">한국어</a> |
|
||||||
|
<a href="README.de.md">Deutsch</a> |
|
||||||
|
<a href="README.es.md">Español</a> |
|
||||||
|
<a href="README.fr.md">Français</a> |
|
||||||
|
<a href="README.it.md">Italiano</a> |
|
||||||
|
<a href="README.da.md">Dansk</a> |
|
||||||
|
<a href="README.ja.md">日本語</a> |
|
||||||
|
<a href="README.pl.md">Polski</a> |
|
||||||
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
|
<a href="README.ar.md">العربية</a> |
|
||||||
|
<a href="README.no.md">Norsk</a> |
|
||||||
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
|
<a href="README.th.md">ไทย</a> |
|
||||||
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
|
<a href="README.uk.md">Українська</a> |
|
||||||
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
[](https://opencode.ai)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Cài đặt
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# YOLO
|
||||||
|
curl -fsSL https://opencode.ai/install | bash
|
||||||
|
|
||||||
|
# Các trình quản lý gói (Package managers)
|
||||||
|
npm i -g opencode-ai@latest # hoặc bun/pnpm/yarn
|
||||||
|
scoop install opencode # Windows
|
||||||
|
choco install opencode # Windows
|
||||||
|
brew install anomalyco/tap/opencode # macOS và Linux (khuyên dùng, luôn cập nhật)
|
||||||
|
brew install opencode # macOS và Linux (công thức brew chính thức, ít cập nhật hơn)
|
||||||
|
sudo pacman -S opencode # Arch Linux (Bản ổn định)
|
||||||
|
paru -S opencode-bin # Arch Linux (Bản mới nhất từ AUR)
|
||||||
|
mise use -g opencode # Mọi hệ điều hành
|
||||||
|
nix run nixpkgs#opencode # hoặc github:anomalyco/opencode cho nhánh dev mới nhất
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> Hãy xóa các phiên bản cũ hơn 0.1.x trước khi cài đặt.
|
||||||
|
|
||||||
|
### Ứng dụng Desktop (BETA)
|
||||||
|
|
||||||
|
OpenCode cũng có sẵn dưới dạng ứng dụng desktop. Tải trực tiếp từ [trang releases](https://github.com/anomalyco/opencode/releases) hoặc [opencode.ai/download](https://opencode.ai/download).
|
||||||
|
|
||||||
|
| Nền tảng | Tải xuống |
|
||||||
|
| --------------------- | ------------------------------------- |
|
||||||
|
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
|
||||||
|
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
|
||||||
|
| Windows | `opencode-desktop-windows-x64.exe` |
|
||||||
|
| Linux | `.deb`, `.rpm`, hoặc AppImage |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# macOS (Homebrew)
|
||||||
|
brew install --cask opencode-desktop
|
||||||
|
# Windows (Scoop)
|
||||||
|
scoop bucket add extras; scoop install extras/opencode-desktop
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Thư mục cài đặt
|
||||||
|
|
||||||
|
Tập lệnh cài đặt tuân theo thứ tự ưu tiên sau cho đường dẫn cài đặt:
|
||||||
|
|
||||||
|
1. `$OPENCODE_INSTALL_DIR` - Thư mục cài đặt tùy chỉnh
|
||||||
|
2. `$XDG_BIN_DIR` - Đường dẫn tuân thủ XDG Base Directory Specification
|
||||||
|
3. `$HOME/bin` - Thư mục nhị phân tiêu chuẩn của người dùng (nếu tồn tại hoặc có thể tạo)
|
||||||
|
4. `$HOME/.opencode/bin` - Mặc định dự phòng
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ví dụ
|
||||||
|
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
|
||||||
|
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### Agents (Đại diện)
|
||||||
|
|
||||||
|
OpenCode bao gồm hai agent được tích hợp sẵn mà bạn có thể chuyển đổi bằng phím `Tab`.
|
||||||
|
|
||||||
|
- **build** - Agent mặc định, có toàn quyền truy cập cho công việc lập trình
|
||||||
|
- **plan** - Agent chỉ đọc dùng để phân tích và khám phá mã nguồn
|
||||||
|
- Mặc định từ chối việc chỉnh sửa tệp
|
||||||
|
- Hỏi quyền trước khi chạy các lệnh bash
|
||||||
|
- Lý tưởng để khám phá các codebase lạ hoặc lên kế hoạch thay đổi
|
||||||
|
|
||||||
|
Ngoài ra còn có một subagent **general** dùng cho các tìm kiếm phức tạp và tác vụ nhiều bước.
|
||||||
|
Agent này được sử dụng nội bộ và có thể gọi bằng cách dùng `@general` trong tin nhắn.
|
||||||
|
|
||||||
|
Tìm hiểu thêm về [agents](https://opencode.ai/docs/agents).
|
||||||
|
|
||||||
|
### Tài liệu
|
||||||
|
|
||||||
|
Để biết thêm thông tin về cách cấu hình OpenCode, [**hãy truy cập tài liệu của chúng tôi**](https://opencode.ai/docs).
|
||||||
|
|
||||||
|
### Đóng góp
|
||||||
|
|
||||||
|
Nếu bạn muốn đóng góp cho OpenCode, vui lòng đọc [tài liệu hướng dẫn đóng góp](./CONTRIBUTING.md) trước khi gửi pull request.
|
||||||
|
|
||||||
|
### Xây dựng trên nền tảng OpenCode
|
||||||
|
|
||||||
|
Nếu bạn đang làm việc trên một dự án liên quan đến OpenCode và sử dụng "opencode" như một phần của tên dự án, ví dụ "opencode-dashboard" hoặc "opencode-mobile", vui lòng thêm một ghi chú vào README của bạn để làm rõ rằng dự án đó không được xây dựng bởi đội ngũ OpenCode và không liên kết với chúng tôi dưới bất kỳ hình thức nào.
|
||||||
|
|
||||||
|
### Các câu hỏi thường gặp (FAQ)
|
||||||
|
|
||||||
|
#### OpenCode khác biệt thế nào so với Claude Code?
|
||||||
|
|
||||||
|
Về mặt tính năng, nó rất giống Claude Code. Dưới đây là những điểm khác biệt chính:
|
||||||
|
|
||||||
|
- 100% mã nguồn mở
|
||||||
|
- Không bị ràng buộc với bất kỳ nhà cung cấp nào. Mặc dù chúng tôi khuyên dùng các mô hình được cung cấp qua [OpenCode Zen](https://opencode.ai/zen), OpenCode có thể được sử dụng với Claude, OpenAI, Google, hoặc thậm chí các mô hình chạy cục bộ. Khi các mô hình phát triển, khoảng cách giữa chúng sẽ thu hẹp lại và giá cả sẽ giảm, vì vậy việc không phụ thuộc vào nhà cung cấp là rất quan trọng.
|
||||||
|
- Hỗ trợ LSP ngay từ đầu
|
||||||
|
- Tập trung vào TUI (Giao diện người dùng dòng lệnh). OpenCode được xây dựng bởi những người dùng neovim và đội ngũ tạo ra [terminal.shop](https://terminal.shop); chúng tôi sẽ đẩy giới hạn của những gì có thể làm được trên terminal lên mức tối đa.
|
||||||
|
- Kiến trúc client/server. Chẳng hạn, điều này cho phép OpenCode chạy trên máy tính của bạn trong khi bạn điều khiển nó từ xa qua một ứng dụng di động, nghĩa là frontend TUI chỉ là một trong những client có thể dùng.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Tham gia cộng đồng của chúng tôi** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
|
||||||
+4
-1
@@ -27,13 +27,16 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a>
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
+4
-1
@@ -27,13 +27,16 @@
|
|||||||
<a href="README.ja.md">日本語</a> |
|
<a href="README.ja.md">日本語</a> |
|
||||||
<a href="README.pl.md">Polski</a> |
|
<a href="README.pl.md">Polski</a> |
|
||||||
<a href="README.ru.md">Русский</a> |
|
<a href="README.ru.md">Русский</a> |
|
||||||
|
<a href="README.bs.md">Bosanski</a> |
|
||||||
<a href="README.ar.md">العربية</a> |
|
<a href="README.ar.md">العربية</a> |
|
||||||
<a href="README.no.md">Norsk</a> |
|
<a href="README.no.md">Norsk</a> |
|
||||||
<a href="README.br.md">Português (Brasil)</a> |
|
<a href="README.br.md">Português (Brasil)</a> |
|
||||||
<a href="README.th.md">ไทย</a> |
|
<a href="README.th.md">ไทย</a> |
|
||||||
<a href="README.tr.md">Türkçe</a> |
|
<a href="README.tr.md">Türkçe</a> |
|
||||||
<a href="README.uk.md">Українська</a> |
|
<a href="README.uk.md">Українська</a> |
|
||||||
<a href="README.bn.md">বাংলা</a>
|
<a href="README.bn.md">বাংলা</a> |
|
||||||
|
<a href="README.gr.md">Ελληνικά</a> |
|
||||||
|
<a href="README.vi.md">Tiếng Việt</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
[](https://opencode.ai)
|
[](https://opencode.ai)
|
||||||
|
|||||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1770812194,
|
"lastModified": 1772091128,
|
||||||
"narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=",
|
"narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8",
|
"rev": "3f0336406035444b4a24b942788334af5f906259",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
+2
-1
@@ -8,6 +8,7 @@ import type { Context as GitHubContext } from "@actions/github/lib/context"
|
|||||||
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
|
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||||
import { spawn } from "node:child_process"
|
import { spawn } from "node:child_process"
|
||||||
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
|
|
||||||
type GitHubAuthor = {
|
type GitHubAuthor = {
|
||||||
login: string
|
login: string
|
||||||
@@ -281,7 +282,7 @@ async function assertOpencodeConnected() {
|
|||||||
connected = true
|
connected = true
|
||||||
break
|
break
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
await Bun.sleep(300)
|
await sleep(300)
|
||||||
} while (retry++ < 30)
|
} while (retry++ < 30)
|
||||||
|
|
||||||
if (!connected) {
|
if (!connected) {
|
||||||
|
|||||||
+1
-4
@@ -118,7 +118,6 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
|
|||||||
price: zenLitePrice.id,
|
price: zenLitePrice.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const ZEN_LITE_LIMITS = new sst.Secret("ZEN_LITE_LIMITS")
|
|
||||||
|
|
||||||
const zenBlackProduct = new stripe.Product("ZenBlack", {
|
const zenBlackProduct = new stripe.Product("ZenBlack", {
|
||||||
name: "OpenCode Black",
|
name: "OpenCode Black",
|
||||||
@@ -142,7 +141,6 @@ const ZEN_BLACK_PRICE = new sst.Linkable("ZEN_BLACK_PRICE", {
|
|||||||
plan20: zenBlackPrice20.id,
|
plan20: zenBlackPrice20.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const ZEN_BLACK_LIMITS = new sst.Secret("ZEN_BLACK_LIMITS")
|
|
||||||
|
|
||||||
const ZEN_MODELS = [
|
const ZEN_MODELS = [
|
||||||
new sst.Secret("ZEN_MODELS1"),
|
new sst.Secret("ZEN_MODELS1"),
|
||||||
@@ -215,9 +213,8 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
AWS_SES_ACCESS_KEY_ID,
|
AWS_SES_ACCESS_KEY_ID,
|
||||||
AWS_SES_SECRET_ACCESS_KEY,
|
AWS_SES_SECRET_ACCESS_KEY,
|
||||||
ZEN_BLACK_PRICE,
|
ZEN_BLACK_PRICE,
|
||||||
ZEN_BLACK_LIMITS,
|
|
||||||
ZEN_LITE_PRICE,
|
ZEN_LITE_PRICE,
|
||||||
ZEN_LITE_LIMITS,
|
new sst.Secret("ZEN_LIMITS"),
|
||||||
new sst.Secret("ZEN_SESSION_SECRET"),
|
new sst.Secret("ZEN_SESSION_SECRET"),
|
||||||
...ZEN_MODELS,
|
...ZEN_MODELS,
|
||||||
...($dev
|
...($dev
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-3hfy6nfEnGq4J6inH0pXANw05oas+81iuayn7J0pj9c=",
|
"x86_64-linux": "sha256-dhL4YeSi4Lm9yDp919Fx7N2hyLUbZQa2qWoCf/50ce8=",
|
||||||
"aarch64-linux": "sha256-dxWaLtzSeI5NfHwB6u0K10yxoA0ESz/r+zTEQ3FdKFY=",
|
"aarch64-linux": "sha256-//YxCsrvYlxuvd0MtFFO+pLxjmuemyrvGzSIPxzO+rA=",
|
||||||
"aarch64-darwin": "sha256-kkK4rj4g0j2jJFXVmVH7CJcXlI8Dj/KmL/VC3iE4Z+8=",
|
"aarch64-darwin": "sha256-c65kSWteQNaBcQUsjbXNqT61vt98JPNYo9yMNvUygCw=",
|
||||||
"x86_64-darwin": "sha256-jt51irxZd48kb0BItd8InP7lfsELUh0unVYO2es+a98="
|
"x86_64-darwin": "sha256-hlTzEFv3nZHwlDXU65LfMC+NaqYjjyZqagdJ366CNxY="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ stdenvNoCC.mkDerivation {
|
|||||||
../package.json
|
../package.json
|
||||||
../patches
|
../patches
|
||||||
../install # required by desktop build (cli.rs include_str!)
|
../install # required by desktop build (cli.rs include_str!)
|
||||||
|
../.github/TEAM_MEMBERS # required by @opencode-ai/script
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+11
-5
@@ -9,7 +9,9 @@
|
|||||||
"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:storybook": "bun --cwd packages/storybook storybook",
|
||||||
"typecheck": "bun turbo typecheck",
|
"typecheck": "bun turbo typecheck",
|
||||||
|
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"random": "echo 'Random script'",
|
"random": "echo 'Random script'",
|
||||||
"hello": "echo 'Hello World!'",
|
"hello": "echo 'Hello World!'",
|
||||||
@@ -35,13 +37,14 @@
|
|||||||
"@tsconfig/bun": "1.0.9",
|
"@tsconfig/bun": "1.0.9",
|
||||||
"@cloudflare/workers-types": "4.20251008.0",
|
"@cloudflare/workers-types": "4.20251008.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@pierre/diffs": "1.1.0-beta.13",
|
"@pierre/diffs": "1.1.0-beta.18",
|
||||||
"@solid-primitives/storage": "4.3.3",
|
"@solid-primitives/storage": "4.3.3",
|
||||||
"@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.12-a5629fb",
|
"effect": "4.0.0-beta.29",
|
||||||
"drizzle-orm": "1.0.0-beta.12-a5629fb",
|
"drizzle-kit": "1.0.0-beta.16-c2458b2",
|
||||||
|
"drizzle-orm": "1.0.0-beta.16-c2458b2",
|
||||||
"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",
|
||||||
@@ -70,12 +73,13 @@
|
|||||||
"@actions/artifact": "5.0.1",
|
"@actions/artifact": "5.0.1",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/mime-types": "3.0.1",
|
"@types/mime-types": "3.0.1",
|
||||||
|
"@typescript/native-preview": "catalog:",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
"semver": "^7.6.0",
|
"semver": "^7.6.0",
|
||||||
"sst": "3.18.10",
|
"sst": "3.18.10",
|
||||||
"turbo": "2.5.6"
|
"turbo": "2.8.13"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "3.933.0",
|
"@aws-sdk/client-s3": "3.933.0",
|
||||||
@@ -95,10 +99,12 @@
|
|||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"esbuild",
|
"esbuild",
|
||||||
|
"node-pty",
|
||||||
"protobufjs",
|
"protobufjs",
|
||||||
"tree-sitter",
|
"tree-sitter",
|
||||||
"tree-sitter-bash",
|
"tree-sitter-bash",
|
||||||
"web-tree-sitter"
|
"web-tree-sitter",
|
||||||
|
"electron"
|
||||||
],
|
],
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
# CreateEffect Simplification Implementation Spec
|
||||||
|
|
||||||
|
Reduce reactive misuse across `packages/app`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This work targets `packages/app/src`, which currently has 101 `createEffect` calls across 37 files.
|
||||||
|
|
||||||
|
The biggest clusters are `pages/session.tsx` (19), `pages/layout.tsx` (13), `pages/session/file-tabs.tsx` (6), and several context providers that mirror one store into another.
|
||||||
|
|
||||||
|
Key issues from the audit:
|
||||||
|
|
||||||
|
- Derived state is being written through effects instead of computed directly
|
||||||
|
- Session and file resets are handled by watch-and-clear effects instead of keyed state boundaries
|
||||||
|
- User-driven actions are hidden inside reactive effects
|
||||||
|
- Context layers mirror and hydrate child stores with multiple sync effects
|
||||||
|
- Several areas repeat the same imperative trigger pattern in multiple effects
|
||||||
|
|
||||||
|
Keep the implementation focused on removing unnecessary effects, not on broad UI redesign.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Cut high-churn `createEffect` usage in the hottest files first
|
||||||
|
- Replace effect-driven derived state with reactive derivation
|
||||||
|
- Replace reset-on-key effects with keyed ownership boundaries
|
||||||
|
- Move event-driven work to direct actions and write paths
|
||||||
|
- Remove mirrored store hydration where a single source of truth can exist
|
||||||
|
- Leave necessary external sync effects in place, but make them narrower and clearer
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Do not rewrite unrelated component structure just to reduce the count
|
||||||
|
- Do not change product behavior, navigation flow, or persisted data shape unless required for a cleaner write boundary
|
||||||
|
- Do not remove effects that bridge to DOM, editors, polling, or external APIs unless there is a clearly safer equivalent
|
||||||
|
- Do not attempt a repo-wide cleanup outside `packages/app`
|
||||||
|
|
||||||
|
## Effect Taxonomy And Replacement Rules
|
||||||
|
|
||||||
|
Use these rules during implementation.
|
||||||
|
|
||||||
|
### Prefer `createMemo`
|
||||||
|
|
||||||
|
Use `createMemo` when the target value is pure derived state from other signals or stores.
|
||||||
|
|
||||||
|
Do this when an effect only reads reactive inputs and writes another reactive value that could be computed instead.
|
||||||
|
|
||||||
|
Apply this to:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/session.tsx:141`
|
||||||
|
- `packages/app/src/pages/layout.tsx:557`
|
||||||
|
- `packages/app/src/components/terminal.tsx:261`
|
||||||
|
- `packages/app/src/components/session/session-header.tsx:309`
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- If no external system is touched, do not use `createEffect`
|
||||||
|
- Derive once, then read the memo where needed
|
||||||
|
- If normalization is required, prefer normalizing at the write boundary before falling back to a memo
|
||||||
|
|
||||||
|
### Prefer Keyed Remounts
|
||||||
|
|
||||||
|
Use keyed remounts when local UI state should reset because an identity changed.
|
||||||
|
|
||||||
|
Do this with `sessionKey`, `scope()`, or another stable identity instead of watching the key and manually clearing signals.
|
||||||
|
|
||||||
|
Apply this to:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/session.tsx:325`
|
||||||
|
- `packages/app/src/pages/session.tsx:336`
|
||||||
|
- `packages/app/src/pages/session.tsx:477`
|
||||||
|
- `packages/app/src/pages/session.tsx:869`
|
||||||
|
- `packages/app/src/pages/session.tsx:963`
|
||||||
|
- `packages/app/src/pages/session/message-timeline.tsx:149`
|
||||||
|
- `packages/app/src/context/file.tsx:100`
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- If the desired behavior is "new identity, fresh local state," key the owner subtree
|
||||||
|
- Keep state local to the keyed boundary so teardown and recreation handle the reset naturally
|
||||||
|
|
||||||
|
### Prefer Event Handlers And Actions
|
||||||
|
|
||||||
|
Use direct handlers, store actions, and async command functions when work happens because a user clicked, selected, reloaded, or navigated.
|
||||||
|
|
||||||
|
Do this when an effect is just watching for a flag change, command token, or event-bus signal to trigger imperative logic.
|
||||||
|
|
||||||
|
Apply this to:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/layout.tsx:484`
|
||||||
|
- `packages/app/src/pages/layout.tsx:652`
|
||||||
|
- `packages/app/src/pages/layout.tsx:776`
|
||||||
|
- `packages/app/src/pages/layout.tsx:1489`
|
||||||
|
- `packages/app/src/pages/layout.tsx:1519`
|
||||||
|
- `packages/app/src/components/file-tree.tsx:328`
|
||||||
|
- `packages/app/src/pages/session/terminal-panel.tsx:55`
|
||||||
|
- `packages/app/src/context/global-sync.tsx:148`
|
||||||
|
- Duplicated trigger sets in:
|
||||||
|
- `packages/app/src/pages/session/review-tab.tsx:122`
|
||||||
|
- `packages/app/src/pages/session/review-tab.tsx:130`
|
||||||
|
- `packages/app/src/pages/session/review-tab.tsx:138`
|
||||||
|
- `packages/app/src/pages/session/file-tabs.tsx:367`
|
||||||
|
- `packages/app/src/pages/session/file-tabs.tsx:378`
|
||||||
|
- `packages/app/src/pages/session/file-tabs.tsx:389`
|
||||||
|
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
|
||||||
|
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
|
||||||
|
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- If the trigger is user intent, call the action at the source of that intent
|
||||||
|
- If the same imperative work is triggered from multiple places, extract one function and call it directly
|
||||||
|
|
||||||
|
### Prefer `onMount` And `onCleanup`
|
||||||
|
|
||||||
|
Use `onMount` and `onCleanup` for lifecycle-only setup and teardown.
|
||||||
|
|
||||||
|
This is the right fit for subscriptions, one-time wiring, timers, and imperative integration that should not rerun for ordinary reactive changes.
|
||||||
|
|
||||||
|
Use this when:
|
||||||
|
|
||||||
|
- Setup should happen once per owner lifecycle
|
||||||
|
- Cleanup should always pair with teardown
|
||||||
|
- The work is not conceptually derived state
|
||||||
|
|
||||||
|
### Keep `createEffect` When It Is A Real Bridge
|
||||||
|
|
||||||
|
Keep `createEffect` when it synchronizes reactive data to an external imperative sink.
|
||||||
|
|
||||||
|
Examples that should remain, though they may be narrowed or split:
|
||||||
|
|
||||||
|
- DOM/editor sync in `packages/app/src/components/prompt-input.tsx:690`
|
||||||
|
- Scroll sync in `packages/app/src/pages/session.tsx:685`
|
||||||
|
- Scroll/hash sync in `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
|
||||||
|
- External sync in:
|
||||||
|
- `packages/app/src/context/language.tsx:207`
|
||||||
|
- `packages/app/src/context/settings.tsx:110`
|
||||||
|
- `packages/app/src/context/sdk.tsx:26`
|
||||||
|
- Polling in:
|
||||||
|
- `packages/app/src/components/status-popover.tsx:59`
|
||||||
|
- `packages/app/src/components/dialog-select-server.tsx:273`
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Keep the effect single-purpose
|
||||||
|
- Make dependencies explicit and narrow
|
||||||
|
- Avoid writing back into the same reactive graph unless absolutely required
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 0: Classification Pass
|
||||||
|
|
||||||
|
Before changing code, tag each targeted effect as one of: derive, reset, event, lifecycle, or external bridge.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Every targeted effect in this spec is tagged with a replacement strategy before refactoring starts
|
||||||
|
- Shared helpers to be introduced are identified up front to avoid repeating patterns
|
||||||
|
|
||||||
|
### Phase 1: Derived-State Cleanup
|
||||||
|
|
||||||
|
Tackle highest-value, lowest-risk derived-state cleanup first.
|
||||||
|
|
||||||
|
Priority items:
|
||||||
|
|
||||||
|
- Normalize tabs at write boundaries and remove `packages/app/src/pages/session.tsx:141`
|
||||||
|
- Stop syncing `workspaceOrder` in `packages/app/src/pages/layout.tsx:557`
|
||||||
|
- Make prompt slash filtering reactive so `packages/app/src/components/prompt-input.tsx:652` can be removed
|
||||||
|
- Replace other obvious derived-state effects in terminal and session header
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- No behavior change in tab ordering, prompt filtering, terminal display, or header state
|
||||||
|
- Targeted derived-state effects are deleted, not just moved
|
||||||
|
|
||||||
|
### Phase 2: Keyed Reset Cleanup
|
||||||
|
|
||||||
|
Replace reset-on-key effects with keyed ownership boundaries.
|
||||||
|
|
||||||
|
Priority items:
|
||||||
|
|
||||||
|
- Key session-scoped UI and state by `sessionKey`
|
||||||
|
- Key file-scoped state by `scope()`
|
||||||
|
- Remove manual clear-and-reseed effects in session and file context
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Switching session or file scope recreates the intended local state cleanly
|
||||||
|
- No stale state leaks across session or scope changes
|
||||||
|
- Target reset effects are deleted
|
||||||
|
|
||||||
|
### Phase 3: Event-Driven Work Extraction
|
||||||
|
|
||||||
|
Move event-driven work out of reactive effects.
|
||||||
|
|
||||||
|
Priority items:
|
||||||
|
|
||||||
|
- Replace `globalStore.reload` effect dispatching with direct calls
|
||||||
|
- Split mixed-responsibility effect in `packages/app/src/pages/layout.tsx:1489`
|
||||||
|
- Collapse duplicated imperative trigger triplets into single functions
|
||||||
|
- Move file-tree and terminal-panel imperative work to explicit handlers
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- User-triggered behavior still fires exactly once per intended action
|
||||||
|
- No effect remains whose only job is to notice a command-like state and trigger an imperative function
|
||||||
|
|
||||||
|
### Phase 4: Context Ownership Cleanup
|
||||||
|
|
||||||
|
Remove mirrored child-store hydration patterns.
|
||||||
|
|
||||||
|
Priority items:
|
||||||
|
|
||||||
|
- Remove child-store hydration mirrors in `packages/app/src/context/global-sync/child-store.ts:184`, `:190`, `:193`
|
||||||
|
- Simplify mirror logic in `packages/app/src/context/global-sync.tsx:130`, `:138`
|
||||||
|
- Revisit `packages/app/src/context/layout.tsx:424` if it still mirrors instead of deriving
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- There is one clear source of truth for each synced value
|
||||||
|
- Child stores no longer need effect-based hydration to stay consistent
|
||||||
|
- Initialization and updates both work without manual mirror effects
|
||||||
|
|
||||||
|
### Phase 5: Cleanup And Keeper Review
|
||||||
|
|
||||||
|
Clean up remaining targeted hotspots and narrow the effects that should stay.
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Remaining `createEffect` calls in touched files are all true bridges or clearly justified lifecycle sync
|
||||||
|
- Mixed-responsibility effects are split into smaller units where still needed
|
||||||
|
|
||||||
|
## Detailed Work Items By Area
|
||||||
|
|
||||||
|
### 1. Normalize Tab State
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/session.tsx:141`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Move tab normalization into the functions that create, load, or update tab state
|
||||||
|
- Make readers consume already-normalized tab data
|
||||||
|
- Remove the effect that rewrites derived tab state after the fact
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Tabs should become valid when written, not be repaired later
|
||||||
|
- This removes a feedback loop and makes state easier to trust
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The effect at `packages/app/src/pages/session.tsx:141` is removed
|
||||||
|
- Newly created and restored tabs are normalized before they enter local state
|
||||||
|
- Tab rendering still matches current behavior for valid and edge-case inputs
|
||||||
|
|
||||||
|
### 2. Key Session-Owned State
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/session.tsx:325`
|
||||||
|
- `packages/app/src/pages/session.tsx:336`
|
||||||
|
- `packages/app/src/pages/session.tsx:477`
|
||||||
|
- `packages/app/src/pages/session.tsx:869`
|
||||||
|
- `packages/app/src/pages/session.tsx:963`
|
||||||
|
- `packages/app/src/pages/session/message-timeline.tsx:149`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Identify state that should reset when `sessionKey` changes
|
||||||
|
- Move that state under a keyed subtree or keyed owner boundary
|
||||||
|
- Remove effects that watch `sessionKey` just to clear local state, refs, or temporary UI flags
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Session identity already defines the lifetime of this UI state
|
||||||
|
- Keyed ownership makes reset behavior automatic and easier to reason about
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The targeted reset effects are removed
|
||||||
|
- Changing sessions resets only the intended session-local state
|
||||||
|
- Scroll and editor state that should persist are not accidentally reset
|
||||||
|
|
||||||
|
### 3. Derive Workspace Order
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/layout.tsx:557`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Stop writing `workspaceOrder` from live workspace data in an effect
|
||||||
|
- Represent user overrides separately from live workspace data
|
||||||
|
- Compute effective order from current data plus overrides with a memo or pure helper
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Persisted user intent and live source data should not mirror each other through an effect
|
||||||
|
- A computed effective order avoids drift and racey resync behavior
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The effect at `packages/app/src/pages/layout.tsx:557` is removed
|
||||||
|
- Workspace order updates correctly when workspaces appear, disappear, or are reordered by the user
|
||||||
|
- User overrides persist without requiring a sync-back effect
|
||||||
|
|
||||||
|
### 4. Remove Child-Store Mirrors
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/context/global-sync.tsx:130`
|
||||||
|
- `packages/app/src/context/global-sync.tsx:138`
|
||||||
|
- `packages/app/src/context/global-sync.tsx:148`
|
||||||
|
- `packages/app/src/context/global-sync/child-store.ts:184`
|
||||||
|
- `packages/app/src/context/global-sync/child-store.ts:190`
|
||||||
|
- `packages/app/src/context/global-sync/child-store.ts:193`
|
||||||
|
- `packages/app/src/context/layout.tsx:424`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Trace the actual ownership of global and child store values
|
||||||
|
- Replace hydration and mirror effects with explicit initialization and direct updates
|
||||||
|
- Remove the `globalStore.reload` event-bus pattern and call the needed reload paths directly
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Mirrors make it hard to tell which state is authoritative
|
||||||
|
- Event-bus style state toggles hide control flow and create accidental reruns
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Child store hydration no longer depends on effect-based copying
|
||||||
|
- Reload work can be followed from the event source to the handler without a reactive relay
|
||||||
|
- State remains correct on first load, child creation, and subsequent updates
|
||||||
|
|
||||||
|
### 5. Key File-Scoped State
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/context/file.tsx:100`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Move file-scoped local state under a boundary keyed by `scope()`
|
||||||
|
- Remove any effect that watches `scope()` only to reset file-local state
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- File scope changes are identity changes
|
||||||
|
- Keyed ownership gives a cleaner reset than manual clear logic
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The effect at `packages/app/src/context/file.tsx:100` is removed
|
||||||
|
- Switching scopes resets only scope-local state
|
||||||
|
- No previous-scope data appears after a scope change
|
||||||
|
|
||||||
|
### 6. Split Layout Side Effects
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/layout.tsx:1489`
|
||||||
|
- Related event-driven effects near `packages/app/src/pages/layout.tsx:484`, `:652`, `:776`, `:1519`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Break the mixed-responsibility effect at `:1489` into direct actions and smaller bridge effects only where required
|
||||||
|
- Move user-triggered branches into the actual command or handler that causes them
|
||||||
|
- Remove any branch that only exists because one effect is handling unrelated concerns
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Mixed effects hide cause and make reruns hard to predict
|
||||||
|
- Smaller units reduce accidental coupling and make future cleanup safer
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The effect at `packages/app/src/pages/layout.tsx:1489` no longer mixes unrelated responsibilities
|
||||||
|
- Event-driven branches execute from direct handlers
|
||||||
|
- Remaining effects in this area each have one clear external sync purpose
|
||||||
|
|
||||||
|
### 7. Remove Duplicate Triggers
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/pages/session/review-tab.tsx:122`
|
||||||
|
- `packages/app/src/pages/session/review-tab.tsx:130`
|
||||||
|
- `packages/app/src/pages/session/review-tab.tsx:138`
|
||||||
|
- `packages/app/src/pages/session/file-tabs.tsx:367`
|
||||||
|
- `packages/app/src/pages/session/file-tabs.tsx:378`
|
||||||
|
- `packages/app/src/pages/session/file-tabs.tsx:389`
|
||||||
|
- `packages/app/src/pages/session/use-session-hash-scroll.ts:144`
|
||||||
|
- `packages/app/src/pages/session/use-session-hash-scroll.ts:149`
|
||||||
|
- `packages/app/src/pages/session/use-session-hash-scroll.ts:167`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Extract one explicit imperative function per behavior
|
||||||
|
- Call that function from each source event instead of replicating the same effect pattern multiple times
|
||||||
|
- Preserve the scroll-sync effect that is truly syncing with the DOM, but remove duplicate trigger scaffolding around it
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Duplicate triggers make it easy to miss a case or fire twice
|
||||||
|
- One named action is easier to test and reason about
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Repeated imperative effect triplets are collapsed into shared functions
|
||||||
|
- Scroll behavior still works, including hash-based navigation
|
||||||
|
- No duplicate firing is introduced
|
||||||
|
|
||||||
|
### 8. Make Prompt Filtering Reactive
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/components/prompt-input.tsx:652`
|
||||||
|
- Keep `packages/app/src/components/prompt-input.tsx:690` as needed
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Convert slash filtering into a pure reactive derivation from the current input and candidate command list
|
||||||
|
- Keep only the editor or DOM bridge effect if it is still needed for imperative syncing
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- Filtering is classic derived state
|
||||||
|
- It should not need an effect if it can be computed from current inputs
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- The effect at `packages/app/src/components/prompt-input.tsx:652` is removed
|
||||||
|
- Filtered slash-command results update correctly as the input changes
|
||||||
|
- The editor sync effect at `:690` still behaves correctly
|
||||||
|
|
||||||
|
### 9. Clean Up Smaller Derived-State Cases
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `packages/app/src/components/terminal.tsx:261`
|
||||||
|
- `packages/app/src/components/session/session-header.tsx:309`
|
||||||
|
|
||||||
|
Work:
|
||||||
|
|
||||||
|
- Replace effect-written local state with memos or inline derivation
|
||||||
|
- Remove intermediate setters when the value can be computed directly
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
|
||||||
|
- These are low-risk wins that reinforce the same pattern
|
||||||
|
- They also help keep follow-up cleanup consistent
|
||||||
|
|
||||||
|
Acceptance criteria:
|
||||||
|
|
||||||
|
- Targeted effects are removed
|
||||||
|
- UI output remains unchanged under the same inputs
|
||||||
|
|
||||||
|
## Verification And Regression Checks
|
||||||
|
|
||||||
|
Run focused checks after each phase, not only at the end.
|
||||||
|
|
||||||
|
### Suggested Verification
|
||||||
|
|
||||||
|
- Switch between sessions rapidly and confirm local session UI resets only where intended
|
||||||
|
- Open, close, and reorder tabs and confirm order and normalization remain stable
|
||||||
|
- Change workspaces, reload workspace data, and verify effective ordering is correct
|
||||||
|
- Change file scope and confirm stale file state does not bleed across scopes
|
||||||
|
- Trigger layout actions that previously depended on effects and confirm they still fire once
|
||||||
|
- Use slash commands in the prompt and verify filtering updates as you type
|
||||||
|
- Test review tab, file tab, and hash-scroll flows for duplicate or missing triggers
|
||||||
|
- Verify global sync initialization, reload, and child-store creation paths
|
||||||
|
|
||||||
|
### Regression Checks
|
||||||
|
|
||||||
|
- No accidental infinite reruns
|
||||||
|
- No double-firing network or command actions
|
||||||
|
- No lost cleanup for listeners, timers, or scroll handlers
|
||||||
|
- No preserved stale state after identity changes
|
||||||
|
- No removed effect that was actually bridging to DOM or an external API
|
||||||
|
|
||||||
|
If available, add or update tests around pure helpers introduced during this cleanup.
|
||||||
|
|
||||||
|
Favor tests for derived ordering, normalization, and action extraction, since those are easiest to lock down.
|
||||||
|
|
||||||
|
## Definition Of Done
|
||||||
|
|
||||||
|
This work is done when all of the following are true:
|
||||||
|
|
||||||
|
- The highest-leverage targets in this spec are implemented
|
||||||
|
- Each removed effect has been replaced by a clearer pattern: memo, keyed boundary, direct action, or lifecycle hook
|
||||||
|
- The "should remain" effects still exist only where they serve a real external sync purpose
|
||||||
|
- Touched files have fewer mixed-responsibility effects and clearer ownership of state
|
||||||
|
- Manual verification covers session switching, file scope changes, workspace ordering, prompt filtering, and reload flows
|
||||||
|
- No behavior regressions are found in the targeted areas
|
||||||
|
|
||||||
|
A reduced raw `createEffect` count is helpful, but it is not the main success metric.
|
||||||
|
|
||||||
|
The main success metric is clearer ownership and fewer effect-driven state repairs.
|
||||||
|
|
||||||
|
## Risks And Rollout Notes
|
||||||
|
|
||||||
|
Main risks:
|
||||||
|
|
||||||
|
- Keyed remounts can reset too much if state boundaries are drawn too high
|
||||||
|
- Store mirror removal can break initialization order if ownership is not mapped first
|
||||||
|
- Moving event work out of effects can accidentally skip triggers that were previously implicit
|
||||||
|
|
||||||
|
Rollout notes:
|
||||||
|
|
||||||
|
- Land in small phases, with each phase keeping the app behaviorally stable
|
||||||
|
- Prefer isolated PRs by phase or by file cluster, especially for context-store changes
|
||||||
|
- Review each remaining effect in touched files and leave it only if it clearly bridges to something external
|
||||||
@@ -71,6 +71,12 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
|||||||
- `closeDialog(page, dialog)` - Close any dialog
|
- `closeDialog(page, dialog)` - Close any dialog
|
||||||
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
||||||
- `withSession(sdk, title, callback)` - Create temp session
|
- `withSession(sdk, title, callback)` - Create temp session
|
||||||
|
- `withProject(...)` - Create temp project/workspace
|
||||||
|
- `sessionIDFromUrl(url)` - Read session ID from URL
|
||||||
|
- `slugFromUrl(url)` - Read workspace slug from URL
|
||||||
|
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
|
||||||
|
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
|
||||||
|
- `trackDirectory(directory)` - Register directory for fixture cleanup
|
||||||
- `clickListItem(container, filter)` - Click list item by key/text
|
- `clickListItem(container, filter)` - Click list item by key/text
|
||||||
|
|
||||||
**Selectors** (`selectors.ts`):
|
**Selectors** (`selectors.ts`):
|
||||||
@@ -109,7 +115,7 @@ import { test, expect } from "@playwright/test"
|
|||||||
|
|
||||||
### Error Handling
|
### Error Handling
|
||||||
|
|
||||||
Tests should clean up after themselves:
|
Tests should clean up after themselves. Prefer fixture-managed cleanup:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
||||||
@@ -120,6 +126,11 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- Prefer `withSession(...)` for temp sessions
|
||||||
|
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
|
||||||
|
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
|
||||||
|
- Avoid calling `sdk.session.delete(...)` directly
|
||||||
|
|
||||||
### Timeouts
|
### Timeouts
|
||||||
|
|
||||||
Default: 60s per test, 10s per assertion. Override when needed:
|
Default: 60s per test, 10s per assertion. Override when needed:
|
||||||
@@ -161,9 +172,10 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
|||||||
1. Choose appropriate folder or create new one
|
1. Choose appropriate folder or create new one
|
||||||
2. Import from `../fixtures`
|
2. Import from `../fixtures`
|
||||||
3. Use helper functions from `../actions` and `../selectors`
|
3. Use helper functions from `../actions` and `../selectors`
|
||||||
4. Clean up any created resources
|
4. When validating routing, use shared helpers from `../actions`. Workspace URL slugs can be canonicalized on Windows, so assert against canonical or resolved workspace slugs.
|
||||||
5. Use specific selectors (avoid CSS classes)
|
5. Clean up any created resources
|
||||||
6. Test one feature per test file
|
6. Use specific selectors (avoid CSS classes)
|
||||||
|
7. Test one feature per test file
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
|
|||||||
+185
-50
@@ -3,12 +3,12 @@ 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 { modKey, serverUrl } from "./utils"
|
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||||
import {
|
import {
|
||||||
sessionItemSelector,
|
|
||||||
dropdownMenuTriggerSelector,
|
dropdownMenuTriggerSelector,
|
||||||
dropdownMenuContentSelector,
|
dropdownMenuContentSelector,
|
||||||
projectMenuTriggerSelector,
|
projectMenuTriggerSelector,
|
||||||
|
projectCloseMenuSelector,
|
||||||
projectWorkspacesToggleSelector,
|
projectWorkspacesToggleSelector,
|
||||||
titlebarRightSelector,
|
titlebarRightSelector,
|
||||||
popoverBodySelector,
|
popoverBodySelector,
|
||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
workspaceItemSelector,
|
workspaceItemSelector,
|
||||||
workspaceMenuTriggerSelector,
|
workspaceMenuTriggerSelector,
|
||||||
} from "./selectors"
|
} from "./selectors"
|
||||||
import type { createSdk } from "./utils"
|
|
||||||
|
|
||||||
export async function defocus(page: Page) {
|
export async function defocus(page: Page) {
|
||||||
await page
|
await page
|
||||||
@@ -61,9 +60,9 @@ export async function closeDialog(page: Page, dialog: Locator) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function isSidebarClosed(page: Page) {
|
export async function isSidebarClosed(page: Page) {
|
||||||
const main = page.locator("main")
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
const classes = (await main.getAttribute("class")) ?? ""
|
await expect(button).toBeVisible()
|
||||||
return classes.includes("xl:border-l")
|
return (await button.getAttribute("aria-expanded")) !== "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function toggleSidebar(page: Page) {
|
export async function toggleSidebar(page: Page) {
|
||||||
@@ -75,48 +74,34 @@ 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 = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
const visible = await button
|
await button.click()
|
||||||
.isVisible()
|
|
||||||
.then((x) => x)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
if (visible) await button.click()
|
const opened = await expect(button)
|
||||||
if (!visible) await toggleSidebar(page)
|
.toHaveAttribute("aria-expanded", "true", { timeout: 1500 })
|
||||||
|
|
||||||
const main = page.locator("main")
|
|
||||||
const opened = await expect(main)
|
|
||||||
.not.toHaveClass(/xl:border-l/, { timeout: 1500 })
|
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
if (opened) return
|
if (opened) return
|
||||||
|
|
||||||
await toggleSidebar(page)
|
await toggleSidebar(page)
|
||||||
await expect(main).not.toHaveClass(/xl:border-l/)
|
await expect(button).toHaveAttribute("aria-expanded", "true")
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
const visible = await button
|
await button.click()
|
||||||
.isVisible()
|
|
||||||
.then((x) => x)
|
|
||||||
.catch(() => false)
|
|
||||||
|
|
||||||
if (visible) await button.click()
|
const closed = await expect(button)
|
||||||
if (!visible) await toggleSidebar(page)
|
.toHaveAttribute("aria-expanded", "false", { timeout: 1500 })
|
||||||
|
|
||||||
const main = page.locator("main")
|
|
||||||
const closed = await expect(main)
|
|
||||||
.toHaveClass(/xl:border-l/, { timeout: 1500 })
|
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
if (closed) return
|
if (closed) return
|
||||||
|
|
||||||
await toggleSidebar(page)
|
await toggleSidebar(page)
|
||||||
await expect(main).toHaveClass(/xl:border-l/)
|
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openSettings(page: Page) {
|
export async function openSettings(page: Page) {
|
||||||
@@ -197,17 +182,48 @@ export async function createTestProject() {
|
|||||||
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
|
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
|
||||||
|
|
||||||
execSync("git init", { cwd: root, stdio: "ignore" })
|
execSync("git init", { 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', {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
})
|
})
|
||||||
|
|
||||||
return root
|
return resolveDirectory(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupTestProject(directory: string) {
|
export async function cleanupTestProject(directory: string) {
|
||||||
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
|
try {
|
||||||
|
execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
|
||||||
|
} catch {}
|
||||||
|
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slugFromUrl(url: string) {
|
||||||
|
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitSlug(page: Page, skip: string[] = []) {
|
||||||
|
let prev = ""
|
||||||
|
let next = ""
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => {
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
if (!slug) return ""
|
||||||
|
if (skip.includes(slug)) return ""
|
||||||
|
if (slug !== prev) {
|
||||||
|
prev = slug
|
||||||
|
next = ""
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
next = slug
|
||||||
|
return slug
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.not.toBe("")
|
||||||
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sessionIDFromUrl(url: string) {
|
export function sessionIDFromUrl(url: string) {
|
||||||
@@ -216,7 +232,7 @@ export function sessionIDFromUrl(url: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function hoverSessionItem(page: Page, sessionID: string) {
|
export async function hoverSessionItem(page: Page, sessionID: string) {
|
||||||
const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
|
const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last()
|
||||||
await expect(sessionEl).toBeVisible()
|
await expect(sessionEl).toBeVisible()
|
||||||
await sessionEl.hover()
|
await sessionEl.hover()
|
||||||
return sessionEl
|
return sessionEl
|
||||||
@@ -317,6 +333,57 @@ export async function clickListItem(
|
|||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||||
|
const data = await sdk.session
|
||||||
|
.status()
|
||||||
|
.then((x) => x.data ?? {})
|
||||||
|
.catch(() => undefined)
|
||||||
|
return data?.[sessionID]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
|
||||||
|
let prev = ""
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const info = await sdk.session
|
||||||
|
.get({ sessionID })
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (!info) return true
|
||||||
|
const next = `${info.title}:${info.time.updated ?? info.time.created}`
|
||||||
|
if (next !== prev) {
|
||||||
|
prev = next
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
{ timeout },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
|
||||||
|
await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupSession(input: {
|
||||||
|
sessionID: string
|
||||||
|
directory?: string
|
||||||
|
sdk?: ReturnType<typeof createSdk>
|
||||||
|
}) {
|
||||||
|
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
|
||||||
|
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
|
||||||
|
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
|
||||||
|
const current = await status(sdk, input.sessionID).catch(() => undefined)
|
||||||
|
if (current && current.type !== "idle") {
|
||||||
|
await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
|
||||||
|
await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
|
||||||
|
}
|
||||||
|
await stable(sdk, input.sessionID).catch(() => undefined)
|
||||||
|
await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
export async function withSession<T>(
|
export async function withSession<T>(
|
||||||
sdk: ReturnType<typeof createSdk>,
|
sdk: ReturnType<typeof createSdk>,
|
||||||
title: string,
|
title: string,
|
||||||
@@ -328,7 +395,7 @@ export async function withSession<T>(
|
|||||||
try {
|
try {
|
||||||
return await callback(session)
|
return await callback(session)
|
||||||
} finally {
|
} finally {
|
||||||
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: session.id })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,6 +508,57 @@ export async function seedSessionPermission(
|
|||||||
return { id: result.id }
|
return { id: result.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function seedSessionTask(
|
||||||
|
sdk: ReturnType<typeof createSdk>,
|
||||||
|
input: {
|
||||||
|
sessionID: string
|
||||||
|
description: string
|
||||||
|
prompt: string
|
||||||
|
subagentType?: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const text = [
|
||||||
|
"Your only valid response is one task tool call.",
|
||||||
|
`Use this JSON input: ${JSON.stringify({
|
||||||
|
description: input.description,
|
||||||
|
prompt: input.prompt,
|
||||||
|
subagent_type: input.subagentType ?? "general",
|
||||||
|
})}`,
|
||||||
|
"Do not output plain text.",
|
||||||
|
"Wait for the task to start and return the child session id.",
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
const result = await seed({
|
||||||
|
sdk,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
prompt: text,
|
||||||
|
timeout: 90_000,
|
||||||
|
probe: async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? [])
|
||||||
|
const part = messages
|
||||||
|
.flatMap((message) => message.parts)
|
||||||
|
.find((part) => {
|
||||||
|
if (part.type !== "tool" || part.tool !== "task") return false
|
||||||
|
if (part.state.input?.description !== input.description) return false
|
||||||
|
return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!part) return
|
||||||
|
const id = part.state.metadata?.sessionId
|
||||||
|
if (typeof id !== "string" || !id) return
|
||||||
|
const child = await sdk.session
|
||||||
|
.get({ sessionID: id })
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (!child?.id) return
|
||||||
|
return { sessionID: id }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) throw new Error("Timed out seeding task tool")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
export async function seedSessionTodos(
|
export async function seedSessionTodos(
|
||||||
sdk: ReturnType<typeof createSdk>,
|
sdk: ReturnType<typeof createSdk>,
|
||||||
input: {
|
input: {
|
||||||
@@ -515,32 +633,42 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
|
|||||||
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
||||||
await expect(trigger).toHaveCount(1)
|
await expect(trigger).toHaveCount(1)
|
||||||
|
|
||||||
|
const menu = page
|
||||||
|
.locator(dropdownMenuContentSelector)
|
||||||
|
.filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
|
||||||
|
.first()
|
||||||
|
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
|
||||||
|
|
||||||
|
const clicked = await trigger
|
||||||
|
.click({ timeout: 1500 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (clicked) {
|
||||||
|
const opened = await menu
|
||||||
|
.waitFor({ state: "visible", timeout: 1500 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (opened) {
|
||||||
|
await expect(close).toBeVisible()
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await trigger.focus()
|
await trigger.focus()
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
|
||||||
const opened = await menu
|
const opened = await menu
|
||||||
.waitFor({ state: "visible", timeout: 1500 })
|
.waitFor({ state: "visible", timeout: 1500 })
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
if (opened) {
|
if (opened) {
|
||||||
const viewport = page.viewportSize()
|
await expect(close).toBeVisible()
|
||||||
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
|
|
||||||
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
|
|
||||||
await page.mouse.move(x, y)
|
|
||||||
return menu
|
return menu
|
||||||
}
|
}
|
||||||
|
|
||||||
await trigger.click({ force: true })
|
throw new Error(`Failed to open project menu: ${projectSlug}`)
|
||||||
|
|
||||||
await expect(menu).toBeVisible()
|
|
||||||
|
|
||||||
const viewport = page.viewportSize()
|
|
||||||
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
|
|
||||||
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
|
|
||||||
await page.mouse.move(x, y)
|
|
||||||
return menu
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
||||||
@@ -553,11 +681,18 @@ export async function setWorkspacesEnabled(page: Page, projectSlug: string, enab
|
|||||||
|
|
||||||
if (current === enabled) return
|
if (current === enabled) return
|
||||||
|
|
||||||
await openProjectMenu(page, projectSlug)
|
const flip = async (timeout?: number) => {
|
||||||
|
const menu = await openProjectMenu(page, projectSlug)
|
||||||
|
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
||||||
|
await expect(toggle).toBeVisible()
|
||||||
|
return toggle.click({ force: true, timeout })
|
||||||
|
}
|
||||||
|
|
||||||
const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
const flipped = await flip(1500)
|
||||||
await expect(toggle).toBeVisible()
|
.then(() => true)
|
||||||
await toggle.click({ force: true })
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (!flipped) await flip()
|
||||||
|
|
||||||
const expected = enabled ? "New workspace" : "New session"
|
const expected = enabled ? "New workspace" : "New session"
|
||||||
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { serverName } from "../utils"
|
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("/")
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
|
||||||
await expect(page.getByRole("button", { name: serverName })).toBeVisible()
|
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("server picker dialog opens from home", async ({ page }) => {
|
test("server picker dialog opens from home", async ({ page }) => {
|
||||||
await page.goto("/")
|
await page.goto("/")
|
||||||
|
|
||||||
const trigger = page.getByRole("button", { name: serverName })
|
const trigger = page.getByRole("button", { name: serverNamePattern })
|
||||||
await expect(trigger).toBeVisible()
|
await expect(trigger).toBeVisible()
|
||||||
await trigger.click()
|
await trigger.click()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { serverName, serverUrl } from "../utils"
|
import { serverNamePattern, serverUrls } from "../utils"
|
||||||
import { clickListItem, closeDialog, clickMenuItem } from "../actions"
|
import { closeDialog, clickMenuItem } from "../actions"
|
||||||
|
|
||||||
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
||||||
|
|
||||||
@@ -31,10 +31,9 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
|
|||||||
const dialog = page.getByRole("dialog")
|
const dialog = page.getByRole("dialog")
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first()
|
await expect(dialog.getByText(serverNamePattern).first()).toBeVisible()
|
||||||
await expect(row).toBeVisible()
|
|
||||||
|
|
||||||
const menuTrigger = row.locator('[data-slot="dropdown-menu-trigger"]').first()
|
const menuTrigger = dialog.locator('[data-slot="dropdown-menu-trigger"]').first()
|
||||||
await expect(menuTrigger).toBeVisible()
|
await expect(menuTrigger).toBeVisible()
|
||||||
await menuTrigger.click({ force: true })
|
await menuTrigger.click({ force: true })
|
||||||
|
|
||||||
@@ -42,14 +41,18 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
|
|||||||
await expect(menu).toBeVisible()
|
await expect(menu).toBeVisible()
|
||||||
await clickMenuItem(menu, /set as default/i)
|
await clickMenuItem(menu, /set as default/i)
|
||||||
|
|
||||||
await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl)
|
await expect
|
||||||
await expect(row.getByText("Default", { exact: true })).toBeVisible()
|
.poll(async () =>
|
||||||
|
serverUrls.includes((await page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)) ?? ""),
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
await expect(dialog.getByText("Default", { exact: true })).toBeVisible()
|
||||||
|
|
||||||
await closeDialog(page, dialog)
|
await closeDialog(page, dialog)
|
||||||
|
|
||||||
await ensurePopoverOpen()
|
await ensurePopoverOpen()
|
||||||
|
|
||||||
const serverRow = popover.locator("button").filter({ hasText: serverName }).first()
|
const serverRow = popover.locator("button").filter({ hasText: serverNamePattern }).first()
|
||||||
await expect(serverRow).toBeVisible()
|
await expect(serverRow).toBeVisible()
|
||||||
await expect(serverRow.getByText("Default", { exact: true })).toBeVisible()
|
await expect(serverRow.getByText("Default", { exact: true })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd
|
|||||||
|
|
||||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||||
await expect(link).toBeVisible()
|
await expect(link).toBeVisible()
|
||||||
await link.scrollIntoViewIfNeeded()
|
|
||||||
await link.click()
|
await link.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||||
@@ -56,7 +55,6 @@ test("titlebar forward is cleared after branching history from sidebar", async (
|
|||||||
|
|
||||||
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
|
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
|
||||||
await expect(second).toBeVisible()
|
await expect(second).toBeVisible()
|
||||||
await second.scrollIntoViewIfNeeded()
|
|
||||||
await second.click()
|
await second.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
|
||||||
@@ -76,7 +74,6 @@ test("titlebar forward is cleared after branching history from sidebar", async (
|
|||||||
|
|
||||||
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
|
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
|
||||||
await expect(third).toBeVisible()
|
await expect(third).toBeVisible()
|
||||||
await third.scrollIntoViewIfNeeded()
|
|
||||||
await third.click()
|
await third.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
|
||||||
@@ -102,7 +99,6 @@ test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, g
|
|||||||
|
|
||||||
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||||
await expect(link).toBeVisible()
|
await expect(link).toBeVisible()
|
||||||
await link.scrollIntoViewIfNeeded()
|
|
||||||
await link.click()
|
await link.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const expanded = async (el: { getAttribute: (name: string) => Promise<string | n
|
|||||||
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
|
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
|
const reviewPanel = page.locator("#review-panel")
|
||||||
|
|
||||||
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
|
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
|
||||||
await expect(treeToggle).toBeVisible()
|
await expect(treeToggle).toBeVisible()
|
||||||
if (await expanded(treeToggle)) await treeToggle.click()
|
if (await expanded(treeToggle)) await treeToggle.click()
|
||||||
@@ -19,13 +21,13 @@ test("review panel can be toggled via keybind", async ({ page, gotoSession }) =>
|
|||||||
await expect(reviewToggle).toBeVisible()
|
await expect(reviewToggle).toBeVisible()
|
||||||
if (await expanded(reviewToggle)) await reviewToggle.click()
|
if (await expanded(reviewToggle)) await reviewToggle.click()
|
||||||
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
||||||
await expect(page.locator("#review-panel")).toHaveCount(0)
|
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+R`)
|
await page.keyboard.press(`${modKey}+Shift+R`)
|
||||||
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
|
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
|
||||||
await expect(page.locator("#review-panel")).toBeVisible()
|
await expect(reviewPanel).toHaveAttribute("aria-hidden", "false")
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+R`)
|
await page.keyboard.press(`${modKey}+Shift+R`)
|
||||||
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
||||||
await expect(page.locator("#review-panel")).toHaveCount(0)
|
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -43,7 +43,14 @@ test("file tree can expand folders and open a file", async ({ page, gotoSession
|
|||||||
await tab.click()
|
await tab.click()
|
||||||
await expect(tab).toHaveAttribute("aria-selected", "true")
|
await expect(tab).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
const code = page.locator('[data-component="code"]').first()
|
await toggle.click()
|
||||||
await expect(code).toBeVisible()
|
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||||
await expect(code).toContainText("export default function FileTree")
|
|
||||||
|
await toggle.click()
|
||||||
|
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||||
|
await expect(allTab).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
|
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||||
|
await expect(viewer).toBeVisible()
|
||||||
|
await expect(viewer).toContainText("export default function FileTree")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
|
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
@@ -43,7 +44,113 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
|
|||||||
await expect(tab).toBeVisible()
|
await expect(tab).toBeVisible()
|
||||||
await tab.click()
|
await tab.click()
|
||||||
|
|
||||||
const code = page.locator('[data-component="code"]').first()
|
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||||
await expect(code).toBeVisible()
|
await expect(viewer).toBeVisible()
|
||||||
await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
|
await expect(viewer.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cmd+f opens text viewer search while prompt is focused", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/open")
|
||||||
|
|
||||||
|
const command = page.locator('[data-slash-id="file.open"]').first()
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
const input = dialog.getByRole("textbox").first()
|
||||||
|
await input.fill("package.json")
|
||||||
|
|
||||||
|
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
|
||||||
|
let index = -1
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
|
||||||
|
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
|
||||||
|
return index >= 0
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const item = items.nth(index)
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click()
|
||||||
|
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: "package.json" })
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
|
||||||
|
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||||
|
await expect(viewer).toBeVisible()
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.press(`${modKey}+f`)
|
||||||
|
|
||||||
|
const findInput = page.getByPlaceholder("Find")
|
||||||
|
await expect(findInput).toBeVisible()
|
||||||
|
await expect(findInput).toBeFocused()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cmd+f opens text viewer search while prompt is not focused", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/open")
|
||||||
|
|
||||||
|
const command = page.locator('[data-slash-id="file.open"]').first()
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
const input = dialog.getByRole("textbox").first()
|
||||||
|
await input.fill("package.json")
|
||||||
|
|
||||||
|
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
|
||||||
|
let index = -1
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
|
||||||
|
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
|
||||||
|
return index >= 0
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const item = items.nth(index)
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click()
|
||||||
|
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: "package.json" })
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
|
||||||
|
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||||
|
await expect(viewer).toBeVisible()
|
||||||
|
|
||||||
|
await viewer.click()
|
||||||
|
await page.keyboard.press(`${modKey}+f`)
|
||||||
|
|
||||||
|
const findInput = page.getByPlaceholder("Find")
|
||||||
|
await expect(findInput).toBeVisible()
|
||||||
|
await expect(findInput).toBeFocused()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test as base, expect, type Page } from "@playwright/test"
|
import { test as base, expect, type Page } from "@playwright/test"
|
||||||
import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
|
import { cleanupSession, cleanupTestProject, createTestProject, seedProjects, sessionIDFromUrl } from "./actions"
|
||||||
import { promptSelector } from "./selectors"
|
import { promptSelector } from "./selectors"
|
||||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||||
|
|
||||||
@@ -13,6 +13,8 @@ type TestFixtures = {
|
|||||||
directory: string
|
directory: string
|
||||||
slug: string
|
slug: string
|
||||||
gotoSession: (sessionID?: string) => Promise<void>
|
gotoSession: (sessionID?: string) => Promise<void>
|
||||||
|
trackSession: (sessionID: string, directory?: string) => void
|
||||||
|
trackDirectory: (directory: string) => void
|
||||||
}) => Promise<T>,
|
}) => Promise<T>,
|
||||||
options?: { extra?: string[] },
|
options?: { extra?: string[] },
|
||||||
) => Promise<T>
|
) => Promise<T>
|
||||||
@@ -51,20 +53,36 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
},
|
},
|
||||||
withProject: async ({ page }, use) => {
|
withProject: async ({ page }, use) => {
|
||||||
await use(async (callback, options) => {
|
await use(async (callback, options) => {
|
||||||
const directory = await createTestProject()
|
const root = await createTestProject()
|
||||||
const slug = dirSlug(directory)
|
const slug = dirSlug(root)
|
||||||
await seedStorage(page, { directory, extra: options?.extra })
|
const sessions = new Map<string, string>()
|
||||||
|
const dirs = new Set<string>()
|
||||||
|
await seedStorage(page, { directory: root, extra: options?.extra })
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
const gotoSession = async (sessionID?: string) => {
|
||||||
await page.goto(sessionPath(directory, sessionID))
|
await page.goto(sessionPath(root, sessionID))
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
const current = sessionIDFromUrl(page.url())
|
||||||
|
if (current) trackSession(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackSession = (sessionID: string, directory?: string) => {
|
||||||
|
sessions.set(sessionID, directory ?? root)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackDirectory = (directory: string) => {
|
||||||
|
if (directory !== root) dirs.add(directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
return await callback({ directory, slug, gotoSession })
|
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(directory)
|
await Promise.allSettled(
|
||||||
|
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
||||||
|
)
|
||||||
|
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
||||||
|
await cleanupTestProject(root)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,25 +1,15 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSidebar } from "../actions"
|
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
|
||||||
|
|
||||||
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async () => {
|
await withProject(async ({ slug }) => {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const open = async () => {
|
const open = async () => {
|
||||||
const header = page.locator(".group\\/project").first()
|
const menu = await openProjectMenu(page, slug)
|
||||||
await header.hover()
|
await clickMenuItem(menu, /^Edit$/i, { force: true })
|
||||||
const trigger = header.getByRole("button", { name: "More options" }).first()
|
|
||||||
await expect(trigger).toBeVisible()
|
|
||||||
await trigger.click({ force: true })
|
|
||||||
|
|
||||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
|
||||||
await expect(menu).toBeVisible()
|
|
||||||
|
|
||||||
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
|
|
||||||
await expect(editItem).toBeVisible()
|
|
||||||
await editItem.click({ force: true })
|
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
const dialog = page.getByRole("dialog")
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
|
|||||||
@@ -1,36 +1,8 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
|
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
|
||||||
import { projectCloseHoverSelector, projectSwitchSelector } from "../selectors"
|
import { projectSwitchSelector } from "../selectors"
|
||||||
import { dirSlug } from "../utils"
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
test("can close a project via hover card close button", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
const other = await createTestProject()
|
|
||||||
const otherSlug = dirSlug(other)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withProject(
|
|
||||||
async () => {
|
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
|
||||||
await expect(otherButton).toBeVisible()
|
|
||||||
await otherButton.hover()
|
|
||||||
|
|
||||||
const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
|
|
||||||
await expect(close).toBeVisible()
|
|
||||||
await close.click()
|
|
||||||
|
|
||||||
await expect(otherButton).toHaveCount(0)
|
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
await cleanupTestProject(other)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("closing active project navigates to another open project", async ({ page, withProject }) => {
|
test("closing active project navigates to another open project", async ({ page, withProject }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
@@ -53,16 +25,26 @@ test("closing active project navigates to another open project", async ({ page,
|
|||||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(() => {
|
.poll(
|
||||||
const pathname = new URL(page.url()).pathname
|
() => {
|
||||||
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
const pathname = new URL(page.url()).pathname
|
||||||
if (pathname === "/") return "home"
|
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
||||||
return ""
|
if (pathname === "/") return "home"
|
||||||
})
|
return ""
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
.toMatch(/^(project|home)$/)
|
.toMatch(/^(project|home)$/)
|
||||||
|
|
||||||
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
|
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
|
||||||
await expect(otherButton).toHaveCount(0)
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
return await page.locator(projectSwitchSelector(otherSlug)).count()
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toBe(0)
|
||||||
},
|
},
|
||||||
{ extra: [other] },
|
{ extra: [other] },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,18 +1,39 @@
|
|||||||
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 {
|
import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
|
||||||
defocus,
|
|
||||||
createTestProject,
|
|
||||||
cleanupTestProject,
|
|
||||||
openSidebar,
|
|
||||||
setWorkspacesEnabled,
|
|
||||||
sessionIDFromUrl,
|
|
||||||
} from "../actions"
|
|
||||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { createSdk, dirSlug } from "../utils"
|
import { dirSlug, resolveDirectory } from "../utils"
|
||||||
|
|
||||||
function slugFromUrl(url: string) {
|
async function workspaces(page: Page, directory: string, enabled: boolean) {
|
||||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
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 }) => {
|
||||||
@@ -51,57 +72,54 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
const stamp = Date.now()
|
|
||||||
let rootDir: string | undefined
|
|
||||||
let workspaceDir: string | undefined
|
|
||||||
let sessionID: string | undefined
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await withProject(
|
||||||
async ({ directory, slug }) => {
|
async ({ directory, slug, trackSession, trackDirectory }) => {
|
||||||
rootDir = directory
|
|
||||||
await defocus(page)
|
await defocus(page)
|
||||||
|
await workspaces(page, directory, true)
|
||||||
|
await page.reload()
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
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()
|
||||||
|
|
||||||
await expect
|
const raw = await waitSlug(page, [slug])
|
||||||
.poll(
|
const dir = base64Decode(raw)
|
||||||
() => {
|
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
|
||||||
const next = slugFromUrl(page.url())
|
const space = await resolveDirectory(dir)
|
||||||
if (!next) return ""
|
const next = dirSlug(space)
|
||||||
if (next === slug) return ""
|
trackDirectory(space)
|
||||||
return next
|
|
||||||
},
|
|
||||||
{ timeout: 45_000 },
|
|
||||||
)
|
|
||||||
.not.toBe("")
|
|
||||||
|
|
||||||
const workspaceSlug = slugFromUrl(page.url())
|
|
||||||
workspaceDir = base64Decode(workspaceSlug)
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const workspace = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
|
||||||
await expect(workspace).toBeVisible()
|
await expect(item).toBeVisible()
|
||||||
await workspace.hover()
|
await item.hover()
|
||||||
|
|
||||||
const newSession = page.locator(workspaceNewSessionSelector(workspaceSlug)).first()
|
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
|
||||||
await expect(newSession).toBeVisible()
|
await expect(btn).toBeVisible()
|
||||||
await newSession.click({ force: true })
|
await btn.click({ force: true })
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session(?:[/?#]|$)`))
|
// A new workspace can be discovered via a transient slug before the route and sidebar
|
||||||
|
// settle to the canonical workspace path on Windows, so interact with either and assert
|
||||||
|
// against the resolved workspace slug.
|
||||||
|
await waitSlug(page)
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
||||||
|
|
||||||
|
// Create a session by sending a prompt
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
await expect(prompt).toBeVisible()
|
await expect(prompt).toBeVisible()
|
||||||
await prompt.fill(`project switch remembers workspace ${stamp}`)
|
await prompt.fill("test")
|
||||||
await prompt.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
// Wait for the URL to update with the new session ID
|
||||||
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
||||||
|
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
|
||||||
const created = sessionIDFromUrl(page.url())
|
const created = sessionIDFromUrl(page.url())
|
||||||
if (!created) throw new Error(`Failed to parse session id from URL: ${page.url()}`)
|
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
||||||
sessionID = created
|
trackSession(created, space)
|
||||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
@@ -114,25 +132,12 @@ test("switching back to a project opens the latest workspace session", async ({
|
|||||||
await expect(rootButton).toBeVisible()
|
await expect(rootButton).toBeVisible()
|
||||||
await rootButton.click()
|
await rootButton.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/session/${created}(?:[/?#]|$)`))
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
||||||
},
|
},
|
||||||
{ extra: [other] },
|
{ extra: [other] },
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
if (sessionID) {
|
|
||||||
const id = sessionID
|
|
||||||
const dirs = [rootDir, workspaceDir].filter((x): x is string => !!x)
|
|
||||||
await Promise.all(
|
|
||||||
dirs.map((directory) =>
|
|
||||||
createSdk(directory)
|
|
||||||
.session.delete({ sessionID: id })
|
|
||||||
.catch(() => undefined),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (workspaceDir) {
|
|
||||||
await cleanupTestProject(workspaceDir)
|
|
||||||
}
|
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { base64Decode } from "@opencode-ai/util/encode"
|
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 { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions"
|
import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
|
||||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { createSdk } from "../utils"
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
function slugFromUrl(url: string) {
|
|
||||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitWorkspaceReady(page: Page, slug: string) {
|
async function waitWorkspaceReady(page: Page, slug: string) {
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect
|
await expect
|
||||||
@@ -31,20 +27,7 @@ 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()
|
||||||
|
|
||||||
await expect
|
const slug = await waitSlug(page, [root, ...seen])
|
||||||
.poll(
|
|
||||||
() => {
|
|
||||||
const slug = slugFromUrl(page.url())
|
|
||||||
if (!slug) return ""
|
|
||||||
if (slug === root) return ""
|
|
||||||
if (seen.includes(slug)) return ""
|
|
||||||
return slug
|
|
||||||
},
|
|
||||||
{ timeout: 45_000 },
|
|
||||||
)
|
|
||||||
.not.toBe("")
|
|
||||||
|
|
||||||
const slug = slugFromUrl(page.url())
|
|
||||||
const directory = base64Decode(slug)
|
const directory = base64Decode(slug)
|
||||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||||
return { slug, directory }
|
return { slug, directory }
|
||||||
@@ -60,12 +43,13 @@ async function openWorkspaceNewSession(page: Page, slug: string) {
|
|||||||
await expect(button).toBeVisible()
|
await expect(button).toBeVisible()
|
||||||
await button.click({ force: true })
|
await button.click({ force: true })
|
||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
const next = await waitSlug(page)
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
|
||||||
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
|
async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
|
||||||
await openWorkspaceNewSession(page, slug)
|
const next = await openWorkspaceNewSession(page, slug)
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
await expect(prompt).toBeVisible()
|
await expect(prompt).toBeVisible()
|
||||||
@@ -76,13 +60,13 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
|
|||||||
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
|
||||||
await prompt.press("Enter")
|
await prompt.press("Enter")
|
||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
|
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: 30_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(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
|
||||||
return sessionID
|
return { sessionID, slug: next }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sessionDirectory(directory: string, sessionID: string) {
|
async function sessionDirectory(directory: string, sessionID: string) {
|
||||||
@@ -97,48 +81,29 @@ async function sessionDirectory(directory: string, sessionID: string) {
|
|||||||
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 }) => {
|
await withProject(async ({ directory, slug: root, trackSession, trackDirectory }) => {
|
||||||
const workspaces = [] as { slug: string; directory: string }[]
|
await openSidebar(page)
|
||||||
const sessions = [] as string[]
|
await setWorkspacesEnabled(page, root, true)
|
||||||
|
|
||||||
try {
|
const first = await createWorkspace(page, root, [])
|
||||||
await openSidebar(page)
|
trackDirectory(first.directory)
|
||||||
await setWorkspacesEnabled(page, root, true)
|
await waitWorkspaceReady(page, first.slug)
|
||||||
|
|
||||||
const first = await createWorkspace(page, root, [])
|
const second = await createWorkspace(page, root, [first.slug])
|
||||||
workspaces.push(first)
|
trackDirectory(second.directory)
|
||||||
await waitWorkspaceReady(page, first.slug)
|
await waitWorkspaceReady(page, second.slug)
|
||||||
|
|
||||||
const second = await createWorkspace(page, root, [first.slug])
|
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
||||||
workspaces.push(second)
|
trackSession(firstSession.sessionID, first.directory)
|
||||||
await waitWorkspaceReady(page, second.slug)
|
|
||||||
|
|
||||||
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
|
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
||||||
sessions.push(firstSession)
|
trackSession(secondSession.sessionID, second.directory)
|
||||||
|
|
||||||
const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`)
|
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
||||||
sessions.push(secondSession)
|
trackSession(thirdSession.sessionID, first.directory)
|
||||||
|
|
||||||
const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`)
|
await expect.poll(() => sessionDirectory(first.directory, firstSession.sessionID)).toBe(first.directory)
|
||||||
sessions.push(thirdSession)
|
await expect.poll(() => sessionDirectory(second.directory, secondSession.sessionID)).toBe(second.directory)
|
||||||
|
await expect.poll(() => sessionDirectory(first.directory, thirdSession.sessionID)).toBe(first.directory)
|
||||||
await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory)
|
|
||||||
await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory)
|
|
||||||
await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory)
|
|
||||||
} finally {
|
|
||||||
const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)]
|
|
||||||
await Promise.all(
|
|
||||||
sessions.map((sessionID) =>
|
|
||||||
Promise.all(
|
|
||||||
dirs.map((dir) =>
|
|
||||||
createSdk(dir)
|
|
||||||
.session.delete({ sessionID })
|
|
||||||
.catch(() => undefined),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory)))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,14 +14,12 @@ import {
|
|||||||
openSidebar,
|
openSidebar,
|
||||||
openWorkspaceMenu,
|
openWorkspaceMenu,
|
||||||
setWorkspacesEnabled,
|
setWorkspacesEnabled,
|
||||||
|
slugFromUrl,
|
||||||
|
waitSlug,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||||
import { createSdk, dirSlug } from "../utils"
|
import { createSdk, dirSlug } from "../utils"
|
||||||
|
|
||||||
function slugFromUrl(url: string) {
|
|
||||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||||
const rootSlug = project.slug
|
const rootSlug = project.slug
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
@@ -29,17 +27,7 @@ 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()
|
||||||
await expect
|
const slug = await waitSlug(page, [rootSlug])
|
||||||
.poll(
|
|
||||||
() => {
|
|
||||||
const slug = slugFromUrl(page.url())
|
|
||||||
return slug.length > 0 && slug !== rootSlug
|
|
||||||
},
|
|
||||||
{ timeout: 45_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
const slug = slugFromUrl(page.url())
|
|
||||||
const dir = base64Decode(slug)
|
const dir = base64Decode(slug)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
@@ -91,18 +79,7 @@ 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])
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
() => {
|
|
||||||
const currentSlug = slugFromUrl(page.url())
|
|
||||||
return currentSlug.length > 0 && currentSlug !== slug
|
|
||||||
},
|
|
||||||
{ timeout: 45_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
const workspaceSlug = slugFromUrl(page.url())
|
|
||||||
const workspaceDir = base64Decode(workspaceSlug)
|
const workspaceDir = base64Decode(workspaceSlug)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
@@ -279,7 +256,7 @@ test("can delete a workspace", async ({ page, withProject }) => {
|
|||||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||||
await confirmDialog(page, /^Delete workspace$/i)
|
await confirmDialog(page, /^Delete workspace$/i)
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
|
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
@@ -336,9 +313,6 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
|
|||||||
const src = page.locator(workspaceItemSelector(from)).first()
|
const src = page.locator(workspaceItemSelector(from)).first()
|
||||||
const dst = page.locator(workspaceItemSelector(to)).first()
|
const dst = page.locator(workspaceItemSelector(to)).first()
|
||||||
|
|
||||||
await src.scrollIntoViewIfNeeded()
|
|
||||||
await dst.scrollIntoViewIfNeeded()
|
|
||||||
|
|
||||||
const a = await src.boundingBox()
|
const a = await src.boundingBox()
|
||||||
const b = await dst.boundingBox()
|
const b = await dst.boundingBox()
|
||||||
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
||||||
@@ -357,17 +331,7 @@ 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()
|
||||||
await expect
|
const slug = await waitSlug(page, [rootSlug, prev])
|
||||||
.poll(
|
|
||||||
() => {
|
|
||||||
const slug = slugFromUrl(page.url())
|
|
||||||
return slug.length > 0 && slug !== rootSlug && slug !== prev
|
|
||||||
},
|
|
||||||
{ timeout: 45_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
const slug = slugFromUrl(page.url())
|
|
||||||
const dir = base64Decode(slug)
|
const dir = base64Decode(slug)
|
||||||
workspaces.push({ slug, directory: dir })
|
workspaces.push({ slug, directory: dir })
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { sessionIDFromUrl } from "../actions"
|
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||||
|
|
||||||
|
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
|
||||||
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
||||||
// the connection open while the agent works, causing "Failed to fetch" over
|
// the connection open while the agent works, causing "Failed to fetch" over
|
||||||
@@ -38,6 +40,37 @@ test("prompt succeeds when sync message endpoint is unreachable", async ({ page,
|
|||||||
)
|
)
|
||||||
.toContain(token)
|
.toContain(token)
|
||||||
} finally {
|
} finally {
|
||||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => {
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
const value = `restore ${Date.now()}`
|
||||||
|
|
||||||
|
await page.route(`**/session/${session.id}/prompt_async`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ message: "e2e prompt failure" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await gotoSession(session.id)
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(value)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect.poll(async () => text(await prompt.textContent())).toBe(value)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
return messages.length
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { withSession } from "../actions"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
|
||||||
|
const isBash = (part: unknown): part is ToolPart => {
|
||||||
|
if (!part || typeof part !== "object") return false
|
||||||
|
if (!("type" in part) || part.type !== "tool") return false
|
||||||
|
if (!("tool" in part) || part.tool !== "bash") return false
|
||||||
|
return "state" in part
|
||||||
|
}
|
||||||
|
|
||||||
|
async function edge(page: Page, pos: "start" | "end") {
|
||||||
|
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
|
||||||
|
const selection = window.getSelection()
|
||||||
|
if (!selection) return
|
||||||
|
|
||||||
|
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
||||||
|
const nodes: Text[] = []
|
||||||
|
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
|
||||||
|
nodes.push(node as Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
const node = document.createTextNode("")
|
||||||
|
el.appendChild(node)
|
||||||
|
nodes.push(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
|
||||||
|
const range = document.createRange()
|
||||||
|
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
|
||||||
|
range.collapse(true)
|
||||||
|
selection.removeAllRanges()
|
||||||
|
selection.addRange(range)
|
||||||
|
}, pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wait(page: Page, value: string) {
|
||||||
|
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
return messages
|
||||||
|
.filter((item) => item.info.role === "assistant")
|
||||||
|
.flatMap((item) => item.parts)
|
||||||
|
.filter((item) => item.type === "text")
|
||||||
|
.map((item) => item.text)
|
||||||
|
.join("\n")
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.toContain(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
const part = messages
|
||||||
|
.filter((item) => item.info.role === "assistant")
|
||||||
|
.flatMap((item) => item.parts)
|
||||||
|
.filter(isBash)
|
||||||
|
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
|
||||||
|
|
||||||
|
if (!part || part.state.status !== "completed") return
|
||||||
|
return typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.toContain(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
||||||
|
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
||||||
|
const first = `Reply with exactly: ${firstToken}`
|
||||||
|
const second = `Reply with exactly: ${secondToken}`
|
||||||
|
const draft = `draft ${Date.now()}`
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(first)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await reply(sdk, session.id, firstToken)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(second)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await reply(sdk, session.id, secondToken)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(draft)
|
||||||
|
await wait(page, draft)
|
||||||
|
|
||||||
|
await edge(page, "start")
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, second)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, first)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown")
|
||||||
|
await wait(page, second)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown")
|
||||||
|
await wait(page, draft)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
||||||
|
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
||||||
|
const normalToken = `E2E_NORMAL_${Date.now()}`
|
||||||
|
const first = `echo ${firstToken}`
|
||||||
|
const second = `echo ${secondToken}`
|
||||||
|
const normal = `Reply with exactly: ${normalToken}`
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
await page.keyboard.type(first)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await shell(sdk, session.id, first, firstToken)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
await page.keyboard.type(second)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await shell(sdk, session.id, second, secondToken)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, second)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, first)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown")
|
||||||
|
await wait(page, second)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown")
|
||||||
|
await wait(page, "")
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await wait(page, "")
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(normal)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await reply(sdk, session.id, normalToken)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, normal)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { sessionIDFromUrl } from "../actions"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
|
const isBash = (part: unknown): part is ToolPart => {
|
||||||
|
if (!part || typeof part !== "object") return false
|
||||||
|
if (!("type" in part) || part.type !== "tool") return false
|
||||||
|
if (!("tool" in part) || part.tool !== "bash") return false
|
||||||
|
return "state" in part
|
||||||
|
}
|
||||||
|
|
||||||
|
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
||||||
|
const sdk = createSdk(directory)
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
const cmd = process.platform === "win32" ? "dir" : "ls"
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
||||||
|
|
||||||
|
await page.keyboard.type(cmd)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||||
|
|
||||||
|
const id = sessionIDFromUrl(page.url())
|
||||||
|
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||||
|
trackSession(id, directory)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
|
||||||
|
const msg = list.findLast(
|
||||||
|
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
|
||||||
|
)
|
||||||
|
if (!msg) return
|
||||||
|
|
||||||
|
const part = msg.parts
|
||||||
|
.filter(isBash)
|
||||||
|
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
|
||||||
|
|
||||||
|
if (!part || part.state.status !== "completed") return
|
||||||
|
const output =
|
||||||
|
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
||||||
|
if (!output.includes("README.md")) return
|
||||||
|
|
||||||
|
return { cwd: directory, output }
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
|
||||||
|
|
||||||
|
await expect(prompt).toHaveText("")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { withSession } from "../actions"
|
||||||
|
|
||||||
|
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
|
||||||
|
|
||||||
|
async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
|
||||||
|
await sdk.session.promptAsync({
|
||||||
|
sessionID,
|
||||||
|
noReply: true,
|
||||||
|
parts: [{ type: "text", text: "e2e share seed" }],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
|
||||||
|
return messages.length
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
|
||||||
|
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
|
||||||
|
await seed(sdk, session.id)
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("/share")
|
||||||
|
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
|
return data?.share?.url || undefined
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.not.toBeUndefined()
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("/unshare")
|
||||||
|
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
|
return data?.share?.url || undefined
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { sessionIDFromUrl, withSession } from "../actions"
|
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||||
|
|
||||||
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
@@ -46,7 +46,7 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
|
|||||||
.toContain(token)
|
.toContain(token)
|
||||||
} finally {
|
} finally {
|
||||||
page.off("pageerror", onPageError)
|
page.off("pageerror", onPageError)
|
||||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageErrors.length > 0) {
|
if (pageErrors.length > 0) {
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
|||||||
export const projectSwitchSelector = (slug: string) =>
|
export const projectSwitchSelector = (slug: string) =>
|
||||||
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
||||||
|
|
||||||
export const projectCloseHoverSelector = (slug: string) => `[data-action="project-close-hover"][data-project="${slug}"]`
|
|
||||||
|
|
||||||
export const projectMenuTriggerSelector = (slug: string) =>
|
export const projectMenuTriggerSelector = (slug: string) =>
|
||||||
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
|
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { seedSessionTask, withSession } from "../actions"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
|
||||||
|
test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
const errs: string[] = []
|
||||||
|
const onError = (err: Error) => {
|
||||||
|
errs.push(err.message)
|
||||||
|
}
|
||||||
|
page.on("pageerror", onError)
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
||||||
|
const child = await seedSessionTask(sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
description: "Open child session",
|
||||||
|
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
const link = page
|
||||||
|
.locator("a.subagent-link")
|
||||||
|
.filter({ hasText: /open child session/i })
|
||||||
|
.first()
|
||||||
|
await expect(link).toBeVisible({ timeout: 30_000 })
|
||||||
|
await link.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
expect(errs).toEqual([])
|
||||||
|
} finally {
|
||||||
|
page.off("pageerror", onError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
|
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions"
|
||||||
import {
|
import {
|
||||||
permissionDockSelector,
|
permissionDockSelector,
|
||||||
promptSelector,
|
promptSelector,
|
||||||
@@ -26,7 +26,7 @@ async function withDockSession<T>(
|
|||||||
try {
|
try {
|
||||||
return await fn(session)
|
return await fn(session)
|
||||||
} finally {
|
} finally {
|
||||||
await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: session.id })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +50,15 @@ async function clearPermissionDock(page: any, label: RegExp) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setAutoAccept(page: any, enabled: boolean) {
|
||||||
|
const button = page.locator('[data-action="prompt-permissions"]').first()
|
||||||
|
await expect(button).toBeVisible()
|
||||||
|
const pressed = (await button.getAttribute("aria-pressed")) === "true"
|
||||||
|
if (pressed === enabled) return
|
||||||
|
await button.click()
|
||||||
|
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
|
||||||
|
}
|
||||||
|
|
||||||
async function withMockPermission<T>(
|
async function withMockPermission<T>(
|
||||||
page: any,
|
page: any,
|
||||||
request: {
|
request: {
|
||||||
@@ -133,6 +142,17 @@ test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const button = page.locator('[data-action="prompt-permissions"]').first()
|
||||||
|
await expect(button).toBeVisible()
|
||||||
|
await expect(button).toHaveAttribute("aria-pressed", "false")
|
||||||
|
|
||||||
|
await setAutoAccept(page, true)
|
||||||
|
await setAutoAccept(page, false)
|
||||||
|
})
|
||||||
|
|
||||||
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
|
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
|
||||||
await withDockSession(sdk, "e2e composer dock question", async (session) => {
|
await withDockSession(sdk, "e2e composer dock question", async (session) => {
|
||||||
await withDockSeed(sdk, session.id, async () => {
|
await withDockSeed(sdk, session.id, async () => {
|
||||||
@@ -168,6 +188,7 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
|
|||||||
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
||||||
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
await setAutoAccept(page, false)
|
||||||
await withMockPermission(
|
await withMockPermission(
|
||||||
page,
|
page,
|
||||||
{
|
{
|
||||||
@@ -195,6 +216,7 @@ test("blocked permission flow supports allow once", async ({ page, sdk, gotoSess
|
|||||||
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
|
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
|
||||||
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
|
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
await setAutoAccept(page, false)
|
||||||
await withMockPermission(
|
await withMockPermission(
|
||||||
page,
|
page,
|
||||||
{
|
{
|
||||||
@@ -221,6 +243,7 @@ test("blocked permission flow supports reject", async ({ page, sdk, gotoSession
|
|||||||
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
|
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
|
||||||
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
|
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
await setAutoAccept(page, false)
|
||||||
await withMockPermission(
|
await withMockPermission(
|
||||||
page,
|
page,
|
||||||
{
|
{
|
||||||
@@ -288,7 +311,7 @@ test("child session question request blocks parent dock and unblocks after submi
|
|||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: child.id })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -300,6 +323,7 @@ test("child session permission request blocks parent dock and supports allow onc
|
|||||||
}) => {
|
}) => {
|
||||||
await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
|
await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
|
||||||
await gotoSession(session.id)
|
await gotoSession(session.id)
|
||||||
|
await setAutoAccept(page, false)
|
||||||
|
|
||||||
const child = await sdk.session
|
const child = await sdk.session
|
||||||
.create({
|
.create({
|
||||||
@@ -334,7 +358,7 @@ test("child session permission request blocks parent dock and supports allow onc
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
await sdk.session.delete({ sessionID: child.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: child.id })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ async function seedConversation(input: {
|
|||||||
.toBe(true)
|
.toBe(true)
|
||||||
|
|
||||||
if (!userMessageID) throw new Error("Expected a user message id")
|
if (!userMessageID) throw new Error("Expected a user message id")
|
||||||
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 })
|
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`)).toHaveCount(1, { timeout: 30_000 })
|
||||||
return { prompt, userMessageID }
|
return { prompt, userMessageID }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ test("slash redo clears revert and restores latest state", async ({ page, withPr
|
|||||||
.toBeUndefined()
|
.toBeUndefined()
|
||||||
|
|
||||||
await expect(seeded.prompt).not.toContainText(token)
|
await expect(seeded.prompt).not.toContainText(token)
|
||||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`).first()).toBeVisible()
|
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -158,8 +158,8 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
|
|||||||
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
|
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
|
||||||
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
|
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
|
||||||
|
|
||||||
await expect(firstMessage.first()).toBeVisible()
|
await expect(firstMessage).toHaveCount(1)
|
||||||
await expect(secondMessage.first()).toBeVisible()
|
await expect(secondMessage).toHaveCount(1)
|
||||||
|
|
||||||
await second.prompt.click()
|
await second.prompt.click()
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
@@ -176,7 +176,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
|
|||||||
})
|
})
|
||||||
.toBe(second.userMessageID)
|
.toBe(second.userMessageID)
|
||||||
|
|
||||||
await expect(firstMessage.first()).toBeVisible()
|
await expect(firstMessage).toHaveCount(1)
|
||||||
await expect(secondMessage).toHaveCount(0)
|
await expect(secondMessage).toHaveCount(0)
|
||||||
|
|
||||||
await second.prompt.click()
|
await second.prompt.click()
|
||||||
@@ -210,7 +210,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
|
|||||||
})
|
})
|
||||||
.toBe(second.userMessageID)
|
.toBe(second.userMessageID)
|
||||||
|
|
||||||
await expect(firstMessage.first()).toBeVisible()
|
await expect(firstMessage).toHaveCount(1)
|
||||||
await expect(secondMessage).toHaveCount(0)
|
await expect(secondMessage).toHaveCount(0)
|
||||||
|
|
||||||
await second.prompt.click()
|
await second.prompt.click()
|
||||||
@@ -226,8 +226,8 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withPro
|
|||||||
})
|
})
|
||||||
.toBeUndefined()
|
.toBeUndefined()
|
||||||
|
|
||||||
await expect(firstMessage.first()).toBeVisible()
|
await expect(firstMessage).toHaveCount(1)
|
||||||
await expect(secondMessage.first()).toBeVisible()
|
await expect(secondMessage).toHaveCount(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -32,22 +32,19 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
|
|||||||
|
|
||||||
await closeDialog(page, dialog)
|
await closeDialog(page, dialog)
|
||||||
|
|
||||||
const main = page.locator("main")
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
const initialClasses = (await main.getAttribute("class")) ?? ""
|
const initiallyClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
||||||
const initiallyClosed = initialClasses.includes("xl:border-l")
|
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+H`)
|
await page.keyboard.press(`${modKey}+Shift+H`)
|
||||||
await page.waitForTimeout(100)
|
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "true" : "false")
|
||||||
|
|
||||||
const afterToggleClasses = (await main.getAttribute("class")) ?? ""
|
const afterToggleClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
||||||
const afterToggleClosed = afterToggleClasses.includes("xl:border-l")
|
|
||||||
expect(afterToggleClosed).toBe(!initiallyClosed)
|
expect(afterToggleClosed).toBe(!initiallyClosed)
|
||||||
|
|
||||||
await page.keyboard.press(`${modKey}+Shift+H`)
|
await page.keyboard.press(`${modKey}+Shift+H`)
|
||||||
await page.waitForTimeout(100)
|
await expect(button).toHaveAttribute("aria-expanded", initiallyClosed ? "false" : "true")
|
||||||
|
|
||||||
const finalClasses = (await main.getAttribute("class")) ?? ""
|
const finalClosed = (await button.getAttribute("aria-expanded")) !== "true"
|
||||||
const finalClosed = finalClasses.includes("xl:border-l")
|
|
||||||
expect(finalClosed).toBe(initiallyClosed)
|
expect(finalClosed).toBe(initiallyClosed)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -83,16 +83,23 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
|||||||
const select = dialog.locator(settingsThemeSelector)
|
const select = dialog.locator(settingsThemeSelector)
|
||||||
await expect(select).toBeVisible()
|
await expect(select).toBeVisible()
|
||||||
|
|
||||||
|
const currentThemeId = await page.evaluate(() => {
|
||||||
|
return document.documentElement.getAttribute("data-theme")
|
||||||
|
})
|
||||||
|
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
||||||
|
|
||||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||||
|
|
||||||
const items = page.locator('[data-slot="select-select-item"]')
|
const items = page.locator('[data-slot="select-select-item"]')
|
||||||
const count = await items.count()
|
const count = await items.count()
|
||||||
expect(count).toBeGreaterThan(1)
|
expect(count).toBeGreaterThan(1)
|
||||||
|
|
||||||
const firstTheme = await items.nth(1).locator('[data-slot="select-select-item-label"]').textContent()
|
const nextTheme = (await items.locator('[data-slot="select-select-item-label"]').allTextContents())
|
||||||
expect(firstTheme).toBeTruthy()
|
.map((x) => x.trim())
|
||||||
|
.find((x) => x && x !== currentTheme)
|
||||||
|
expect(nextTheme).toBeTruthy()
|
||||||
|
|
||||||
await items.nth(1).click()
|
await items.filter({ hasText: nextTheme! }).first().click()
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
await page.keyboard.press("Escape")
|
||||||
|
|
||||||
@@ -101,7 +108,7 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
expect(storedThemeId).not.toBeNull()
|
expect(storedThemeId).not.toBeNull()
|
||||||
expect(storedThemeId).not.toBe("oc-1")
|
expect(storedThemeId).not.toBe(currentThemeId)
|
||||||
|
|
||||||
const dataTheme = await page.evaluate(() => {
|
const dataTheme = await page.evaluate(() => {
|
||||||
return document.documentElement.getAttribute("data-theme")
|
return document.documentElement.getAttribute("data-theme")
|
||||||
@@ -109,6 +116,42 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
|||||||
expect(dataTheme).toBe(storedThemeId)
|
expect(dataTheme).toBe(storedThemeId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("legacy oc-1 theme migrates to oc-2", async ({ page, gotoSession }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem("opencode-theme-id", "oc-1")
|
||||||
|
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
|
||||||
|
localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;")
|
||||||
|
})
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await expect(page.locator("html")).toHaveAttribute("data-theme", "oc-2")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
return localStorage.getItem("opencode-theme-id")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.toBe("oc-2")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
return localStorage.getItem("opencode-theme-css-light")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.toBeNull()
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
return localStorage.getItem("opencode-theme-css-dark")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
|
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { closeSidebar, hoverSessionItem } from "../actions"
|
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions"
|
||||||
import { projectSwitchSelector, sessionItemSelector } from "../selectors"
|
import { projectSwitchSelector } from "../selectors"
|
||||||
|
|
||||||
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()
|
||||||
@@ -15,12 +15,15 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
|||||||
await gotoSession(one.id)
|
await gotoSession(one.id)
|
||||||
await closeSidebar(page)
|
await closeSidebar(page)
|
||||||
|
|
||||||
|
const oneItem = page.locator(`[data-session-id="${one.id}"]`).last()
|
||||||
|
const twoItem = page.locator(`[data-session-id="${two.id}"]`).last()
|
||||||
|
|
||||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
const project = page.locator(projectSwitchSelector(slug)).first()
|
||||||
await expect(project).toBeVisible()
|
await expect(project).toBeVisible()
|
||||||
await project.hover()
|
await project.hover()
|
||||||
|
|
||||||
await expect(page.locator(sessionItemSelector(one.id)).first()).toBeVisible()
|
await expect(oneItem).toBeVisible()
|
||||||
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
|
await expect(twoItem).toBeVisible()
|
||||||
|
|
||||||
const item = await hoverSessionItem(page, one.id)
|
const item = await hoverSessionItem(page, one.id)
|
||||||
await item
|
await item
|
||||||
@@ -28,9 +31,9 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
|||||||
.first()
|
.first()
|
||||||
.click()
|
.click()
|
||||||
|
|
||||||
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
|
await expect(twoItem).toBeVisible()
|
||||||
} finally {
|
} finally {
|
||||||
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: one.id })
|
||||||
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: two.id })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { openSidebar, withSession } from "../actions"
|
import { cleanupSession, openSidebar, withSession } from "../actions"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
|
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
|
||||||
@@ -18,14 +18,13 @@ test("sidebar session links navigate to the selected session", async ({ page, sl
|
|||||||
|
|
||||||
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
|
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||||
await expect(target).toBeVisible()
|
await expect(target).toBeVisible()
|
||||||
await target.scrollIntoViewIfNeeded()
|
|
||||||
await target.click()
|
await target.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/)
|
await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/)
|
||||||
} finally {
|
} finally {
|
||||||
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: one.id })
|
||||||
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
|
await cleanupSession({ sdk, sessionID: two.id })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
|
|||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
|
await expect(button).toHaveAttribute("aria-expanded", "true")
|
||||||
|
|
||||||
await toggleSidebar(page)
|
await toggleSidebar(page)
|
||||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||||
|
|
||||||
await toggleSidebar(page)
|
await toggleSidebar(page)
|
||||||
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
|
await expect(button).toHaveAttribute("aria-expanded", "true")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
|
test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
|
||||||
@@ -19,14 +21,15 @@ test("sidebar collapsed state persists across navigation and reload", async ({ p
|
|||||||
await gotoSession(session1.id)
|
await gotoSession(session1.id)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
await toggleSidebar(page)
|
await toggleSidebar(page)
|
||||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||||
|
|
||||||
await gotoSession(session2.id)
|
await gotoSession(session2.id)
|
||||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||||
|
|
||||||
await page.reload()
|
await page.reload()
|
||||||
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
|
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||||
|
|
||||||
const opened = await page.evaluate(
|
const opened = await page.evaluate(
|
||||||
() => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
|
() => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { terminalSelector } from "../selectors"
|
||||||
|
import { terminalToggleKey, workspacePersistKey } from "../utils"
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
active?: string
|
||||||
|
all: Array<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
titleNumber: number
|
||||||
|
buffer?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(page: Page) {
|
||||||
|
const terminal = page.locator(terminalSelector)
|
||||||
|
const visible = await terminal.isVisible().catch(() => false)
|
||||||
|
if (!visible) await page.keyboard.press(terminalToggleKey)
|
||||||
|
await expect(terminal).toBeVisible()
|
||||||
|
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) {
|
||||||
|
return page.evaluate((key) => {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
if (raw) return JSON.parse(raw) as State
|
||||||
|
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const next = localStorage.key(i)
|
||||||
|
if (!next?.endsWith(":workspace:terminal")) continue
|
||||||
|
const value = localStorage.getItem(next)
|
||||||
|
if (!value) continue
|
||||||
|
return JSON.parse(value) as State
|
||||||
|
}
|
||||||
|
}, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
|
||||||
|
await withProject(async ({ directory, gotoSession }) => {
|
||||||
|
const key = workspacePersistKey(directory, "terminal")
|
||||||
|
const one = `E2E_TERM_ONE_${Date.now()}`
|
||||||
|
const two = `E2E_TERM_TWO_${Date.now()}`
|
||||||
|
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||||
|
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||||
|
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
await open(page)
|
||||||
|
|
||||||
|
await run(page, `echo ${one}`)
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||||
|
await expect(tabs).toHaveCount(2)
|
||||||
|
|
||||||
|
await run(page, `echo ${two}`)
|
||||||
|
|
||||||
|
await first.click()
|
||||||
|
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const state = await store(page, key)
|
||||||
|
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||||
|
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||||
|
return {
|
||||||
|
first: first.includes(one),
|
||||||
|
second: second.includes(two),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toEqual({ first: false, second: true })
|
||||||
|
|
||||||
|
await second.click()
|
||||||
|
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const state = await store(page, key)
|
||||||
|
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||||
|
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||||
|
return {
|
||||||
|
first: first.includes(one),
|
||||||
|
second: second.includes(two),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toEqual({ first: true, second: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
|
||||||
|
await withProject(async ({ directory, gotoSession }) => {
|
||||||
|
const key = workspacePersistKey(directory, "terminal")
|
||||||
|
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
await open(page)
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||||
|
await expect(tabs).toHaveCount(2)
|
||||||
|
|
||||||
|
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||||
|
await second.click()
|
||||||
|
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
|
await second.hover()
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: /close terminal/i })
|
||||||
|
.nth(1)
|
||||||
|
.click({ force: true })
|
||||||
|
|
||||||
|
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||||
|
await expect(tabs).toHaveCount(1)
|
||||||
|
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const state = await store(page, key)
|
||||||
|
return {
|
||||||
|
count: state?.all.length ?? 0,
|
||||||
|
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toEqual({ count: 1, first: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
||||||
|
|
||||||
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
export const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
|
||||||
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||||
@@ -7,6 +7,22 @@ export const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
|||||||
export const serverUrl = `http://${serverHost}:${serverPort}`
|
export const serverUrl = `http://${serverHost}:${serverPort}`
|
||||||
export const serverName = `${serverHost}:${serverPort}`
|
export const serverName = `${serverHost}:${serverPort}`
|
||||||
|
|
||||||
|
const localHosts = ["127.0.0.1", "localhost"]
|
||||||
|
|
||||||
|
const serverLabels = (() => {
|
||||||
|
const url = new URL(serverUrl)
|
||||||
|
if (!localHosts.includes(url.hostname)) return [serverName]
|
||||||
|
return localHosts.map((host) => `${host}:${url.port}`)
|
||||||
|
})()
|
||||||
|
|
||||||
|
export const serverNames = [...new Set(serverLabels)]
|
||||||
|
|
||||||
|
export const serverUrls = serverNames.map((name) => `http://${name}`)
|
||||||
|
|
||||||
|
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||||
|
|
||||||
|
export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("|")})`)
|
||||||
|
|
||||||
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
||||||
export const terminalToggleKey = "Control+Backquote"
|
export const terminalToggleKey = "Control+Backquote"
|
||||||
|
|
||||||
@@ -14,6 +30,12 @@ export function createSdk(directory?: string) {
|
|||||||
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveDirectory(directory: string) {
|
||||||
|
return createSdk(directory)
|
||||||
|
.path.get()
|
||||||
|
.then((x) => x.data?.directory ?? directory)
|
||||||
|
}
|
||||||
|
|
||||||
export async function getWorktree() {
|
export async function getWorktree() {
|
||||||
const sdk = createSdk()
|
const sdk = createSdk()
|
||||||
const result = await sdk.path.get()
|
const result = await sdk.path.get()
|
||||||
@@ -33,3 +55,9 @@ export function dirPath(directory: string) {
|
|||||||
export function sessionPath(directory: string, sessionID?: string) {
|
export function sessionPath(directory: string, sessionID?: string) {
|
||||||
return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
|
return `${dirPath(directory)}/session${sessionID ? `/${sessionID}` : ""}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function workspacePersistKey(directory: string, key: string) {
|
||||||
|
const head = (directory.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||||
|
const sum = checksum(directory) ?? "0"
|
||||||
|
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.2.15",
|
"version": "1.2.24",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
"@thisbeyond/solid-dnd": "0.7.5",
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "catalog:",
|
"diff": "catalog:",
|
||||||
"fuzzysort": "catalog:",
|
"fuzzysort": "catalog:",
|
||||||
"ghostty-web": "0.4.0",
|
"ghostty-web": "github:anomalyco/ghostty-web#main",
|
||||||
"luxon": "catalog:",
|
"luxon": "catalog:",
|
||||||
"marked": "catalog:",
|
"marked": "catalog:",
|
||||||
"marked-shiki": "catalog:",
|
"marked-shiki": "catalog:",
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
;(function () {
|
;(function () {
|
||||||
var themeId = localStorage.getItem("opencode-theme-id")
|
var key = "opencode-theme-id"
|
||||||
if (!themeId) return
|
var themeId = localStorage.getItem(key) || "oc-2"
|
||||||
|
|
||||||
|
if (themeId === "oc-1") {
|
||||||
|
themeId = "oc-2"
|
||||||
|
localStorage.setItem(key, themeId)
|
||||||
|
localStorage.removeItem("opencode-theme-css-light")
|
||||||
|
localStorage.removeItem("opencode-theme-css-dark")
|
||||||
|
}
|
||||||
|
|
||||||
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
||||||
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
||||||
@@ -9,9 +16,9 @@
|
|||||||
document.documentElement.dataset.theme = themeId
|
document.documentElement.dataset.theme = themeId
|
||||||
document.documentElement.dataset.colorScheme = mode
|
document.documentElement.dataset.colorScheme = mode
|
||||||
|
|
||||||
if (themeId === "oc-1") return
|
if (themeId === "oc-2") return
|
||||||
|
|
||||||
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
|
var css = localStorage.getItem("opencode-theme-css-" + mode)
|
||||||
if (css) {
|
if (css) {
|
||||||
var style = document.createElement("style")
|
var style = document.createElement("style")
|
||||||
style.id = "oc-theme-preload"
|
style.id = "oc-theme-preload"
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ try {
|
|||||||
Object.assign(process.env, serverEnv)
|
Object.assign(process.env, serverEnv)
|
||||||
process.env.AGENT = "1"
|
process.env.AGENT = "1"
|
||||||
process.env.OPENCODE = "1"
|
process.env.OPENCODE = "1"
|
||||||
|
process.env.OPENCODE_PID = String(process.pid)
|
||||||
|
|
||||||
const log = await import("../../opencode/src/util/log")
|
const log = await import("../../opencode/src/util/log")
|
||||||
const install = await import("../../opencode/src/installation")
|
const install = await import("../../opencode/src/installation")
|
||||||
|
|||||||
+10
-11
@@ -1,16 +1,14 @@
|
|||||||
import "@/index.css"
|
import "@/index.css"
|
||||||
import { Code } from "@opencode-ai/ui/code"
|
import { File } from "@opencode-ai/ui/file"
|
||||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||||
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
|
|
||||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||||
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
|
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 { Diff } from "@opencode-ai/ui/diff"
|
|
||||||
import { Font } from "@opencode-ai/ui/font"
|
import { Font } from "@opencode-ai/ui/font"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
import { ThemeProvider } from "@opencode-ai/ui/theme"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { Navigate, Route, Router } from "@solidjs/router"
|
import { BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
|
||||||
import { ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
|
import { Component, ErrorBoundary, type JSX, lazy, type ParentProps, Show, Suspense } from "solid-js"
|
||||||
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"
|
||||||
@@ -30,6 +28,7 @@ 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"
|
||||||
|
|
||||||
const Home = lazy(() => import("@/pages/home"))
|
const Home = lazy(() => import("@/pages/home"))
|
||||||
const Session = lazy(() => import("@/pages/session"))
|
const Session = lazy(() => import("@/pages/session"))
|
||||||
@@ -122,9 +121,7 @@ export function AppBaseProviders(props: ParentProps) {
|
|||||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||||
<DialogProvider>
|
<DialogProvider>
|
||||||
<MarkedProviderWithNativeParser>
|
<MarkedProviderWithNativeParser>
|
||||||
<DiffComponentProvider component={Diff}>
|
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||||
<CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
|
|
||||||
</DiffComponentProvider>
|
|
||||||
</MarkedProviderWithNativeParser>
|
</MarkedProviderWithNativeParser>
|
||||||
</DialogProvider>
|
</DialogProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
@@ -148,13 +145,15 @@ export function AppInterface(props: {
|
|||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
defaultServer: ServerConnection.Key
|
defaultServer: ServerConnection.Key
|
||||||
servers?: Array<ServerConnection.Any>
|
servers?: Array<ServerConnection.Any>
|
||||||
|
router?: Component<BaseRouterProps>
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
|
||||||
<ServerKey>
|
<ServerKey>
|
||||||
<GlobalSDKProvider>
|
<GlobalSDKProvider>
|
||||||
<GlobalSyncProvider>
|
<GlobalSyncProvider>
|
||||||
<Router
|
<Dynamic
|
||||||
|
component={props.router ?? Router}
|
||||||
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
|
||||||
>
|
>
|
||||||
<Route path="/" component={HomeRoute} />
|
<Route path="/" component={HomeRoute} />
|
||||||
@@ -162,7 +161,7 @@ export function AppInterface(props: {
|
|||||||
<Route path="/" component={SessionIndexRoute} />
|
<Route path="/" component={SessionIndexRoute} />
|
||||||
<Route path="/session/:id?" component={SessionRoute} />
|
<Route path="/session/:id?" component={SessionRoute} />
|
||||||
</Route>
|
</Route>
|
||||||
</Router>
|
</Dynamic>
|
||||||
</GlobalSyncProvider>
|
</GlobalSyncProvider>
|
||||||
</GlobalSDKProvider>
|
</GlobalSDKProvider>
|
||||||
</ServerKey>
|
</ServerKey>
|
||||||
|
|||||||
@@ -0,0 +1,441 @@
|
|||||||
|
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"
|
||||||
|
|
||||||
|
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 "n/a"
|
||||||
|
return `${n.toFixed(d)}ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = (n?: number) => {
|
||||||
|
if (n === undefined || Number.isNaN(n)) return "n/a"
|
||||||
|
return `${Math.round(n)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const mb = (n?: number) => {
|
||||||
|
if (n === undefined || Number.isNaN(n)) return "n/a"
|
||||||
|
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] bg-white/5 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 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 heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined)
|
||||||
|
const heapv = () => {
|
||||||
|
const value = heap()
|
||||||
|
if (value === undefined) return "n/a"
|
||||||
|
return `${Math.round(value * 100)}%`
|
||||||
|
}
|
||||||
|
const longv = () => (state.long.count === undefined ? "n/a" : `${time(state.long.block)}/${state.long.count}`)
|
||||||
|
const navv = () => (state.nav.pending ? "..." : time(state.nav.dur))
|
||||||
|
|
||||||
|
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="Development performance diagnostics"
|
||||||
|
class="pointer-events-auto fixed bottom-3 right-3 z-50 w-[308px] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl border p-0.5 text-text-on-interactive-base shadow-[var(--shadow-lg-border-base)] sm:bottom-4 sm:right-4 sm:w-[324px]"
|
||||||
|
style={{
|
||||||
|
"background-color": "color-mix(in srgb, var(--icon-interactive-base) 42%, black)",
|
||||||
|
"border-color": "color-mix(in srgb, white 14%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-5 gap-px font-mono">
|
||||||
|
<Cell
|
||||||
|
label="NAV"
|
||||||
|
tip="Last completed route transition touching a session page, measured from router start until the first paint after it settles."
|
||||||
|
value={navv()}
|
||||||
|
bad={bad(state.nav.dur, 400)}
|
||||||
|
dim={state.nav.dur === undefined && !state.nav.pending}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="FPS"
|
||||||
|
tip="Rolling frames per second over the last 5 seconds."
|
||||||
|
value={state.fps === undefined ? "n/a" : `${Math.round(state.fps)}`}
|
||||||
|
bad={bad(state.fps, 50, true)}
|
||||||
|
dim={state.fps === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="FRM"
|
||||||
|
tip="Worst frame time over the last 5 seconds."
|
||||||
|
value={time(state.gap)}
|
||||||
|
bad={bad(state.gap, 50)}
|
||||||
|
dim={state.gap === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="JNK"
|
||||||
|
tip="Frames over 32ms in the last 5 seconds."
|
||||||
|
value={state.jank === undefined ? "n/a" : `${state.jank}`}
|
||||||
|
bad={bad(state.jank, 8)}
|
||||||
|
dim={state.jank === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="LNG"
|
||||||
|
tip={`Blocked time and long-task count in the last 5 seconds. Max task: ${ms(state.long.max)}.`}
|
||||||
|
value={longv()}
|
||||||
|
bad={bad(state.long.block, 200)}
|
||||||
|
dim={state.long.count === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="DLY"
|
||||||
|
tip="Worst observed input delay in the last 5 seconds."
|
||||||
|
value={time(state.delay)}
|
||||||
|
bad={bad(state.delay, 100)}
|
||||||
|
dim={state.delay === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="INP"
|
||||||
|
tip="Approximate interaction duration over the last 5 seconds. This is INP-like, not the official Web Vitals INP."
|
||||||
|
value={time(state.inp)}
|
||||||
|
bad={bad(state.inp, 200)}
|
||||||
|
dim={state.inp === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="CLS"
|
||||||
|
tip="Cumulative layout shift for the current app lifetime."
|
||||||
|
value={state.cls === undefined ? "n/a" : state.cls.toFixed(2)}
|
||||||
|
bad={bad(state.cls, 0.1)}
|
||||||
|
dim={state.cls === undefined}
|
||||||
|
/>
|
||||||
|
<Cell
|
||||||
|
label="MEM"
|
||||||
|
tip={
|
||||||
|
state.heap.used === undefined
|
||||||
|
? "Used JS heap vs heap limit. Chromium only."
|
||||||
|
: `Used JS heap vs heap limit. ${mb(state.heap.used)} of ${mb(state.heap.limit)}.`
|
||||||
|
}
|
||||||
|
value={heapv()}
|
||||||
|
bad={bad(heap(), 0.8)}
|
||||||
|
dim={state.heap.used === undefined}
|
||||||
|
wide
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
|||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
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 type { IconName } from "@opencode-ai/ui/icons/provider"
|
|
||||||
import { List, type ListRef } from "@opencode-ai/ui/list"
|
import { List, type ListRef } from "@opencode-ai/ui/list"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
@@ -447,7 +446,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
|||||||
>
|
>
|
||||||
<div class="flex flex-col gap-6 px-2.5 pb-3">
|
<div class="flex flex-col gap-6 px-2.5 pb-3">
|
||||||
<div class="px-2.5 flex gap-4 items-center">
|
<div class="px-2.5 flex gap-4 items-center">
|
||||||
<ProviderIcon id={props.provider as IconName} class="size-5 shrink-0 icon-strong-base" />
|
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
|
||||||
<div class="text-16-medium text-text-strong">
|
<div class="text-16-medium text-text-strong">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createSignal } from "solid-js"
|
|||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
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 { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
|
|
||||||
export type Highlight = {
|
export type Highlight = {
|
||||||
@@ -16,6 +17,7 @@ export type Highlight = {
|
|||||||
|
|
||||||
export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const [index, setIndex] = createSignal(0)
|
const [index, setIndex] = createSignal(0)
|
||||||
|
|
||||||
@@ -83,16 +85,16 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
|||||||
<div class="flex flex-col items-start gap-3">
|
<div class="flex flex-col items-start gap-3">
|
||||||
{isLast() ? (
|
{isLast() ? (
|
||||||
<Button variant="primary" size="large" onClick={handleClose}>
|
<Button variant="primary" size="large" onClick={handleClose}>
|
||||||
Get started
|
{language.t("dialog.releaseNotes.action.getStarted")}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="secondary" size="large" onClick={handleNext}>
|
<Button variant="secondary" size="large" onClick={handleNext}>
|
||||||
Next
|
{language.t("dialog.releaseNotes.action.next")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button variant="ghost" size="small" onClick={handleDisable}>
|
<Button variant="ghost" size="small" onClick={handleDisable}>
|
||||||
Don't show these in the future
|
{language.t("dialog.releaseNotes.action.hideFuture")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -128,7 +130,7 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
|
|||||||
{feature()!.media!.type === "image" ? (
|
{feature()!.media!.type === "image" ? (
|
||||||
<img
|
<img
|
||||||
src={feature()!.media!.src}
|
src={feature()!.media!.src}
|
||||||
alt={feature()!.media!.alt ?? feature()?.title ?? "Release preview"}
|
alt={feature()!.media!.alt ?? feature()?.title ?? language.t("dialog.releaseNotes.media.alt")}
|
||||||
class="w-full h-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import fuzzysort from "fuzzysort"
|
|||||||
import { createMemo, createResource, createSignal } from "solid-js"
|
import { createMemo, createResource, createSignal } from "solid-js"
|
||||||
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 { useLayout } from "@/context/layout"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
interface DialogSelectDirectoryProps {
|
interface DialogSelectDirectoryProps {
|
||||||
@@ -19,6 +20,7 @@ interface DialogSelectDirectoryProps {
|
|||||||
type Row = {
|
type Row = {
|
||||||
absolute: string
|
absolute: string
|
||||||
search: string
|
search: string
|
||||||
|
group: "recent" | "folders"
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanInput(value: string) {
|
function cleanInput(value: string) {
|
||||||
@@ -101,7 +103,7 @@ function displayPath(path: string, input: string, home: string) {
|
|||||||
return tildeOf(full, home) || full
|
return tildeOf(full, home) || full
|
||||||
}
|
}
|
||||||
|
|
||||||
function toRow(absolute: string, home: string): Row {
|
function toRow(absolute: string, home: string, group: Row["group"]): Row {
|
||||||
const full = trimTrailing(absolute)
|
const full = trimTrailing(absolute)
|
||||||
const tilde = tildeOf(full, home)
|
const tilde = tildeOf(full, home)
|
||||||
const withSlash = (value: string) => {
|
const withSlash = (value: string) => {
|
||||||
@@ -113,7 +115,16 @@ function toRow(absolute: string, home: string): Row {
|
|||||||
const search = Array.from(
|
const search = Array.from(
|
||||||
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
|
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
|
||||||
).join("\n")
|
).join("\n")
|
||||||
return { absolute: full, search }
|
return { absolute: full, search, group }
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueRows(rows: Row[]) {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
return rows.filter((row) => {
|
||||||
|
if (seen.has(row.absolute)) return false
|
||||||
|
seen.add(row.absolute)
|
||||||
|
return true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function useDirectorySearch(args: {
|
function useDirectorySearch(args: {
|
||||||
@@ -237,6 +248,7 @@ function useDirectorySearch(args: {
|
|||||||
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
||||||
const sync = useGlobalSync()
|
const sync = useGlobalSync()
|
||||||
const sdk = useGlobalSDK()
|
const sdk = useGlobalSDK()
|
||||||
|
const layout = useLayout()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
@@ -266,9 +278,42 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
start,
|
start,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const recentProjects = createMemo(() => {
|
||||||
|
const projects = layout.projects.list()
|
||||||
|
const byProject = new Map<string, number>()
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
let at = 0
|
||||||
|
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
||||||
|
for (const directory of dirs) {
|
||||||
|
const sessions = sync.child(directory, { bootstrap: false })[0].session
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (session.time.archived) continue
|
||||||
|
const updated = session.time.updated ?? session.time.created
|
||||||
|
if (updated > at) at = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
byProject.set(project.worktree, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
return projects
|
||||||
|
.map((project, index) => ({ project, at: byProject.get(project.worktree) ?? 0, index }))
|
||||||
|
.sort((a, b) => b.at - a.at || a.index - b.index)
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(({ project }) => {
|
||||||
|
const row = toRow(project.worktree, home(), "recent")
|
||||||
|
const name = project.name || getFilename(project.worktree)
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
search: `${row.search}\n${name}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const items = async (value: string) => {
|
const items = async (value: string) => {
|
||||||
const results = await directories(value)
|
const results = await directories(value)
|
||||||
return results.map((absolute) => toRow(absolute, home()))
|
const directoryRows = results.map((absolute) => toRow(absolute, home(), "folders"))
|
||||||
|
return uniqueRows([...recentProjects(), ...directoryRows])
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolve(absolute: string) {
|
function resolve(absolute: string) {
|
||||||
@@ -285,6 +330,14 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
|
|||||||
items={items}
|
items={items}
|
||||||
key={(x) => x.absolute}
|
key={(x) => x.absolute}
|
||||||
filterKeys={["search"]}
|
filterKeys={["search"]}
|
||||||
|
groupBy={(item) => item.group}
|
||||||
|
sortGroupsBy={(a, b) => {
|
||||||
|
if (a.category === b.category) return 0
|
||||||
|
return a.category === "recent" ? -1 : 1
|
||||||
|
}}
|
||||||
|
groupHeader={(group) =>
|
||||||
|
group.category === "recent" ? language.t("home.recentProjects") : language.t("command.project.open")
|
||||||
|
}
|
||||||
ref={(r) => (list = r)}
|
ref={(r) => (list = r)}
|
||||||
onFilter={(value) => setFilter(cleanInput(value))}
|
onFilter={(value) => setFilter(cleanInput(value))}
|
||||||
onKeyEvent={(e, item) => {
|
onKeyEvent={(e, item) => {
|
||||||
|
|||||||
@@ -449,7 +449,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
|||||||
</div>
|
</div>
|
||||||
<Show when={item.updated}>
|
<Show when={item.updated}>
|
||||||
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
|
<span class="text-12-regular text-text-weak whitespace-nowrap ml-2">
|
||||||
{getRelativeTime(new Date(item.updated!).toISOString())}
|
{getRelativeTime(new Date(item.updated!).toISOString(), language.t)}
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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"
|
||||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
|
||||||
import { List, type ListRef } from "@opencode-ai/ui/list"
|
import { List, type ListRef } from "@opencode-ai/ui/list"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
@@ -95,11 +94,22 @@ export const DialogSelectModelUnpaid: Component = () => {
|
|||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="w-full flex items-center gap-x-3">
|
<div class="w-full flex items-center gap-x-3">
|
||||||
<ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
|
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
|
||||||
<span>{i.name}</span>
|
<span>{i.name}</span>
|
||||||
|
<Show when={i.id === "opencode"}>
|
||||||
|
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
|
||||||
|
</Show>
|
||||||
<Show when={i.id === "opencode"}>
|
<Show when={i.id === "opencode"}>
|
||||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
|
<Show when={i.id === "opencode-go"}>
|
||||||
|
<>
|
||||||
|
<div class="text-14-regular text-text-weak">
|
||||||
|
{language.t("dialog.provider.opencodeGo.tagline")}
|
||||||
|
</div>
|
||||||
|
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||||
|
</>
|
||||||
|
</Show>
|
||||||
<Show when={i.id === "anthropic"}>
|
<Show when={i.id === "anthropic"}>
|
||||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
|
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -5,18 +5,12 @@ import { Dialog } from "@opencode-ai/ui/dialog"
|
|||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Tag } from "@opencode-ai/ui/tag"
|
import { Tag } from "@opencode-ai/ui/tag"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import { iconNames, type IconName } from "@opencode-ai/ui/icons/provider"
|
|
||||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||||
|
|
||||||
const CUSTOM_ID = "_custom"
|
const CUSTOM_ID = "_custom"
|
||||||
|
|
||||||
function icon(id: string): IconName {
|
|
||||||
if (iconNames.includes(id as IconName)) return id as IconName
|
|
||||||
return "synthetic"
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DialogSelectProvider: Component = () => {
|
export const DialogSelectProvider: Component = () => {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const providers = useProviders()
|
const providers = useProviders()
|
||||||
@@ -29,6 +23,7 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
|
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
|
||||||
if (id === "openai") return language.t("dialog.provider.openai.note")
|
if (id === "openai") return language.t("dialog.provider.openai.note")
|
||||||
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
|
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
|
||||||
|
if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline")
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -68,8 +63,11 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
>
|
>
|
||||||
{(i) => (
|
{(i) => (
|
||||||
<div class="px-1.25 w-full flex items-center gap-x-3">
|
<div class="px-1.25 w-full flex items-center gap-x-3">
|
||||||
<ProviderIcon data-slot="list-item-extra-icon" id={icon(i.id)} />
|
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
|
||||||
<span>{i.name}</span>
|
<span>{i.name}</span>
|
||||||
|
<Show when={i.id === "opencode"}>
|
||||||
|
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
|
||||||
|
</Show>
|
||||||
<Show when={i.id === CUSTOM_ID}>
|
<Show when={i.id === CUSTOM_ID}>
|
||||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -77,6 +75,9 @@ export const DialogSelectProvider: Component = () => {
|
|||||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
|
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
|
||||||
|
<Show when={i.id === "opencode-go"}>
|
||||||
|
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</List>
|
</List>
|
||||||
|
|||||||
@@ -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 { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
|
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"
|
||||||
@@ -9,32 +10,27 @@ 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"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { ServerRow } from "@/components/server/server-row"
|
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 { checkServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
interface AddRowProps {
|
interface ServerFormProps {
|
||||||
value: string
|
|
||||||
placeholder: string
|
|
||||||
adding: boolean
|
|
||||||
error: string
|
|
||||||
status: boolean | undefined
|
|
||||||
onChange: (value: string) => void
|
|
||||||
onKeyDown: (event: KeyboardEvent) => void
|
|
||||||
onBlur: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EditRowProps {
|
|
||||||
value: string
|
value: string
|
||||||
|
name: string
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
placeholder: string
|
placeholder: string
|
||||||
busy: boolean
|
busy: boolean
|
||||||
error: string
|
error: string
|
||||||
status: boolean | undefined
|
status: boolean | undefined
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
onKeyDown: (event: KeyboardEvent) => void
|
onNameChange: (value: string) => void
|
||||||
onBlur: () => void
|
onUsernameChange: (value: string) => void
|
||||||
|
onPasswordChange: (value: string) => void
|
||||||
|
onSubmit: () => void
|
||||||
|
onBack: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
|
||||||
@@ -83,83 +79,86 @@ function useServerPreview(fetcher: typeof fetch) {
|
|||||||
return host.includes(".") || host.includes(":")
|
return host.includes(".") || host.includes(":")
|
||||||
}
|
}
|
||||||
|
|
||||||
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
|
const previewStatus = async (
|
||||||
|
value: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
setStatus: (value: boolean | undefined) => void,
|
||||||
|
) => {
|
||||||
setStatus(undefined)
|
setStatus(undefined)
|
||||||
if (!looksComplete(value)) return
|
if (!looksComplete(value)) return
|
||||||
const normalized = normalizeServerUrl(value)
|
const normalized = normalizeServerUrl(value)
|
||||||
if (!normalized) return
|
if (!normalized) return
|
||||||
const result = await checkServerHealth({ url: normalized }, fetcher)
|
const http: ServerConnection.HttpBase = { url: normalized }
|
||||||
|
if (username) http.username = username
|
||||||
|
if (password) http.password = password
|
||||||
|
const result = await checkServerHealth(http, fetcher)
|
||||||
setStatus(result.healthy)
|
setStatus(result.healthy)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { previewStatus }
|
return { previewStatus }
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddRow(props: AddRowProps) {
|
function ServerForm(props: ServerFormProps) {
|
||||||
return (
|
const language = useLanguage()
|
||||||
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
|
const keyDown = (event: KeyboardEvent) => {
|
||||||
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
|
event.stopPropagation()
|
||||||
<div
|
if (event.key === "Escape") {
|
||||||
classList={{
|
event.preventDefault()
|
||||||
"size-1.5 rounded-full absolute left-3 top-1/2 -translate-y-1/2 z-10 pointer-events-none": true,
|
props.onBack()
|
||||||
"bg-icon-success-base": props.status === true,
|
return
|
||||||
"bg-icon-critical-base": props.status === false,
|
}
|
||||||
"bg-border-weak-base": props.status === undefined,
|
if (event.key !== "Enter" || event.isComposing) return
|
||||||
}}
|
event.preventDefault()
|
||||||
ref={(el) => {
|
props.onSubmit()
|
||||||
// Position relative to input-wrapper
|
}
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const wrapper = el.parentElement?.querySelector('[data-slot="input-wrapper"]')
|
|
||||||
if (wrapper instanceof HTMLElement) {
|
|
||||||
wrapper.appendChild(el)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
type="text"
|
|
||||||
hideLabel
|
|
||||||
placeholder={props.placeholder}
|
|
||||||
value={props.value}
|
|
||||||
autofocus
|
|
||||||
validationState={props.error ? "invalid" : "valid"}
|
|
||||||
error={props.error}
|
|
||||||
disabled={props.adding}
|
|
||||||
onChange={props.onChange}
|
|
||||||
onKeyDown={props.onKeyDown}
|
|
||||||
onBlur={props.onBlur}
|
|
||||||
class="pl-7"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EditRow(props: EditRowProps) {
|
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-3 px-4 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}>
|
<div class="px-5">
|
||||||
<div
|
<div class="bg-surface-raised-base rounded-md p-5 flex flex-col gap-3">
|
||||||
classList={{
|
<div class="flex-1 min-w-0 [&_[data-slot=input-wrapper]]:relative">
|
||||||
"size-1.5 rounded-full shrink-0": true,
|
<TextField
|
||||||
"bg-icon-success-base": props.status === true,
|
type="text"
|
||||||
"bg-icon-critical-base": props.status === false,
|
label={language.t("dialog.server.add.url")}
|
||||||
"bg-border-weak-base": props.status === undefined,
|
placeholder={props.placeholder}
|
||||||
}}
|
value={props.value}
|
||||||
/>
|
autofocus
|
||||||
<div class="flex-1 min-w-0">
|
validationState={props.error ? "invalid" : "valid"}
|
||||||
|
error={props.error}
|
||||||
|
disabled={props.busy}
|
||||||
|
onChange={props.onChange}
|
||||||
|
onKeyDown={keyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<TextField
|
<TextField
|
||||||
type="text"
|
type="text"
|
||||||
hideLabel
|
label={language.t("dialog.server.add.name")}
|
||||||
placeholder={props.placeholder}
|
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||||
value={props.value}
|
value={props.name}
|
||||||
autofocus
|
|
||||||
validationState={props.error ? "invalid" : "valid"}
|
|
||||||
error={props.error}
|
|
||||||
disabled={props.busy}
|
disabled={props.busy}
|
||||||
onChange={props.onChange}
|
onChange={props.onNameChange}
|
||||||
onKeyDown={props.onKeyDown}
|
onKeyDown={keyDown}
|
||||||
onBlur={props.onBlur}
|
|
||||||
/>
|
/>
|
||||||
|
<div class="grid grid-cols-2 gap-2 min-w-0">
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
label={language.t("dialog.server.add.username")}
|
||||||
|
placeholder="username"
|
||||||
|
value={props.username}
|
||||||
|
disabled={props.busy}
|
||||||
|
onChange={props.onUsernameChange}
|
||||||
|
onKeyDown={keyDown}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
type="password"
|
||||||
|
label={language.t("dialog.server.add.password")}
|
||||||
|
placeholder="password"
|
||||||
|
value={props.password}
|
||||||
|
disabled={props.busy}
|
||||||
|
onChange={props.onPasswordChange}
|
||||||
|
onKeyDown={keyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -174,11 +173,13 @@ export function DialogSelectServer() {
|
|||||||
const fetcher = platform.fetch ?? globalThis.fetch
|
const fetcher = platform.fetch ?? globalThis.fetch
|
||||||
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
|
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
|
||||||
const { previewStatus } = useServerPreview(fetcher)
|
const { previewStatus } = useServerPreview(fetcher)
|
||||||
let listRoot: HTMLDivElement | undefined
|
|
||||||
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: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
adding: false,
|
adding: false,
|
||||||
error: "",
|
error: "",
|
||||||
showForm: false,
|
showForm: false,
|
||||||
@@ -187,6 +188,9 @@ export function DialogSelectServer() {
|
|||||||
editServer: {
|
editServer: {
|
||||||
id: undefined as string | undefined,
|
id: undefined as string | undefined,
|
||||||
value: "",
|
value: "",
|
||||||
|
name: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
busy: false,
|
busy: false,
|
||||||
status: undefined as boolean | undefined,
|
status: undefined as boolean | undefined,
|
||||||
@@ -196,27 +200,32 @@ export function DialogSelectServer() {
|
|||||||
const resetAdd = () => {
|
const resetAdd = () => {
|
||||||
setStore("addServer", {
|
setStore("addServer", {
|
||||||
url: "",
|
url: "",
|
||||||
|
name: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
adding: false,
|
||||||
error: "",
|
error: "",
|
||||||
showForm: false,
|
showForm: false,
|
||||||
status: undefined,
|
status: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetEdit = () => {
|
const resetEdit = () => {
|
||||||
setStore("editServer", {
|
setStore("editServer", {
|
||||||
id: undefined,
|
id: undefined,
|
||||||
value: "",
|
value: "",
|
||||||
|
name: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
error: "",
|
error: "",
|
||||||
status: undefined,
|
status: undefined,
|
||||||
busy: false,
|
busy: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const replaceServer = (original: ServerConnection.Http, next: string) => {
|
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)
|
||||||
if (!newConn) return
|
if (!newConn) return
|
||||||
|
|
||||||
const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
|
const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active
|
||||||
if (nextActive) server.setActive(nextActive)
|
if (nextActive) server.setActive(nextActive)
|
||||||
server.remove(ServerConnection.key(original))
|
server.remove(ServerConnection.key(original))
|
||||||
@@ -271,8 +280,8 @@ export function DialogSelectServer() {
|
|||||||
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
||||||
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
|
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
|
||||||
dialog.close()
|
dialog.close()
|
||||||
if (persist) {
|
if (persist && conn.type === "http") {
|
||||||
server.add(conn.http.url)
|
server.add(conn)
|
||||||
navigate("/")
|
navigate("/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -283,21 +292,59 @@ export function DialogSelectServer() {
|
|||||||
const handleAddChange = (value: string) => {
|
const handleAddChange = (value: string) => {
|
||||||
if (store.addServer.adding) return
|
if (store.addServer.adding) return
|
||||||
setStore("addServer", { url: value, error: "" })
|
setStore("addServer", { url: value, error: "" })
|
||||||
void previewStatus(value, (next) => setStore("addServer", { status: next }))
|
void previewStatus(value, store.addServer.username, store.addServer.password, (next) =>
|
||||||
|
setStore("addServer", { status: next }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollListToBottom = () => {
|
const handleAddNameChange = (value: string) => {
|
||||||
const scroll = listRoot?.querySelector<HTMLDivElement>('[data-slot="list-scroll"]')
|
if (store.addServer.adding) return
|
||||||
if (!scroll) return
|
setStore("addServer", { name: value, error: "" })
|
||||||
requestAnimationFrame(() => {
|
}
|
||||||
scroll.scrollTop = scroll.scrollHeight
|
|
||||||
})
|
const handleAddUsernameChange = (value: string) => {
|
||||||
|
if (store.addServer.adding) return
|
||||||
|
setStore("addServer", { username: value, error: "" })
|
||||||
|
void previewStatus(store.addServer.url, value, store.addServer.password, (next) =>
|
||||||
|
setStore("addServer", { status: next }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddPasswordChange = (value: string) => {
|
||||||
|
if (store.addServer.adding) return
|
||||||
|
setStore("addServer", { password: value, error: "" })
|
||||||
|
void previewStatus(store.addServer.url, store.addServer.username, value, (next) =>
|
||||||
|
setStore("addServer", { status: next }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditChange = (value: string) => {
|
const handleEditChange = (value: string) => {
|
||||||
if (store.editServer.busy) return
|
if (store.editServer.busy) return
|
||||||
setStore("editServer", { value, error: "" })
|
setStore("editServer", { value, error: "" })
|
||||||
void previewStatus(value, (next) => setStore("editServer", { status: next }))
|
void previewStatus(value, store.editServer.username, store.editServer.password, (next) =>
|
||||||
|
setStore("editServer", { status: next }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditNameChange = (value: string) => {
|
||||||
|
if (store.editServer.busy) return
|
||||||
|
setStore("editServer", { name: value, error: "" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditUsernameChange = (value: string) => {
|
||||||
|
if (store.editServer.busy) return
|
||||||
|
setStore("editServer", { username: value, error: "" })
|
||||||
|
void previewStatus(store.editServer.value, value, store.editServer.password, (next) =>
|
||||||
|
setStore("editServer", { status: next }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEditPasswordChange = (value: string) => {
|
||||||
|
if (store.editServer.busy) return
|
||||||
|
setStore("editServer", { password: value, error: "" })
|
||||||
|
void previewStatus(store.editServer.value, store.editServer.username, value, (next) =>
|
||||||
|
setStore("editServer", { status: next }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAdd(value: string) {
|
async function handleAdd(value: string) {
|
||||||
@@ -310,16 +357,22 @@ export function DialogSelectServer() {
|
|||||||
|
|
||||||
setStore("addServer", { adding: true, error: "" })
|
setStore("addServer", { adding: true, error: "" })
|
||||||
|
|
||||||
const result = await checkServerHealth({ url: normalized }, fetcher)
|
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 })
|
setStore("addServer", { adding: false })
|
||||||
|
|
||||||
if (!result.healthy) {
|
if (!result.healthy) {
|
||||||
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
setStore("addServer", { error: language.t("dialog.server.add.error") })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resetAdd()
|
resetAdd()
|
||||||
await select({ type: "http", http: { url: normalized } }, true)
|
await select(conn, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEdit(original: ServerConnection.Any, value: string) {
|
async function handleEdit(original: ServerConnection.Any, value: string) {
|
||||||
@@ -330,53 +383,115 @@ export function DialogSelectServer() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalized === original.http.url) {
|
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()
|
resetEdit()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setStore("editServer", { busy: true, error: "" })
|
setStore("editServer", { busy: true, error: "" })
|
||||||
|
|
||||||
const result = await checkServerHealth({ url: normalized }, fetcher)
|
const conn: ServerConnection.Http = {
|
||||||
|
type: "http",
|
||||||
|
displayName: name,
|
||||||
|
http: { url: normalized, username, password },
|
||||||
|
}
|
||||||
|
const result = await checkServerHealth(conn.http, fetcher)
|
||||||
setStore("editServer", { busy: false })
|
setStore("editServer", { busy: false })
|
||||||
|
|
||||||
if (!result.healthy) {
|
if (!result.healthy) {
|
||||||
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
setStore("editServer", { error: language.t("dialog.server.add.error") })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (normalized === original.http.url) {
|
||||||
replaceServer(original, normalized)
|
server.add(conn)
|
||||||
|
} else {
|
||||||
|
replaceServer(original, conn)
|
||||||
|
}
|
||||||
|
|
||||||
resetEdit()
|
resetEdit()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddKey = (event: KeyboardEvent) => {
|
const mode = createMemo<"list" | "add" | "edit">(() => {
|
||||||
event.stopPropagation()
|
if (store.editServer.id) return "edit"
|
||||||
if (event.key !== "Enter" || event.isComposing) return
|
if (store.addServer.showForm) return "add"
|
||||||
event.preventDefault()
|
return "list"
|
||||||
handleAdd(store.addServer.url)
|
})
|
||||||
|
|
||||||
|
const editing = createMemo(() => {
|
||||||
|
if (!store.editServer.id) return
|
||||||
|
return items().find((x) => x.type === "http" && x.http.url === store.editServer.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
resetAdd()
|
||||||
|
resetEdit()
|
||||||
}
|
}
|
||||||
|
|
||||||
const blurAdd = () => {
|
const startAdd = () => {
|
||||||
if (!store.addServer.url.trim()) {
|
resetEdit()
|
||||||
resetAdd()
|
setStore("addServer", {
|
||||||
return
|
showForm: true,
|
||||||
}
|
url: "",
|
||||||
handleAdd(store.addServer.url)
|
name: "",
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
error: "",
|
||||||
|
status: undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => {
|
const startEdit = (conn: ServerConnection.Http) => {
|
||||||
event.stopPropagation()
|
resetAdd()
|
||||||
if (event.key === "Escape") {
|
setStore("editServer", {
|
||||||
event.preventDefault()
|
id: conn.http.url,
|
||||||
resetEdit()
|
value: conn.http.url,
|
||||||
|
name: conn.displayName ?? "",
|
||||||
|
username: conn.http.username ?? "",
|
||||||
|
password: conn.http.password ?? "",
|
||||||
|
error: "",
|
||||||
|
status: store.status[ServerConnection.key(conn)]?.healthy,
|
||||||
|
busy: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitForm = () => {
|
||||||
|
if (mode() === "add") {
|
||||||
|
void handleAdd(store.addServer.url)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.key !== "Enter" || event.isComposing) return
|
const original = editing()
|
||||||
event.preventDefault()
|
if (!original) return
|
||||||
handleEdit(original, store.editServer.value)
|
void handleEdit(original, store.editServer.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isFormMode = createMemo(() => mode() !== "list")
|
||||||
|
const isAddMode = createMemo(() => mode() === "add")
|
||||||
|
const formBusy = createMemo(() => (isAddMode() ? store.addServer.adding : store.editServer.busy))
|
||||||
|
|
||||||
|
const formTitle = createMemo(() => {
|
||||||
|
if (!isFormMode()) return language.t("dialog.server.title")
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-2 -ml-2">
|
||||||
|
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
|
||||||
|
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!store.editServer.id) return
|
||||||
|
if (editing()) return
|
||||||
|
resetEdit()
|
||||||
|
})
|
||||||
|
|
||||||
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.getDefaultServerUrl?.()) === url) {
|
||||||
@@ -385,9 +500,29 @@ export function DialogSelectServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog title={language.t("dialog.server.title")}>
|
<Dialog title={formTitle()}>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div ref={(el) => (listRoot = el)}>
|
<Show
|
||||||
|
when={!isFormMode()}
|
||||||
|
fallback={
|
||||||
|
<ServerForm
|
||||||
|
value={isAddMode() ? store.addServer.url : store.editServer.value}
|
||||||
|
name={isAddMode() ? store.addServer.name : store.editServer.name}
|
||||||
|
username={isAddMode() ? store.addServer.username : store.editServer.username}
|
||||||
|
password={isAddMode() ? store.addServer.password : store.editServer.password}
|
||||||
|
placeholder={language.t("dialog.server.add.placeholder")}
|
||||||
|
busy={formBusy()}
|
||||||
|
error={isAddMode() ? store.addServer.error : store.editServer.error}
|
||||||
|
status={isAddMode() ? store.addServer.status : store.editServer.status}
|
||||||
|
onChange={isAddMode() ? handleAddChange : handleEditChange}
|
||||||
|
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
|
||||||
|
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
|
||||||
|
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
|
||||||
|
onSubmit={submitForm}
|
||||||
|
onBack={resetForm}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
<List
|
<List
|
||||||
search={{
|
search={{
|
||||||
placeholder: language.t("dialog.server.search.placeholder"),
|
placeholder: language.t("dialog.server.search.placeholder"),
|
||||||
@@ -400,143 +535,110 @@ export function DialogSelectServer() {
|
|||||||
onSelect={(x) => {
|
onSelect={(x) => {
|
||||||
if (x) select(x)
|
if (x) select(x)
|
||||||
}}
|
}}
|
||||||
onFilter={(value) => {
|
|
||||||
if (value && store.addServer.showForm && !store.addServer.adding) {
|
|
||||||
resetAdd()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
divider={true}
|
divider={true}
|
||||||
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-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]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
|
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"
|
||||||
add={
|
|
||||||
store.addServer.showForm
|
|
||||||
? {
|
|
||||||
render: () => (
|
|
||||||
<AddRow
|
|
||||||
value={store.addServer.url}
|
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
|
||||||
adding={store.addServer.adding}
|
|
||||||
error={store.addServer.error}
|
|
||||||
status={store.addServer.status}
|
|
||||||
onChange={handleAddChange}
|
|
||||||
onKeyDown={handleAddKey}
|
|
||||||
onBlur={blurAdd}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
const key = ServerConnection.key(i)
|
const key = ServerConnection.key(i)
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
|
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
||||||
<Show
|
<div class="flex flex-col h-full items-start w-5">
|
||||||
when={store.editServer.id !== i.http.url}
|
<ServerHealthIndicator health={store.status[key]} />
|
||||||
fallback={
|
</div>
|
||||||
<EditRow
|
<ServerRow
|
||||||
value={store.editServer.value}
|
conn={i}
|
||||||
placeholder={language.t("dialog.server.add.placeholder")}
|
dimmed={store.status[key]?.healthy === false}
|
||||||
busy={store.editServer.busy}
|
status={store.status[key]}
|
||||||
error={store.editServer.error}
|
class="flex items-center gap-3 min-w-0 flex-1"
|
||||||
status={store.editServer.status}
|
badge={
|
||||||
onChange={handleEditChange}
|
<Show when={defaultUrl() === i.http.url}>
|
||||||
onKeyDown={(event) => handleEditKey(event, i)}
|
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
|
||||||
onBlur={() => handleEdit(i, store.editServer.value)}
|
{language.t("dialog.server.status.default")}
|
||||||
/>
|
</span>
|
||||||
}
|
|
||||||
>
|
|
||||||
<ServerRow
|
|
||||||
conn={i}
|
|
||||||
status={store.status[key]}
|
|
||||||
dimmed={store.status[key]?.healthy === false}
|
|
||||||
class="flex items-center gap-3 px-4 min-w-0 flex-1"
|
|
||||||
badge={
|
|
||||||
<Show when={defaultUrl() === i.http.url}>
|
|
||||||
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
|
|
||||||
{language.t("dialog.server.status.default")}
|
|
||||||
</span>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<Show when={store.editServer.id !== i.http.url}>
|
|
||||||
<div class="flex items-center justify-center gap-5 pl-4">
|
|
||||||
<Show when={ServerConnection.key(current()) === key}>
|
|
||||||
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
|
|
||||||
</Show>
|
</Show>
|
||||||
|
}
|
||||||
|
showCredentials
|
||||||
|
/>
|
||||||
|
<div class="flex items-center justify-center gap-4 pl-4">
|
||||||
|
<Show when={ServerConnection.key(current()) === key}>
|
||||||
|
<Icon name="check" class="h-6" />
|
||||||
|
</Show>
|
||||||
|
|
||||||
<Show when={i.type === "http"}>
|
<Show when={i.type === "http"}>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu.Trigger
|
||||||
as={IconButton}
|
as={IconButton}
|
||||||
icon="dot-grid"
|
icon="dot-grid"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
|
||||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
onClick={(e: MouseEvent) => e.stopPropagation()}
|
||||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
<DropdownMenu.Portal>
|
<DropdownMenu.Portal>
|
||||||
<DropdownMenu.Content class="mt-1">
|
<DropdownMenu.Content class="mt-1">
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setStore("editServer", {
|
if (i.type !== "http") return
|
||||||
id: i.http.url,
|
startEdit(i)
|
||||||
value: i.http.url,
|
}}
|
||||||
error: "",
|
>
|
||||||
status: store.status[ServerConnection.key(i)]?.healthy,
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||||
})
|
</DropdownMenu.Item>
|
||||||
}}
|
<Show when={canDefault() && defaultUrl() !== i.http.url}>
|
||||||
>
|
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
|
||||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<Show when={canDefault() && defaultUrl() !== i.http.url}>
|
|
||||||
<DropdownMenu.Item onSelect={() => setDefault(i.http.url)}>
|
|
||||||
<DropdownMenu.ItemLabel>
|
|
||||||
{language.t("dialog.server.menu.default")}
|
|
||||||
</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</Show>
|
|
||||||
<Show when={canDefault() && defaultUrl() === i.http.url}>
|
|
||||||
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
|
||||||
<DropdownMenu.ItemLabel>
|
|
||||||
{language.t("dialog.server.menu.defaultRemove")}
|
|
||||||
</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</Show>
|
|
||||||
<DropdownMenu.Separator />
|
|
||||||
<DropdownMenu.Item
|
|
||||||
onSelect={() => handleRemove(ServerConnection.key(i))}
|
|
||||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>
|
||||||
{language.t("dialog.server.menu.delete")}
|
{language.t("dialog.server.menu.default")}
|
||||||
</DropdownMenu.ItemLabel>
|
</DropdownMenu.ItemLabel>
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</DropdownMenu.Content>
|
</Show>
|
||||||
</DropdownMenu.Portal>
|
<Show when={canDefault() && defaultUrl() === i.http.url}>
|
||||||
</DropdownMenu>
|
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||||
</Show>
|
<DropdownMenu.ItemLabel>
|
||||||
</div>
|
{language.t("dialog.server.menu.defaultRemove")}
|
||||||
</Show>
|
</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</Show>
|
||||||
|
<DropdownMenu.Separator />
|
||||||
|
<DropdownMenu.Item
|
||||||
|
onSelect={() => handleRemove(ServerConnection.key(i))}
|
||||||
|
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||||
|
>
|
||||||
|
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</List>
|
</List>
|
||||||
</div>
|
</Show>
|
||||||
|
|
||||||
<div class="px-5 pb-5">
|
<div class="px-5 pb-5">
|
||||||
<Button
|
<Show
|
||||||
variant="secondary"
|
when={isFormMode()}
|
||||||
icon="plus-small"
|
fallback={
|
||||||
size="large"
|
<Button
|
||||||
onClick={() => {
|
variant="secondary"
|
||||||
setStore("addServer", { showForm: true, url: "", error: "" })
|
icon="plus-small"
|
||||||
scrollListToBottom()
|
size="large"
|
||||||
}}
|
onClick={startAdd}
|
||||||
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
{language.t("dialog.server.add.button")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{store.addServer.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")}
|
<Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">
|
||||||
</Button>
|
{formBusy()
|
||||||
|
? language.t("dialog.server.add.checking")
|
||||||
|
: isAddMode()
|
||||||
|
? language.t("dialog.server.add.button")
|
||||||
|
: language.t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -325,12 +325,6 @@ export default function FileTree(props: {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const dir = file.tree.state(props.path)
|
|
||||||
if (!shouldListExpanded({ level, dir })) return
|
|
||||||
void file.tree.list(props.path)
|
|
||||||
})
|
|
||||||
|
|
||||||
const nodes = createMemo(() => {
|
const nodes = createMemo(() => {
|
||||||
const nodes = file.tree.children(props.path)
|
const nodes = file.tree.children(props.path)
|
||||||
const current = filter()
|
const current = filter()
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||||
|
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, Switch, Match, 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 { createFocusSignal } from "@solid-primitives/active-element"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { useFile } from "@/context/file"
|
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
|
||||||
import {
|
import {
|
||||||
ContentPart,
|
ContentPart,
|
||||||
DEFAULT_PROMPT,
|
DEFAULT_PROMPT,
|
||||||
@@ -23,7 +24,6 @@ import { Button } from "@opencode-ai/ui/button"
|
|||||||
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||||
import type { IconName } from "@opencode-ai/ui/icons/provider"
|
|
||||||
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"
|
||||||
@@ -43,6 +43,9 @@ import {
|
|||||||
canNavigateHistoryAtCursor,
|
canNavigateHistoryAtCursor,
|
||||||
navigatePromptHistory,
|
navigatePromptHistory,
|
||||||
prependHistoryEntry,
|
prependHistoryEntry,
|
||||||
|
type PromptHistoryComment,
|
||||||
|
type PromptHistoryEntry,
|
||||||
|
type PromptHistoryStoredEntry,
|
||||||
promptLength,
|
promptLength,
|
||||||
} from "./prompt-input/history"
|
} from "./prompt-input/history"
|
||||||
import { createPromptSubmit } from "./prompt-input/submit"
|
import { createPromptSubmit } from "./prompt-input/submit"
|
||||||
@@ -170,12 +173,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const focus = { file: item.path, id: item.commentID }
|
const focus = { file: item.path, id: item.commentID }
|
||||||
comments.setActive(focus)
|
comments.setActive(focus)
|
||||||
|
|
||||||
|
const queueCommentFocus = (attempts = 6) => {
|
||||||
|
const schedule = (left: number) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
comments.setFocus({ ...focus })
|
||||||
|
if (left <= 0) return
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const current = comments.focus()
|
||||||
|
if (!current) return
|
||||||
|
if (current.file !== focus.file || current.id !== focus.id) return
|
||||||
|
schedule(left - 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule(attempts)
|
||||||
|
}
|
||||||
|
|
||||||
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
|
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
|
||||||
if (wantsReview) {
|
if (wantsReview) {
|
||||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||||
layout.fileTree.setTab("changes")
|
layout.fileTree.setTab("changes")
|
||||||
tabs().setActive("review")
|
tabs().setActive("review")
|
||||||
requestAnimationFrame(() => comments.setFocus(focus))
|
queueCommentFocus()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,8 +203,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
layout.fileTree.setTab("all")
|
layout.fileTree.setTab("all")
|
||||||
const tab = files.tab(item.path)
|
const tab = files.tab(item.path)
|
||||||
tabs().open(tab)
|
tabs().open(tab)
|
||||||
files.load(item.path)
|
tabs().setActive(tab)
|
||||||
requestAnimationFrame(() => comments.setFocus(focus))
|
Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus())
|
||||||
}
|
}
|
||||||
|
|
||||||
const recent = createMemo(() => {
|
const recent = createMemo(() => {
|
||||||
@@ -219,7 +239,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
popover: "at" | "slash" | null
|
popover: "at" | "slash" | null
|
||||||
historyIndex: number
|
historyIndex: number
|
||||||
savedPrompt: Prompt | null
|
savedPrompt: PromptHistoryEntry | null
|
||||||
placeholder: number
|
placeholder: number
|
||||||
draggingType: "image" | "@mention" | null
|
draggingType: "image" | "@mention" | null
|
||||||
mode: "normal" | "shell"
|
mode: "normal" | "shell"
|
||||||
@@ -227,13 +247,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}>({
|
}>({
|
||||||
popover: null,
|
popover: null,
|
||||||
historyIndex: -1,
|
historyIndex: -1,
|
||||||
savedPrompt: null,
|
savedPrompt: null as PromptHistoryEntry | null,
|
||||||
placeholder: Math.floor(Math.random() * EXAMPLES.length),
|
placeholder: Math.floor(Math.random() * EXAMPLES.length),
|
||||||
draggingType: null,
|
draggingType: null,
|
||||||
mode: "normal",
|
mode: "normal",
|
||||||
applyingHistory: false,
|
applyingHistory: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 })
|
||||||
|
|
||||||
const commentCount = createMemo(() => {
|
const commentCount = createMemo(() => {
|
||||||
if (store.mode === "shell") return 0
|
if (store.mode === "shell") return 0
|
||||||
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
||||||
@@ -256,7 +278,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const [history, setHistory] = persisted(
|
const [history, setHistory] = persisted(
|
||||||
Persist.global("prompt-history", ["prompt-history.v1"]),
|
Persist.global("prompt-history", ["prompt-history.v1"]),
|
||||||
createStore<{
|
createStore<{
|
||||||
entries: Prompt[]
|
entries: PromptHistoryStoredEntry[]
|
||||||
}>({
|
}>({
|
||||||
entries: [],
|
entries: [],
|
||||||
}),
|
}),
|
||||||
@@ -264,7 +286,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const [shellHistory, setShellHistory] = persisted(
|
const [shellHistory, setShellHistory] = persisted(
|
||||||
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
|
||||||
createStore<{
|
createStore<{
|
||||||
entries: Prompt[]
|
entries: PromptHistoryStoredEntry[]
|
||||||
}>({
|
}>({
|
||||||
entries: [],
|
entries: [],
|
||||||
}),
|
}),
|
||||||
@@ -282,9 +304,66 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
|
const historyComments = () => {
|
||||||
|
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
|
||||||
|
return prompt.context.items().flatMap((item) => {
|
||||||
|
if (item.type !== "file") return []
|
||||||
|
const comment = item.comment?.trim()
|
||||||
|
if (!comment) return []
|
||||||
|
|
||||||
|
const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined
|
||||||
|
const nextSelection =
|
||||||
|
selection ??
|
||||||
|
(item.selection
|
||||||
|
? ({
|
||||||
|
start: item.selection.startLine,
|
||||||
|
end: item.selection.endLine,
|
||||||
|
} satisfies SelectedLineRange)
|
||||||
|
: undefined)
|
||||||
|
if (!nextSelection) return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: item.commentID ?? item.key,
|
||||||
|
path: item.path,
|
||||||
|
selection: { ...nextSelection },
|
||||||
|
comment,
|
||||||
|
time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(),
|
||||||
|
origin: item.commentOrigin,
|
||||||
|
preview: item.preview,
|
||||||
|
} satisfies PromptHistoryComment,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyHistoryComments = (items: PromptHistoryComment[]) => {
|
||||||
|
comments.replace(
|
||||||
|
items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
file: item.path,
|
||||||
|
selection: { ...item.selection },
|
||||||
|
comment: item.comment,
|
||||||
|
time: item.time,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
prompt.context.replaceComments(
|
||||||
|
items.map((item) => ({
|
||||||
|
type: "file" as const,
|
||||||
|
path: item.path,
|
||||||
|
selection: selectionFromLines(item.selection),
|
||||||
|
comment: item.comment,
|
||||||
|
commentID: item.id,
|
||||||
|
commentOrigin: item.origin,
|
||||||
|
preview: item.preview,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyHistoryPrompt = (entry: PromptHistoryEntry, position: "start" | "end") => {
|
||||||
|
const p = entry.prompt
|
||||||
const length = position === "start" ? 0 : promptLength(p)
|
const length = position === "start" ? 0 : promptLength(p)
|
||||||
setStore("applyingHistory", true)
|
setStore("applyingHistory", true)
|
||||||
|
applyHistoryComments(entry.comments)
|
||||||
prompt.set(p, length)
|
prompt.set(p, length)
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
editorRef.focus()
|
editorRef.focus()
|
||||||
@@ -515,7 +594,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
setActive: setSlashActive,
|
setActive: setSlashActive,
|
||||||
onInput: slashOnInput,
|
onInput: slashOnInput,
|
||||||
onKeyDown: slashOnKeyDown,
|
onKeyDown: slashOnKeyDown,
|
||||||
refetch: slashRefetch,
|
|
||||||
} = useFilteredList<SlashCommand>({
|
} = useFilteredList<SlashCommand>({
|
||||||
items: slashCommands,
|
items: slashCommands,
|
||||||
key: (x) => x?.id,
|
key: (x) => x?.id,
|
||||||
@@ -572,14 +650,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => sync.data.command,
|
|
||||||
() => slashRefetch(),
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Auto-scroll active command into view when navigating with keyboard
|
// Auto-scroll active command into view when navigating with keyboard
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const activeId = slashActive()
|
const activeId = slashActive()
|
||||||
@@ -846,7 +916,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
|
||||||
const currentHistory = mode === "shell" ? shellHistory : history
|
const currentHistory = mode === "shell" ? shellHistory : history
|
||||||
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
|
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
|
||||||
const next = prependHistoryEntry(currentHistory.entries, prompt)
|
const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments())
|
||||||
if (next === currentHistory.entries) return
|
if (next === currentHistory.entries) return
|
||||||
setCurrentHistory("entries", next)
|
setCurrentHistory("entries", next)
|
||||||
}
|
}
|
||||||
@@ -857,12 +927,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
entries: store.mode === "shell" ? shellHistory.entries : history.entries,
|
entries: store.mode === "shell" ? shellHistory.entries : history.entries,
|
||||||
historyIndex: store.historyIndex,
|
historyIndex: store.historyIndex,
|
||||||
currentPrompt: prompt.current(),
|
currentPrompt: prompt.current(),
|
||||||
|
currentComments: historyComments(),
|
||||||
savedPrompt: store.savedPrompt,
|
savedPrompt: store.savedPrompt,
|
||||||
})
|
})
|
||||||
if (!result.handled) return false
|
if (!result.handled) return false
|
||||||
setStore("historyIndex", result.historyIndex)
|
setStore("historyIndex", result.historyIndex)
|
||||||
setStore("savedPrompt", result.savedPrompt)
|
setStore("savedPrompt", result.savedPrompt)
|
||||||
applyHistoryPrompt(result.prompt, result.cursor)
|
applyHistoryPrompt(result.entry, result.cursor)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -879,10 +950,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
readClipboardImage: platform.readClipboardImage,
|
readClipboardImage: platform.readClipboardImage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
||||||
|
const accepting = createMemo(() => {
|
||||||
|
const id = params.id
|
||||||
|
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||||
|
return permission.isAutoAccepting(id, sdk.directory)
|
||||||
|
})
|
||||||
|
|
||||||
const { abort, handleSubmit } = createPromptSubmit({
|
const { abort, handleSubmit } = createPromptSubmit({
|
||||||
info,
|
info,
|
||||||
imageAttachments,
|
imageAttachments,
|
||||||
commentCount,
|
commentCount,
|
||||||
|
autoAccept: () => accepting(),
|
||||||
mode: () => store.mode,
|
mode: () => store.mode,
|
||||||
working,
|
working,
|
||||||
editor: () => editorRef,
|
editor: () => editorRef,
|
||||||
@@ -1047,8 +1126,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const variants = createMemo(() => ["default", ...local.model.variant.list()])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative size-full _max-h-[320px] flex flex-col gap-0">
|
<div class="relative size-full _max-h-[320px] flex flex-col gap-0">
|
||||||
<PromptPopover
|
<PromptPopover
|
||||||
@@ -1126,9 +1203,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
aria-label={placeholder()}
|
aria-label={placeholder()}
|
||||||
contenteditable="true"
|
contenteditable="true"
|
||||||
autocapitalize="off"
|
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||||
autocorrect="off"
|
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||||
spellcheck={false}
|
spellcheck={store.mode === "normal"}
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onCompositionStart={() => setComposing(true)}
|
onCompositionStart={() => setComposing(true)}
|
||||||
@@ -1168,10 +1245,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
aria-hidden={store.mode !== "normal"}
|
aria-hidden={store.mode !== "normal"}
|
||||||
class="flex items-center gap-1 transition-all duration-200 ease-out"
|
class="flex items-center gap-1"
|
||||||
classList={{
|
style={{
|
||||||
"opacity-100 translate-y-0 scale-100 pointer-events-auto": store.mode === "normal",
|
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||||
"opacity-0 translate-y-2 scale-95 pointer-events-none": store.mode !== "normal",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
@@ -1184,6 +1260,11 @@ 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={{
|
||||||
|
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}
|
||||||
@@ -1221,60 +1302,78 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
icon={working() ? "stop" : "arrow-up"}
|
icon={working() ? "stop" : "arrow-up"}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
class="size-8"
|
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")}
|
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={store.mode === "normal" && permission.permissionsEnabled() && params.id}>
|
<div class="pointer-events-none absolute bottom-2 left-2">
|
||||||
<div class="pointer-events-none absolute bottom-2 left-2">
|
<div class="pointer-events-auto">
|
||||||
<div class="pointer-events-auto">
|
<TooltipKeybind
|
||||||
<TooltipKeybind
|
placement="top"
|
||||||
placement="top"
|
gutter={8}
|
||||||
gutter={8}
|
title={language.t(
|
||||||
title={language.t("command.permissions.autoaccept.enable")}
|
accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable",
|
||||||
keybind={command.keybind("permissions.autoaccept")}
|
)}
|
||||||
>
|
keybind={command.keybind("permissions.autoaccept")}
|
||||||
<Button
|
>
|
||||||
data-action="prompt-permissions"
|
<Button
|
||||||
variant="ghost"
|
data-action="prompt-permissions"
|
||||||
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
|
variant="ghost"
|
||||||
classList={{
|
onClick={() => {
|
||||||
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
|
if (!params.id) {
|
||||||
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
|
permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||||
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
|
return
|
||||||
}}
|
|
||||||
aria-label={
|
|
||||||
permission.isAutoAccepting(params.id!, sdk.directory)
|
|
||||||
? language.t("command.permissions.autoaccept.disable")
|
|
||||||
: language.t("command.permissions.autoaccept.enable")
|
|
||||||
}
|
}
|
||||||
aria-pressed={permission.isAutoAccepting(params.id!, sdk.directory)}
|
permission.toggleAutoAccept(params.id, sdk.directory)
|
||||||
>
|
}}
|
||||||
<Icon
|
classList={{
|
||||||
name="chevron-double-right"
|
"size-6 flex items-center justify-center": true,
|
||||||
size="small"
|
"text-text-base": !accepting(),
|
||||||
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
|
"hover:bg-surface-success-base": accepting(),
|
||||||
/>
|
}}
|
||||||
</Button>
|
aria-label={
|
||||||
</TooltipKeybind>
|
accepting()
|
||||||
</div>
|
? 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>
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DockShellForm>
|
</DockShellForm>
|
||||||
<Show when={store.mode === "normal" || store.mode === "shell"}>
|
<Show when={store.mode === "normal" || store.mode === "shell"}>
|
||||||
<DockTray attach="top">
|
<DockTray attach="top">
|
||||||
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
|
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
|
||||||
<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 relative">
|
||||||
<Show when={store.mode === "shell"}>
|
<div
|
||||||
<div class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0" style={{ padding: "0 4px 0 8px" }}>
|
class="h-7 flex items-center gap-1.5 max-w-[160px] min-w-0 absolute inset-y-0 left-0"
|
||||||
<span class="truncate text-13-medium text-text-strong">{language.t("prompt.mode.shell")}</span>
|
style={{
|
||||||
<div class="size-4 shrink-0" />
|
padding: "0 4px 0 8px",
|
||||||
</div>
|
opacity: 1 - buttonsSpring(),
|
||||||
</Show>
|
transform: `scale(${0.95 + (1 - buttonsSpring()) * 0.05})`,
|
||||||
<Show when={store.mode === "normal"}>
|
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>
|
||||||
|
<div class="size-4 shrink-0" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
@@ -1288,7 +1387,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
onSelect={local.agent.set}
|
onSelect={local.agent.set}
|
||||||
class="capitalize max-w-[160px]"
|
class="capitalize max-w-[160px]"
|
||||||
valueClass="truncate text-13-regular"
|
valueClass="truncate text-13-regular"
|
||||||
triggerStyle={{ height: "28px" }}
|
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"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
@@ -1306,12 +1411,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="normal"
|
size="normal"
|
||||||
class="min-w-0 max-w-[320px] text-13-regular group"
|
class="min-w-0 max-w-[320px] text-13-regular group"
|
||||||
style={{ height: "28px" }}
|
style={{
|
||||||
|
height: "28px",
|
||||||
|
opacity: buttonsSpring(),
|
||||||
|
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||||
|
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||||
|
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||||
|
}}
|
||||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
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 as IconName}
|
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)" }}
|
||||||
/>
|
/>
|
||||||
@@ -1335,13 +1446,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
triggerProps={{
|
triggerProps={{
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
size: "normal",
|
size: "normal",
|
||||||
style: { height: "28px" },
|
style: {
|
||||||
|
height: "28px",
|
||||||
|
opacity: buttonsSpring(),
|
||||||
|
transform: `scale(${0.95 + buttonsSpring() * 0.05})`,
|
||||||
|
filter: `blur(${(1 - buttonsSpring()) * 2}px)`,
|
||||||
|
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
|
||||||
|
},
|
||||||
class: "min-w-0 max-w-[320px] text-13-regular group",
|
class: "min-w-0 max-w-[320px] text-13-regular group",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
id={local.model.current()!.provider.id as IconName}
|
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)" }}
|
||||||
/>
|
/>
|
||||||
@@ -1367,11 +1484,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
||||||
class="capitalize max-w-[160px]"
|
class="capitalize max-w-[160px]"
|
||||||
valueClass="truncate text-13-regular"
|
valueClass="truncate text-13-regular"
|
||||||
triggerStyle={{ height: "28px" }}
|
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"
|
variant="ghost"
|
||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
|
|||||||
@@ -35,6 +35,15 @@ describe("buildRequestParts", () => {
|
|||||||
result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
|
result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
|
expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
|
||||||
|
expect(
|
||||||
|
result.requestParts.some(
|
||||||
|
(part) =>
|
||||||
|
part.type === "text" &&
|
||||||
|
part.synthetic &&
|
||||||
|
part.metadata?.opencodeComment &&
|
||||||
|
(part.metadata.opencodeComment as { comment?: string }).comment === "check this",
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
|
||||||
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
|
expect(result.optimisticParts).toHaveLength(result.requestParts.length)
|
||||||
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)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { FileSelection } from "@/context/file"
|
|||||||
import { encodeFilePath } from "@/context/file/path"
|
import { encodeFilePath } from "@/context/file/path"
|
||||||
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
|
import { createCommentMetadata, formatCommentNote } from "@/utils/comment-note"
|
||||||
|
|
||||||
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
|
type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
|
||||||
|
|
||||||
@@ -41,18 +42,6 @@ const fileQuery = (selection: FileSelection | undefined) =>
|
|||||||
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||||
|
|
||||||
const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
|
|
||||||
const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
|
|
||||||
const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
|
|
||||||
const range =
|
|
||||||
start === undefined || end === undefined
|
|
||||||
? "this file"
|
|
||||||
: start === end
|
|
||||||
? `line ${start}`
|
|
||||||
: `lines ${start} through ${end}`
|
|
||||||
return `The user made the following comment regarding ${range} of ${path}: ${comment}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
|
const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
return {
|
return {
|
||||||
@@ -153,8 +142,15 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
|||||||
{
|
{
|
||||||
id: Identifier.ascending("part"),
|
id: Identifier.ascending("part"),
|
||||||
type: "text",
|
type: "text",
|
||||||
text: commentNote(item.path, item.selection, comment),
|
text: formatCommentNote({ path: item.path, selection: item.selection, comment }),
|
||||||
synthetic: true,
|
synthetic: true,
|
||||||
|
metadata: createCommentMetadata({
|
||||||
|
path: item.path,
|
||||||
|
selection: item.selection,
|
||||||
|
comment,
|
||||||
|
preview: item.preview,
|
||||||
|
origin: item.commentOrigin,
|
||||||
|
}),
|
||||||
} satisfies PromptRequestPart,
|
} satisfies PromptRequestPart,
|
||||||
filePart,
|
filePart,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,25 +3,42 @@ import type { Prompt } from "@/context/prompt"
|
|||||||
import {
|
import {
|
||||||
canNavigateHistoryAtCursor,
|
canNavigateHistoryAtCursor,
|
||||||
clonePromptParts,
|
clonePromptParts,
|
||||||
|
normalizePromptHistoryEntry,
|
||||||
navigatePromptHistory,
|
navigatePromptHistory,
|
||||||
prependHistoryEntry,
|
prependHistoryEntry,
|
||||||
promptLength,
|
promptLength,
|
||||||
|
type PromptHistoryComment,
|
||||||
} from "./history"
|
} from "./history"
|
||||||
|
|
||||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||||
|
|
||||||
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
|
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
|
||||||
|
const comment = (id: string, value = "note"): PromptHistoryComment => ({
|
||||||
|
id,
|
||||||
|
path: "src/a.ts",
|
||||||
|
selection: { start: 2, end: 4 },
|
||||||
|
comment: value,
|
||||||
|
time: 1,
|
||||||
|
origin: "review",
|
||||||
|
preview: "const a = 1",
|
||||||
|
})
|
||||||
|
|
||||||
describe("prompt-input history", () => {
|
describe("prompt-input history", () => {
|
||||||
test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => {
|
test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => {
|
||||||
const first = prependHistoryEntry([], DEFAULT_PROMPT)
|
const first = prependHistoryEntry([], DEFAULT_PROMPT)
|
||||||
expect(first).toEqual([])
|
expect(first).toEqual([])
|
||||||
|
|
||||||
|
const commentsOnly = prependHistoryEntry([], DEFAULT_PROMPT, [comment("c1")])
|
||||||
|
expect(commentsOnly).toHaveLength(1)
|
||||||
|
|
||||||
const withOne = prependHistoryEntry([], text("hello"))
|
const withOne = prependHistoryEntry([], text("hello"))
|
||||||
expect(withOne).toHaveLength(1)
|
expect(withOne).toHaveLength(1)
|
||||||
|
|
||||||
const deduped = prependHistoryEntry(withOne, text("hello"))
|
const deduped = prependHistoryEntry(withOne, text("hello"))
|
||||||
expect(deduped).toBe(withOne)
|
expect(deduped).toBe(withOne)
|
||||||
|
|
||||||
|
const dedupedComments = prependHistoryEntry(commentsOnly, DEFAULT_PROMPT, [comment("c1")])
|
||||||
|
expect(dedupedComments).toBe(commentsOnly)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
|
test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
|
||||||
@@ -31,24 +48,57 @@ describe("prompt-input history", () => {
|
|||||||
entries,
|
entries,
|
||||||
historyIndex: -1,
|
historyIndex: -1,
|
||||||
currentPrompt: text("draft"),
|
currentPrompt: text("draft"),
|
||||||
|
currentComments: [comment("draft")],
|
||||||
savedPrompt: null,
|
savedPrompt: null,
|
||||||
})
|
})
|
||||||
expect(up.handled).toBe(true)
|
expect(up.handled).toBe(true)
|
||||||
if (!up.handled) throw new Error("expected handled")
|
if (!up.handled) throw new Error("expected handled")
|
||||||
expect(up.historyIndex).toBe(0)
|
expect(up.historyIndex).toBe(0)
|
||||||
expect(up.cursor).toBe("start")
|
expect(up.cursor).toBe("start")
|
||||||
|
expect(up.entry.comments).toEqual([])
|
||||||
|
|
||||||
const down = navigatePromptHistory({
|
const down = navigatePromptHistory({
|
||||||
direction: "down",
|
direction: "down",
|
||||||
entries,
|
entries,
|
||||||
historyIndex: up.historyIndex,
|
historyIndex: up.historyIndex,
|
||||||
currentPrompt: text("ignored"),
|
currentPrompt: text("ignored"),
|
||||||
|
currentComments: [],
|
||||||
savedPrompt: up.savedPrompt,
|
savedPrompt: up.savedPrompt,
|
||||||
})
|
})
|
||||||
expect(down.handled).toBe(true)
|
expect(down.handled).toBe(true)
|
||||||
if (!down.handled) throw new Error("expected handled")
|
if (!down.handled) throw new Error("expected handled")
|
||||||
expect(down.historyIndex).toBe(-1)
|
expect(down.historyIndex).toBe(-1)
|
||||||
expect(down.prompt[0]?.type === "text" ? down.prompt[0].content : "").toBe("draft")
|
expect(down.entry.prompt[0]?.type === "text" ? down.entry.prompt[0].content : "").toBe("draft")
|
||||||
|
expect(down.entry.comments).toEqual([comment("draft")])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("navigatePromptHistory keeps entry comments when moving through history", () => {
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
prompt: text("with comment"),
|
||||||
|
comments: [comment("c1")],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const up = navigatePromptHistory({
|
||||||
|
direction: "up",
|
||||||
|
entries,
|
||||||
|
historyIndex: -1,
|
||||||
|
currentPrompt: text("draft"),
|
||||||
|
currentComments: [],
|
||||||
|
savedPrompt: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(up.handled).toBe(true)
|
||||||
|
if (!up.handled) throw new Error("expected handled")
|
||||||
|
expect(up.entry.prompt[0]?.type === "text" ? up.entry.prompt[0].content : "").toBe("with comment")
|
||||||
|
expect(up.entry.comments).toEqual([comment("c1")])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("normalizePromptHistoryEntry supports legacy prompt arrays", () => {
|
||||||
|
const entry = normalizePromptHistoryEntry(text("legacy"))
|
||||||
|
expect(entry.prompt[0]?.type === "text" ? entry.prompt[0].content : "").toBe("legacy")
|
||||||
|
expect(entry.comments).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("helpers clone prompt and count text content length", () => {
|
test("helpers clone prompt and count text content length", () => {
|
||||||
|
|||||||
@@ -1,9 +1,27 @@
|
|||||||
import type { Prompt } from "@/context/prompt"
|
import type { Prompt } from "@/context/prompt"
|
||||||
|
import type { SelectedLineRange } from "@/context/file"
|
||||||
|
|
||||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||||
|
|
||||||
export const MAX_HISTORY = 100
|
export const MAX_HISTORY = 100
|
||||||
|
|
||||||
|
export type PromptHistoryComment = {
|
||||||
|
id: string
|
||||||
|
path: string
|
||||||
|
selection: SelectedLineRange
|
||||||
|
comment: string
|
||||||
|
time: number
|
||||||
|
origin?: "review" | "file"
|
||||||
|
preview?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromptHistoryEntry = {
|
||||||
|
prompt: Prompt
|
||||||
|
comments: PromptHistoryComment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromptHistoryStoredEntry = Prompt | PromptHistoryEntry
|
||||||
|
|
||||||
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
|
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
|
||||||
const position = Math.max(0, Math.min(cursor, text.length))
|
const position = Math.max(0, Math.min(cursor, text.length))
|
||||||
const atStart = position === 0
|
const atStart = position === 0
|
||||||
@@ -25,29 +43,82 @@ export function clonePromptParts(prompt: Prompt): Prompt {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cloneSelection(selection: SelectedLineRange): SelectedLineRange {
|
||||||
|
return {
|
||||||
|
start: selection.start,
|
||||||
|
end: selection.end,
|
||||||
|
...(selection.side ? { side: selection.side } : {}),
|
||||||
|
...(selection.endSide ? { endSide: selection.endSide } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clonePromptHistoryComments(comments: PromptHistoryComment[]) {
|
||||||
|
return comments.map((comment) => ({
|
||||||
|
...comment,
|
||||||
|
selection: cloneSelection(comment.selection),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
|
||||||
|
if (Array.isArray(entry)) {
|
||||||
|
return {
|
||||||
|
prompt: clonePromptParts(entry),
|
||||||
|
comments: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
prompt: clonePromptParts(entry.prompt),
|
||||||
|
comments: clonePromptHistoryComments(entry.comments),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function promptLength(prompt: Prompt) {
|
export function promptLength(prompt: Prompt) {
|
||||||
return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
|
return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prependHistoryEntry(entries: Prompt[], prompt: Prompt, max = MAX_HISTORY) {
|
export function prependHistoryEntry(
|
||||||
|
entries: PromptHistoryStoredEntry[],
|
||||||
|
prompt: Prompt,
|
||||||
|
comments: PromptHistoryComment[] = [],
|
||||||
|
max = MAX_HISTORY,
|
||||||
|
) {
|
||||||
const text = prompt
|
const text = prompt
|
||||||
.map((part) => ("content" in part ? part.content : ""))
|
.map((part) => ("content" in part ? part.content : ""))
|
||||||
.join("")
|
.join("")
|
||||||
.trim()
|
.trim()
|
||||||
const hasImages = prompt.some((part) => part.type === "image")
|
const hasImages = prompt.some((part) => part.type === "image")
|
||||||
if (!text && !hasImages) return entries
|
const hasComments = comments.some((comment) => !!comment.comment.trim())
|
||||||
|
if (!text && !hasImages && !hasComments) return entries
|
||||||
|
|
||||||
const entry = clonePromptParts(prompt)
|
const entry = {
|
||||||
|
prompt: clonePromptParts(prompt),
|
||||||
|
comments: clonePromptHistoryComments(comments),
|
||||||
|
} satisfies PromptHistoryEntry
|
||||||
const last = entries[0]
|
const last = entries[0]
|
||||||
if (last && isPromptEqual(last, entry)) return entries
|
if (last && isPromptEqual(last, entry)) return entries
|
||||||
return [entry, ...entries].slice(0, max)
|
return [entry, ...entries].slice(0, max)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPromptEqual(promptA: Prompt, promptB: Prompt) {
|
function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryComment) {
|
||||||
if (promptA.length !== promptB.length) return false
|
return (
|
||||||
for (let i = 0; i < promptA.length; i++) {
|
commentA.path === commentB.path &&
|
||||||
const partA = promptA[i]
|
commentA.comment === commentB.comment &&
|
||||||
const partB = promptB[i]
|
commentA.origin === commentB.origin &&
|
||||||
|
commentA.preview === commentB.preview &&
|
||||||
|
commentA.selection.start === commentB.selection.start &&
|
||||||
|
commentA.selection.end === commentB.selection.end &&
|
||||||
|
commentA.selection.side === commentB.selection.side &&
|
||||||
|
commentA.selection.endSide === commentB.selection.endSide
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPromptEqual(promptA: PromptHistoryStoredEntry, promptB: PromptHistoryStoredEntry) {
|
||||||
|
const entryA = normalizePromptHistoryEntry(promptA)
|
||||||
|
const entryB = normalizePromptHistoryEntry(promptB)
|
||||||
|
if (entryA.prompt.length !== entryB.prompt.length) return false
|
||||||
|
for (let i = 0; i < entryA.prompt.length; i++) {
|
||||||
|
const partA = entryA.prompt[i]
|
||||||
|
const partB = entryB.prompt[i]
|
||||||
if (partA.type !== partB.type) return false
|
if (partA.type !== partB.type) return false
|
||||||
if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false
|
if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false
|
||||||
if (partA.type === "file") {
|
if (partA.type === "file") {
|
||||||
@@ -67,28 +138,35 @@ function isPromptEqual(promptA: Prompt, promptB: Prompt) {
|
|||||||
if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false
|
if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false
|
||||||
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
|
if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
|
||||||
}
|
}
|
||||||
|
if (entryA.comments.length !== entryB.comments.length) return false
|
||||||
|
for (let i = 0; i < entryA.comments.length; i++) {
|
||||||
|
const commentA = entryA.comments[i]
|
||||||
|
const commentB = entryB.comments[i]
|
||||||
|
if (!commentA || !commentB || !isCommentEqual(commentA, commentB)) return false
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
type HistoryNavInput = {
|
type HistoryNavInput = {
|
||||||
direction: "up" | "down"
|
direction: "up" | "down"
|
||||||
entries: Prompt[]
|
entries: PromptHistoryStoredEntry[]
|
||||||
historyIndex: number
|
historyIndex: number
|
||||||
currentPrompt: Prompt
|
currentPrompt: Prompt
|
||||||
savedPrompt: Prompt | null
|
currentComments: PromptHistoryComment[]
|
||||||
|
savedPrompt: PromptHistoryEntry | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type HistoryNavResult =
|
type HistoryNavResult =
|
||||||
| {
|
| {
|
||||||
handled: false
|
handled: false
|
||||||
historyIndex: number
|
historyIndex: number
|
||||||
savedPrompt: Prompt | null
|
savedPrompt: PromptHistoryEntry | null
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
handled: true
|
handled: true
|
||||||
historyIndex: number
|
historyIndex: number
|
||||||
savedPrompt: Prompt | null
|
savedPrompt: PromptHistoryEntry | null
|
||||||
prompt: Prompt
|
entry: PromptHistoryEntry
|
||||||
cursor: "start" | "end"
|
cursor: "start" | "end"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,22 +181,27 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (input.historyIndex === -1) {
|
if (input.historyIndex === -1) {
|
||||||
|
const entry = normalizePromptHistoryEntry(input.entries[0])
|
||||||
return {
|
return {
|
||||||
handled: true,
|
handled: true,
|
||||||
historyIndex: 0,
|
historyIndex: 0,
|
||||||
savedPrompt: clonePromptParts(input.currentPrompt),
|
savedPrompt: {
|
||||||
prompt: input.entries[0],
|
prompt: clonePromptParts(input.currentPrompt),
|
||||||
|
comments: clonePromptHistoryComments(input.currentComments),
|
||||||
|
},
|
||||||
|
entry,
|
||||||
cursor: "start",
|
cursor: "start",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.historyIndex < input.entries.length - 1) {
|
if (input.historyIndex < input.entries.length - 1) {
|
||||||
const next = input.historyIndex + 1
|
const next = input.historyIndex + 1
|
||||||
|
const entry = normalizePromptHistoryEntry(input.entries[next])
|
||||||
return {
|
return {
|
||||||
handled: true,
|
handled: true,
|
||||||
historyIndex: next,
|
historyIndex: next,
|
||||||
savedPrompt: input.savedPrompt,
|
savedPrompt: input.savedPrompt,
|
||||||
prompt: input.entries[next],
|
entry,
|
||||||
cursor: "start",
|
cursor: "start",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,11 +215,12 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
|
|||||||
|
|
||||||
if (input.historyIndex > 0) {
|
if (input.historyIndex > 0) {
|
||||||
const next = input.historyIndex - 1
|
const next = input.historyIndex - 1
|
||||||
|
const entry = normalizePromptHistoryEntry(input.entries[next])
|
||||||
return {
|
return {
|
||||||
handled: true,
|
handled: true,
|
||||||
historyIndex: next,
|
historyIndex: next,
|
||||||
savedPrompt: input.savedPrompt,
|
savedPrompt: input.savedPrompt,
|
||||||
prompt: input.entries[next],
|
entry,
|
||||||
cursor: "end",
|
cursor: "end",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,7 +231,7 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
|
|||||||
handled: true,
|
handled: true,
|
||||||
historyIndex: -1,
|
historyIndex: -1,
|
||||||
savedPrompt: null,
|
savedPrompt: null,
|
||||||
prompt: input.savedPrompt,
|
entry: input.savedPrompt,
|
||||||
cursor: "end",
|
cursor: "end",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,7 +240,10 @@ export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult
|
|||||||
handled: true,
|
handled: true,
|
||||||
historyIndex: -1,
|
historyIndex: -1,
|
||||||
savedPrompt: null,
|
savedPrompt: null,
|
||||||
prompt: DEFAULT_PROMPT,
|
entry: {
|
||||||
|
prompt: DEFAULT_PROMPT,
|
||||||
|
comments: [],
|
||||||
|
},
|
||||||
cursor: "end",
|
cursor: "end",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,20 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit
|
|||||||
|
|
||||||
const createdClients: string[] = []
|
const createdClients: string[] = []
|
||||||
const createdSessions: string[] = []
|
const createdSessions: string[] = []
|
||||||
|
const enabledAutoAccept: Array<{ sessionID: string; directory: string }> = []
|
||||||
|
const optimistic: Array<{
|
||||||
|
message: {
|
||||||
|
agent: string
|
||||||
|
model: { providerID: string; modelID: string }
|
||||||
|
variant?: string
|
||||||
|
}
|
||||||
|
}> = []
|
||||||
const sentShell: string[] = []
|
const sentShell: string[] = []
|
||||||
const syncedDirectories: string[] = []
|
const syncedDirectories: string[] = []
|
||||||
|
|
||||||
|
let params: { id?: string } = {}
|
||||||
let selected = "/repo/worktree-a"
|
let selected = "/repo/worktree-a"
|
||||||
|
let variant: string | undefined
|
||||||
|
|
||||||
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||||
|
|
||||||
@@ -25,6 +35,7 @@ const clientFor = (directory: string) => {
|
|||||||
return { data: undefined }
|
return { data: undefined }
|
||||||
},
|
},
|
||||||
prompt: async () => ({ data: undefined }),
|
prompt: async () => ({ data: undefined }),
|
||||||
|
promptAsync: async () => ({ data: undefined }),
|
||||||
command: async () => ({ data: undefined }),
|
command: async () => ({ data: undefined }),
|
||||||
abort: async () => ({ data: undefined }),
|
abort: async () => ({ data: undefined }),
|
||||||
},
|
},
|
||||||
@@ -39,7 +50,7 @@ beforeAll(async () => {
|
|||||||
|
|
||||||
mock.module("@solidjs/router", () => ({
|
mock.module("@solidjs/router", () => ({
|
||||||
useNavigate: () => () => undefined,
|
useNavigate: () => () => undefined,
|
||||||
useParams: () => ({}),
|
useParams: () => params,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
mock.module("@opencode-ai/sdk/v2/client", () => ({
|
||||||
@@ -61,7 +72,7 @@ beforeAll(async () => {
|
|||||||
useLocal: () => ({
|
useLocal: () => ({
|
||||||
model: {
|
model: {
|
||||||
current: () => ({ id: "model", provider: { id: "provider" } }),
|
current: () => ({ id: "model", provider: { id: "provider" } }),
|
||||||
variant: { current: () => undefined },
|
variant: { current: () => variant },
|
||||||
},
|
},
|
||||||
agent: {
|
agent: {
|
||||||
current: () => ({ name: "agent" }),
|
current: () => ({ name: "agent" }),
|
||||||
@@ -69,6 +80,14 @@ beforeAll(async () => {
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
mock.module("@/context/permission", () => ({
|
||||||
|
usePermission: () => ({
|
||||||
|
enableAutoAccept(sessionID: string, directory: string) {
|
||||||
|
enabledAutoAccept.push({ sessionID, directory })
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
mock.module("@/context/prompt", () => ({
|
mock.module("@/context/prompt", () => ({
|
||||||
usePrompt: () => ({
|
usePrompt: () => ({
|
||||||
current: () => promptValue,
|
current: () => promptValue,
|
||||||
@@ -109,7 +128,11 @@ beforeAll(async () => {
|
|||||||
data: { command: [] },
|
data: { command: [] },
|
||||||
session: {
|
session: {
|
||||||
optimistic: {
|
optimistic: {
|
||||||
add: () => undefined,
|
add: (value: {
|
||||||
|
message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
|
||||||
|
}) => {
|
||||||
|
optimistic.push(value)
|
||||||
|
},
|
||||||
remove: () => undefined,
|
remove: () => undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -145,9 +168,13 @@ beforeAll(async () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createdClients.length = 0
|
createdClients.length = 0
|
||||||
createdSessions.length = 0
|
createdSessions.length = 0
|
||||||
|
enabledAutoAccept.length = 0
|
||||||
|
optimistic.length = 0
|
||||||
|
params = {}
|
||||||
sentShell.length = 0
|
sentShell.length = 0
|
||||||
syncedDirectories.length = 0
|
syncedDirectories.length = 0
|
||||||
selected = "/repo/worktree-a"
|
selected = "/repo/worktree-a"
|
||||||
|
variant = undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("prompt submit worktree selection", () => {
|
describe("prompt submit worktree selection", () => {
|
||||||
@@ -156,6 +183,7 @@ describe("prompt submit worktree selection", () => {
|
|||||||
info: () => undefined,
|
info: () => undefined,
|
||||||
imageAttachments: () => [],
|
imageAttachments: () => [],
|
||||||
commentCount: () => 0,
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => false,
|
||||||
mode: () => "shell",
|
mode: () => "shell",
|
||||||
working: () => false,
|
working: () => false,
|
||||||
editor: () => undefined,
|
editor: () => undefined,
|
||||||
@@ -181,4 +209,66 @@ describe("prompt submit worktree selection", () => {
|
|||||||
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-b"])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("applies auto-accept to newly created sessions", async () => {
|
||||||
|
const submit = createPromptSubmit({
|
||||||
|
info: () => undefined,
|
||||||
|
imageAttachments: () => [],
|
||||||
|
commentCount: () => 0,
|
||||||
|
autoAccept: () => true,
|
||||||
|
mode: () => "shell",
|
||||||
|
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(enabledAutoAccept).toEqual([{ sessionID: "session-1", directory: "/repo/worktree-a" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("includes the selected variant on optimistic prompts", async () => {
|
||||||
|
params = { id: "session-1" }
|
||||||
|
variant = "high"
|
||||||
|
|
||||||
|
const submit = createPromptSubmit({
|
||||||
|
info: () => ({ id: "session-1" }),
|
||||||
|
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,
|
||||||
|
onSubmit: () => undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const event = { preventDefault: () => undefined } as unknown as Event
|
||||||
|
|
||||||
|
await submit.handleSubmit(event)
|
||||||
|
|
||||||
|
expect(optimistic).toHaveLength(1)
|
||||||
|
expect(optimistic[0]).toMatchObject({
|
||||||
|
message: {
|
||||||
|
agent: "agent",
|
||||||
|
model: { providerID: "provider", modelID: "model" },
|
||||||
|
variant: "high",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useGlobalSync } from "@/context/global-sync"
|
|||||||
import { useLanguage } from "@/context/language"
|
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 { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
import { 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"
|
||||||
@@ -15,6 +16,7 @@ import { Identifier } from "@/utils/id"
|
|||||||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||||
import { buildRequestParts } from "./build-request-parts"
|
import { buildRequestParts } from "./build-request-parts"
|
||||||
import { setCursorPosition } from "./editor-dom"
|
import { setCursorPosition } from "./editor-dom"
|
||||||
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
|
|
||||||
type PendingPrompt = {
|
type PendingPrompt = {
|
||||||
abort: AbortController
|
abort: AbortController
|
||||||
@@ -27,6 +29,7 @@ type PromptSubmitInput = {
|
|||||||
info: Accessor<{ id: string } | undefined>
|
info: Accessor<{ id: string } | undefined>
|
||||||
imageAttachments: Accessor<ImageAttachmentPart[]>
|
imageAttachments: Accessor<ImageAttachmentPart[]>
|
||||||
commentCount: Accessor<number>
|
commentCount: Accessor<number>
|
||||||
|
autoAccept: Accessor<boolean>
|
||||||
mode: Accessor<"normal" | "shell">
|
mode: Accessor<"normal" | "shell">
|
||||||
working: Accessor<boolean>
|
working: Accessor<boolean>
|
||||||
editor: () => HTMLDivElement | undefined
|
editor: () => HTMLDivElement | undefined
|
||||||
@@ -56,6 +59,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
|
const permission = usePermission()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -140,6 +144,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
|
|
||||||
const projectDirectory = sdk.directory
|
const projectDirectory = sdk.directory
|
||||||
const isNewSession = !params.id
|
const isNewSession = !params.id
|
||||||
|
const shouldAutoAccept = isNewSession && input.autoAccept()
|
||||||
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
const worktreeSelection = input.newSessionWorktree?.() || "main"
|
||||||
|
|
||||||
let sessionDirectory = projectDirectory
|
let sessionDirectory = projectDirectory
|
||||||
@@ -197,6 +202,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
if (session) {
|
if (session) {
|
||||||
|
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
|
||||||
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}`)
|
||||||
}
|
}
|
||||||
@@ -281,7 +287,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||||
description: errorMessage(err),
|
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||||
})
|
})
|
||||||
restoreInput()
|
restoreInput()
|
||||||
})
|
})
|
||||||
@@ -310,6 +316,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
time: { created: Date.now() },
|
time: { created: Date.now() },
|
||||||
agent,
|
agent,
|
||||||
model,
|
model,
|
||||||
|
variant,
|
||||||
}
|
}
|
||||||
|
|
||||||
const addOptimisticMessage = () =>
|
const addOptimisticMessage = () =>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import {
|
import {
|
||||||
|
children,
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createSignal,
|
createSignal,
|
||||||
@@ -9,7 +10,7 @@ import {
|
|||||||
type ParentProps,
|
type ParentProps,
|
||||||
Show,
|
Show,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { type ServerConnection, serverDisplayName } from "@/context/server"
|
import { type ServerConnection, serverName } from "@/context/server"
|
||||||
import type { ServerHealth } from "@/utils/server-health"
|
import type { ServerHealth } from "@/utils/server-health"
|
||||||
|
|
||||||
interface ServerRowProps extends ParentProps {
|
interface ServerRowProps extends ParentProps {
|
||||||
@@ -20,13 +21,14 @@ interface ServerRowProps extends ParentProps {
|
|||||||
versionClass?: string
|
versionClass?: string
|
||||||
dimmed?: boolean
|
dimmed?: boolean
|
||||||
badge?: JSXElement
|
badge?: JSXElement
|
||||||
|
showCredentials?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServerRow(props: ServerRowProps) {
|
export function ServerRow(props: ServerRowProps) {
|
||||||
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
|
||||||
const name = createMemo(() => serverDisplayName(props.conn))
|
const name = createMemo(() => serverName(props.conn))
|
||||||
|
|
||||||
const check = () => {
|
const check = () => {
|
||||||
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
||||||
@@ -52,35 +54,71 @@ export function ServerRow(props: ServerRowProps) {
|
|||||||
|
|
||||||
const tooltipValue = () => (
|
const tooltipValue = () => (
|
||||||
<span class="flex items-center gap-2">
|
<span class="flex items-center gap-2">
|
||||||
<span>{name()}</span>
|
<span>{serverName(props.conn, true)}</span>
|
||||||
<Show when={props.status?.version}>
|
<Show when={props.status?.version}>
|
||||||
<span class="text-text-invert-base">{props.status?.version}</span>
|
<span class="text-text-invert-weak">v{props.status?.version}</span>
|
||||||
</Show>
|
</Show>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const badge = children(() => props.badge)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
|
<Tooltip
|
||||||
|
class="flex-1"
|
||||||
|
value={tooltipValue()}
|
||||||
|
placement="top-start"
|
||||||
|
inactive={!truncated() && !props.conn.displayName}
|
||||||
|
>
|
||||||
<div class={props.class} classList={{ "opacity-50": props.dimmed }}>
|
<div class={props.class} classList={{ "opacity-50": props.dimmed }}>
|
||||||
<div
|
<div class="flex flex-col items-start">
|
||||||
classList={{
|
<div class="flex flex-row items-center gap-2">
|
||||||
"size-1.5 rounded-full shrink-0": true,
|
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
|
||||||
"bg-icon-success-base": props.status?.healthy === true,
|
{name()}
|
||||||
"bg-icon-critical-base": props.status?.healthy === false,
|
</span>
|
||||||
"bg-border-weak-base": props.status === undefined,
|
<Show
|
||||||
}}
|
when={badge()}
|
||||||
/>
|
fallback={
|
||||||
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
|
<Show when={props.status?.version}>
|
||||||
{name()}
|
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
|
||||||
</span>
|
v{props.status?.version}
|
||||||
<Show when={props.status?.version}>
|
</span>
|
||||||
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
|
</Show>
|
||||||
{props.status?.version}
|
}
|
||||||
</span>
|
>
|
||||||
</Show>
|
{(badge) => badge()}
|
||||||
{props.badge}
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show when={props.showCredentials && props.conn.type === "http" && props.conn}>
|
||||||
|
{(conn) => (
|
||||||
|
<div class="flex flex-row gap-3">
|
||||||
|
<span>
|
||||||
|
{conn().http.username ? (
|
||||||
|
<span class="text-text-weak">{conn().http.username}</span>
|
||||||
|
) : (
|
||||||
|
<span class="text-text-weaker">no username</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{conn().http.password && <span class="text-text-weak">••••••••</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ServerHealthIndicator(props: { health?: ServerHealth }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"size-1.5 rounded-full shrink-0": true,
|
||||||
|
"bg-icon-success-base": props.health?.healthy === true,
|
||||||
|
"bg-icon-critical-base": props.health?.healthy === false,
|
||||||
|
"bg-border-weak-base": props.health === undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
|
|
||||||
const usd = createMemo(
|
const usd = createMemo(
|
||||||
() =>
|
() =>
|
||||||
new Intl.NumberFormat(language.locale(), {
|
new Intl.NumberFormat(language.intl(), {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
}),
|
}),
|
||||||
@@ -77,7 +77,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
{(ctx) => (
|
{(ctx) => (
|
||||||
<>
|
<>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.locale())}</span>
|
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.intl())}</span>
|
||||||
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
|
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { same } from "@/utils/same"
|
|||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Accordion } from "@opencode-ai/ui/accordion"
|
import { Accordion } from "@opencode-ai/ui/accordion"
|
||||||
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||||
import { Code } from "@opencode-ai/ui/code"
|
import { File } from "@opencode-ai/ui/file"
|
||||||
import { Markdown } from "@opencode-ai/ui/markdown"
|
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"
|
||||||
@@ -47,7 +47,8 @@ function RawMessageContent(props: { message: Message; getParts: (id: string) =>
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Code
|
<File
|
||||||
|
mode="text"
|
||||||
file={file()}
|
file={file()}
|
||||||
overflow="wrap"
|
overflow="wrap"
|
||||||
class="select-text"
|
class="select-text"
|
||||||
@@ -127,7 +128,7 @@ export function SessionContextTab() {
|
|||||||
|
|
||||||
const usd = createMemo(
|
const usd = createMemo(
|
||||||
() =>
|
() =>
|
||||||
new Intl.NumberFormat(language.locale(), {
|
new Intl.NumberFormat(language.intl(), {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
}),
|
}),
|
||||||
@@ -135,7 +136,7 @@ export function SessionContextTab() {
|
|||||||
|
|
||||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
|
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
|
||||||
const ctx = createMemo(() => metrics().context)
|
const ctx = createMemo(() => metrics().context)
|
||||||
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
|
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||||
|
|
||||||
const cost = createMemo(() => {
|
const cost = createMemo(() => {
|
||||||
return usd().format(metrics().totalCost)
|
return usd().format(metrics().totalCost)
|
||||||
@@ -199,7 +200,7 @@ export function SessionContextTab() {
|
|||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
|
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
|
||||||
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
|
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) },
|
||||||
{ label: "context.stats.provider", value: providerLabel },
|
{ label: "context.stats.provider", value: providerLabel },
|
||||||
{ label: "context.stats.model", value: modelLabel },
|
{ label: "context.stats.model", value: modelLabel },
|
||||||
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
|
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
|
||||||
@@ -212,8 +213,8 @@ export function SessionContextTab() {
|
|||||||
label: "context.stats.cacheTokens",
|
label: "context.stats.cacheTokens",
|
||||||
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
|
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
|
||||||
},
|
},
|
||||||
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
|
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
|
||||||
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
|
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
|
||||||
{ label: "context.stats.totalCost", value: cost },
|
{ label: "context.stats.totalCost", value: cost },
|
||||||
{ label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) },
|
{ label: "context.stats.sessionCreated", value: () => formatter().time(info()?.time.created) },
|
||||||
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
|
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
|
||||||
@@ -306,7 +307,7 @@ export function SessionContextTab() {
|
|||||||
<div class="flex items-center gap-1 text-11-regular text-text-weak">
|
<div class="flex items-center gap-1 text-11-regular text-text-weak">
|
||||||
<div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
|
<div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
|
||||||
<div>{breakdownLabel(segment.key)}</div>
|
<div>{breakdownLabel(segment.key)}</div>
|
||||||
<div class="text-text-weaker">{segment.percent.toLocaleString(language.locale())}%</div>
|
<div class="text-text-weaker">{segment.percent.toLocaleString(language.intl())}%</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ 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 { 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"
|
||||||
@@ -35,6 +37,7 @@ const OPEN_APPS = [
|
|||||||
"terminal",
|
"terminal",
|
||||||
"iterm2",
|
"iterm2",
|
||||||
"ghostty",
|
"ghostty",
|
||||||
|
"warp",
|
||||||
"xcode",
|
"xcode",
|
||||||
"android-studio",
|
"android-studio",
|
||||||
"powershell",
|
"powershell",
|
||||||
@@ -63,6 +66,7 @@ const MAC_APPS = [
|
|||||||
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
|
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
|
||||||
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
|
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
|
||||||
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
|
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
|
||||||
|
{ id: "warp", label: "Warp", icon: "warp", openWith: "Warp" },
|
||||||
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
|
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
|
||||||
{
|
{
|
||||||
id: "android-studio",
|
id: "android-studio",
|
||||||
@@ -136,12 +140,12 @@ function useSessionShare(args: {
|
|||||||
globalSDK: ReturnType<typeof useGlobalSDK>
|
globalSDK: ReturnType<typeof useGlobalSDK>
|
||||||
currentSession: () =>
|
currentSession: () =>
|
||||||
| {
|
| {
|
||||||
id: string
|
|
||||||
share?: {
|
share?: {
|
||||||
url?: string
|
url?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
|
sessionID: () => string | undefined
|
||||||
projectDirectory: () => string
|
projectDirectory: () => string
|
||||||
platform: ReturnType<typeof usePlatform>
|
platform: ReturnType<typeof usePlatform>
|
||||||
}) {
|
}) {
|
||||||
@@ -165,11 +169,11 @@ function useSessionShare(args: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const shareSession = () => {
|
const shareSession = () => {
|
||||||
const session = args.currentSession()
|
const sessionID = args.sessionID()
|
||||||
if (!session || state.share) return
|
if (!sessionID || state.share) return
|
||||||
setState("share", true)
|
setState("share", true)
|
||||||
args.globalSDK.client.session
|
args.globalSDK.client.session
|
||||||
.share({ sessionID: session.id, directory: args.projectDirectory() })
|
.share({ sessionID, directory: args.projectDirectory() })
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Failed to share session", error)
|
console.error("Failed to share session", error)
|
||||||
})
|
})
|
||||||
@@ -179,11 +183,11 @@ function useSessionShare(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const unshareSession = () => {
|
const unshareSession = () => {
|
||||||
const session = args.currentSession()
|
const sessionID = args.sessionID()
|
||||||
if (!session || state.unshare) return
|
if (!sessionID || state.unshare) return
|
||||||
setState("unshare", true)
|
setState("unshare", true)
|
||||||
args.globalSDK.client.session
|
args.globalSDK.client.session
|
||||||
.unshare({ sessionID: session.id, directory: args.projectDirectory() })
|
.unshare({ sessionID, directory: args.projectDirectory() })
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Failed to unshare session", error)
|
console.error("Failed to unshare session", error)
|
||||||
})
|
})
|
||||||
@@ -227,6 +231,7 @@ export function SessionHeader() {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const terminal = useTerminal()
|
||||||
|
|
||||||
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
|
||||||
const project = createMemo(() => {
|
const project = createMemo(() => {
|
||||||
@@ -241,9 +246,9 @@ export function SessionHeader() {
|
|||||||
})
|
})
|
||||||
const hotkey = createMemo(() => command.keybind("file.open"))
|
const hotkey = createMemo(() => command.keybind("file.open"))
|
||||||
|
|
||||||
const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
|
const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||||
const showShare = createMemo(() => shareEnabled() && !!currentSession())
|
const showShare = createMemo(() => shareEnabled() && !!params.id)
|
||||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||||
const view = createMemo(() => layout.view(sessionKey))
|
const view = createMemo(() => layout.view(sessionKey))
|
||||||
const os = createMemo(() => detectOS(platform))
|
const os = createMemo(() => detectOS(platform))
|
||||||
@@ -294,6 +299,16 @@ export function SessionHeader() {
|
|||||||
] 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({
|
||||||
@@ -301,14 +316,18 @@ export function SessionHeader() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
|
||||||
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
|
const current = createMemo(
|
||||||
|
() =>
|
||||||
|
options().find((o) => o.id === prefs.app) ??
|
||||||
|
options()[0] ??
|
||||||
|
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
|
||||||
|
)
|
||||||
const opening = createMemo(() => openRequest.app !== undefined)
|
const opening = createMemo(() => openRequest.app !== undefined)
|
||||||
|
|
||||||
createEffect(() => {
|
const selectApp = (app: OpenApp) => {
|
||||||
const value = prefs.app
|
if (!options().some((item) => item.id === app)) return
|
||||||
if (options().some((o) => o.id === value)) return
|
setPrefs("app", app)
|
||||||
setPrefs("app", options()[0]?.id ?? "finder")
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const openDir = (app: OpenApp) => {
|
const openDir = (app: OpenApp) => {
|
||||||
if (opening() || !canOpen() || !platform.openPath) return
|
if (opening() || !canOpen() || !platform.openPath) return
|
||||||
@@ -345,6 +364,7 @@ export function SessionHeader() {
|
|||||||
const share = useSessionShare({
|
const share = useSessionShare({
|
||||||
globalSDK,
|
globalSDK,
|
||||||
currentSession,
|
currentSession,
|
||||||
|
sessionID: () => params.id,
|
||||||
projectDirectory,
|
projectDirectory,
|
||||||
platform,
|
platform,
|
||||||
})
|
})
|
||||||
@@ -428,7 +448,7 @@ export function SessionHeader() {
|
|||||||
<Spinner class="size-3.5 text-icon-base" />
|
<Spinner class="size-3.5 text-icon-base" />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-12-regular text-text-strong">Open</span>
|
<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" />
|
<div class="self-stretch w-px bg-border-weak-base" />
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
@@ -456,7 +476,7 @@ export function SessionHeader() {
|
|||||||
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
|
||||||
setPrefs("app", value as OpenApp)
|
selectApp(value as OpenApp)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<For each={options()}>
|
<For each={options()}>
|
||||||
@@ -610,39 +630,39 @@ export function SessionHeader() {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</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">
|
<TooltipKeybind
|
||||||
<TooltipKeybind
|
title={language.t("command.terminal.toggle")}
|
||||||
title={language.t("command.terminal.toggle")}
|
keybind={command.keybind("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
|
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
||||||
variant="ghost"
|
<Icon
|
||||||
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border"
|
size="small"
|
||||||
onClick={() => view().terminal.toggle()}
|
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
|
||||||
aria-label={language.t("command.terminal.toggle")}
|
class="group-hover/terminal-toggle:hidden"
|
||||||
aria-expanded={view().terminal.opened()}
|
/>
|
||||||
aria-controls="terminal-panel"
|
<Icon
|
||||||
>
|
size="small"
|
||||||
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
|
name="layout-bottom-partial"
|
||||||
<Icon
|
class="hidden group-hover/terminal-toggle:inline-block"
|
||||||
size="small"
|
/>
|
||||||
name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
|
<Icon
|
||||||
class="group-hover/terminal-toggle:hidden"
|
size="small"
|
||||||
/>
|
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
|
||||||
<Icon
|
class="hidden group-active/terminal-toggle:inline-block"
|
||||||
size="small"
|
/>
|
||||||
name="layout-bottom-partial"
|
</div>
|
||||||
class="hidden group-hover/terminal-toggle:inline-block"
|
</Button>
|
||||||
/>
|
</TooltipKeybind>
|
||||||
<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")}
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ import { useSync } from "@/context/sync"
|
|||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Mark } from "@opencode-ai/ui/logo"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||||
|
|
||||||
const MAIN_WORKTREE = "main"
|
const MAIN_WORKTREE = "main"
|
||||||
const CREATE_WORKTREE = "create"
|
const CREATE_WORKTREE = "create"
|
||||||
const ROOT_CLASS =
|
const ROOT_CLASS = "size-full flex flex-col"
|
||||||
"size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-16"
|
|
||||||
|
|
||||||
interface NewSessionViewProps {
|
interface NewSessionViewProps {
|
||||||
worktree: string
|
worktree: string
|
||||||
@@ -50,33 +50,43 @@ export function NewSessionView(props: NewSessionViewProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={ROOT_CLASS}>
|
<div class={ROOT_CLASS}>
|
||||||
<div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
|
<div class="h-12 shrink-0" aria-hidden />
|
||||||
<div class="flex justify-center items-center gap-3">
|
<div class="flex-1 px-6 pb-30 flex items-center justify-center text-center">
|
||||||
<Icon name="folder" size="small" />
|
<div class="w-full max-w-200 flex flex-col items-center text-center gap-4">
|
||||||
<div class="text-12-medium text-text-weak select-text">
|
<div class="flex flex-col items-center gap-6">
|
||||||
{getDirectory(projectRoot())}
|
<Mark class="w-10" />
|
||||||
<span class="text-text-strong">{getFilename(projectRoot())}</span>
|
<div class="text-20-medium text-text-strong">{language.t("session.new.title")}</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-full flex flex-col gap-4 items-center">
|
||||||
|
<div class="flex items-start justify-center gap-3 min-h-5">
|
||||||
|
<div class="text-12-medium text-text-weak select-text leading-5 min-w-0 max-w-160 break-words text-center">
|
||||||
|
{getDirectory(projectRoot())}
|
||||||
|
<span class="text-text-strong">{getFilename(projectRoot())}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start justify-center gap-1.5 min-h-5">
|
||||||
|
<Icon name="branch" size="small" class="mt-0.5 shrink-0" />
|
||||||
|
<div class="text-12-medium text-text-weak select-text leading-5 min-w-0 max-w-160 break-words text-center">
|
||||||
|
{label(current())}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Show when={sync.project}>
|
||||||
|
{(project) => (
|
||||||
|
<div class="flex items-start justify-center gap-3 min-h-5">
|
||||||
|
<div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center">
|
||||||
|
{language.t("session.new.lastModified")}
|
||||||
|
<span class="text-text-strong">
|
||||||
|
{DateTime.fromMillis(project().time.updated ?? project().time.created)
|
||||||
|
.setLocale(language.intl())
|
||||||
|
.toRelative()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center items-center gap-1">
|
|
||||||
<Icon name="branch" size="small" />
|
|
||||||
<div class="text-12-medium text-text-weak select-text ml-2">{label(current())}</div>
|
|
||||||
</div>
|
|
||||||
<Show when={sync.project}>
|
|
||||||
{(project) => (
|
|
||||||
<div class="flex justify-center items-center gap-3">
|
|
||||||
<Icon name="pencil-line" size="small" />
|
|
||||||
<div class="text-12-medium text-text-weak">
|
|
||||||
{language.t("session.new.lastModified")}
|
|
||||||
<span class="text-text-strong">
|
|
||||||
{DateTime.fromMillis(project().time.updated ?? project().time.created)
|
|
||||||
.setLocale(language.locale())
|
|
||||||
.toRelative()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,15 @@ import { useCommand } from "@/context/command"
|
|||||||
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
|
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div class="flex items-center gap-x-1.5 min-w-0">
|
<div class="flex items-center gap-x-1.5 min-w-0">
|
||||||
<FileIcon
|
<Show
|
||||||
node={{ path: props.path, type: "file" }}
|
when={!props.active}
|
||||||
classList={{
|
fallback={<FileIcon node={{ path: props.path, type: "file" }} class="size-4 shrink-0" />}
|
||||||
"grayscale-100 group-data-[selected]/tab:grayscale-0": !props.active,
|
>
|
||||||
"grayscale-0": props.active,
|
<span class="relative inline-flex size-4 shrink-0">
|
||||||
}}
|
<FileIcon node={{ path: props.path, type: "file" }} class="absolute inset-0 size-4 tab-fileicon-color" />
|
||||||
/>
|
<FileIcon node={{ path: props.path, type: "file" }} mono class="absolute inset-0 size-4 tab-fileicon-mono" />
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
<span class="text-14-medium truncate">{getFilename(props.path)}</span>
|
<span class="text-14-medium truncate">{getFilename(props.path)}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -37,8 +39,8 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
|||||||
return <FileVisual path={value} />
|
return <FileVisual path={value} />
|
||||||
})
|
})
|
||||||
return (
|
return (
|
||||||
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
|
<div use:sortable class="h-full flex items-center" classList={{ "opacity-0": sortable.isActiveDraggable }}>
|
||||||
<div class="relative h-full">
|
<div class="relative">
|
||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
value={props.tab}
|
value={props.tab}
|
||||||
closeButton={
|
closeButton={
|
||||||
@@ -46,6 +48,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
|
|||||||
title={language.t("common.closeTab")}
|
title={language.t("common.closeTab")}
|
||||||
keybind={command.keybind("tab.close")}
|
keybind={command.keybind("tab.close")}
|
||||||
placement="bottom"
|
placement="bottom"
|
||||||
|
gutter={10}
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="close-small"
|
icon="close-small"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user