Compare commits
480
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dee1e7acd | ||
|
|
4ed3afea84 | ||
|
|
3cf7c7536b | ||
|
|
85674f4bfd | ||
|
|
f2525a63c9 | ||
|
|
8c42d391f5 | ||
|
|
7f9bf91073 | ||
|
|
6ce5c01b1a | ||
|
|
a53fae1511 | ||
|
|
4626458175 | ||
|
|
9a5178e4ac | ||
|
|
68384613be | ||
|
|
4f967d5bc0 | ||
|
|
ff60859e36 | ||
|
|
020c47a055 | ||
|
|
64171db173 | ||
|
|
ad265797ab | ||
|
|
b1312a3181 | ||
|
|
a8f9f6b705 | ||
|
|
d312c677c5 | ||
|
|
5b60e51c9f | ||
|
|
7cbe1627ec | ||
|
|
d6840868d4 | ||
|
|
9b2648dd57 | ||
|
|
f954854232 | ||
|
|
6a99079012 | ||
|
|
0a8b6298cd | ||
|
|
f40209bdfb | ||
|
|
a2cb4909da | ||
|
|
7a05ba47d1 | ||
|
|
36745caa2a | ||
|
|
c2403d0f15 | ||
|
|
34e2429c49 | ||
|
|
10ba68c772 | ||
|
|
e8471256f2 | ||
|
|
43b37346b6 | ||
|
|
d199648aeb | ||
|
|
a06f40297b | ||
|
|
59c0fc28ee | ||
|
|
b22add292c | ||
|
|
67aaecacac | ||
|
|
29c202e6ab | ||
|
|
dcbf11f41a | ||
|
|
14ccff4037 | ||
|
|
5b8b874732 | ||
|
|
1d81c0266c | ||
|
|
913120759a | ||
|
|
7a6ce05d09 | ||
|
|
1dc69359d5 | ||
|
|
329fcb040b | ||
|
|
bf50d1c028 | ||
|
|
b8801dbd22 | ||
|
|
f7c6943817 | ||
|
|
91fe4db27c | ||
|
|
21d7a85e76 | ||
|
|
663e798e76 | ||
|
|
5bc2d2498d | ||
|
|
c22e34853d | ||
|
|
6825b0bbc7 | ||
|
|
3644581b55 | ||
|
|
79cc15335e | ||
|
|
ca6200121b | ||
|
|
7239b38b7f | ||
|
|
9ae8dc2d01 | ||
|
|
7164662be2 | ||
|
|
94f71f59a3 | ||
|
|
3eb6508a64 | ||
|
|
6fdb8ab90d | ||
|
|
321bf1f8e1 | ||
|
|
62bd023086 | ||
|
|
cb1a50055c | ||
|
|
65e3348232 | ||
|
|
a6b9f0dac1 | ||
|
|
34f5bdbc99 | ||
|
|
0b4fe14b0a | ||
|
|
7230cd2683 | ||
|
|
a915fe74be | ||
|
|
26d35583c5 | ||
|
|
ae17b416b8 | ||
|
|
8ffadde85c | ||
|
|
3c0ad70653 | ||
|
|
264418c0cd | ||
|
|
fa2c69f09c | ||
|
|
113304a058 | ||
|
|
8c4d49c2bc | ||
|
|
2aa6110c6e | ||
|
|
8b9b9ad31e | ||
|
|
3729fd5706 | ||
|
|
74b14a2d4e | ||
|
|
cdb951ec2f | ||
|
|
fc01cad2b8 | ||
|
|
c1ddc0ea2d | ||
|
|
319b7655b7 | ||
|
|
824c12c01a | ||
|
|
17b2900884 | ||
|
|
003010bdb6 | ||
|
|
82a4292934 | ||
|
|
eea4253d67 | ||
|
|
1eacc3c339 | ||
|
|
1a509d62a0 | ||
|
|
4c4eef46f1 | ||
|
|
d62ec7776e | ||
|
|
cb1e5d9e41 | ||
|
|
ca5f086759 | ||
|
|
57c40eb7c2 | ||
|
|
63035f977f | ||
|
|
514d2a36bc | ||
|
|
0b6fd5f612 | ||
|
|
029e7135b7 | ||
|
|
c43591f8a2 | ||
|
|
a2c22714cb | ||
|
|
312f10f797 | ||
|
|
d1f05b0f3a | ||
|
|
ccb0b320e1 | ||
|
|
5ee7edaf9e | ||
|
|
27190635ea | ||
|
|
2e340d976f | ||
|
|
fe4dfb9f6f | ||
|
|
5e3dc80999 | ||
|
|
d84cc33742 | ||
|
|
c92c462148 | ||
|
|
9ca06e0336 | ||
|
|
3b523b32f5 | ||
|
|
ba3600a515 | ||
|
|
03ce2e5288 | ||
|
|
87e23abb10 | ||
|
|
2868000c20 | ||
|
|
f38f415bf0 | ||
|
|
4341ab838e | ||
|
|
cd004cf0b2 | ||
|
|
19ae8c88b0 | ||
|
|
3dd09147c2 | ||
|
|
9581bf0670 | ||
|
|
af8aff3788 | ||
|
|
2a8a59ded9 | ||
|
|
5917ac2162 | ||
|
|
b6af4d0dc6 | ||
|
|
577139c626 | ||
|
|
c5fb6281f0 | ||
|
|
f99812443c | ||
|
|
b898c6d0ea | ||
|
|
9e7045eaec | ||
|
|
a17ac02061 | ||
|
|
57f9397677 | ||
|
|
a4c686025c | ||
|
|
face879100 | ||
|
|
605559b165 | ||
|
|
5cd4c6eb22 | ||
|
|
40358d60a0 | ||
|
|
96c1c0363d | ||
|
|
33819932ec | ||
|
|
5d6fe01465 | ||
|
|
cf27a73397 | ||
|
|
f2c492a8e6 | ||
|
|
0556774097 | ||
|
|
d9d5a0615e | ||
|
|
d72ddd71fa | ||
|
|
fb26308bc7 | ||
|
|
b41fa8e318 | ||
|
|
57b2e64345 | ||
|
|
346b3e1b8d | ||
|
|
b139bc2ef3 | ||
|
|
378b8ca241 | ||
|
|
f63bdc8e08 | ||
|
|
ce26120205 | ||
|
|
d2d5d84d1e | ||
|
|
847f1d99c9 | ||
|
|
59d08683ea | ||
|
|
f7514d9eca | ||
|
|
180ded6a27 | ||
|
|
bf601628db | ||
|
|
00e39d2114 | ||
|
|
46b74e0873 | ||
|
|
aedc4e964f | ||
|
|
e83404367c | ||
|
|
42206da1f8 | ||
|
|
44f38193c0 | ||
|
|
9a6b455bfe | ||
|
|
8063e0b5c6 | ||
|
|
157c5d77f8 | ||
|
|
ce19c051be | ||
|
|
91786d2fc1 | ||
|
|
eca11ca71a | ||
|
|
17bd16667c | ||
|
|
16c60c9ee7 | ||
|
|
0970b102e1 | ||
|
|
04074d3f4a | ||
|
|
b16ee08fd5 | ||
|
|
98874a09f7 | ||
|
|
877be7e8e0 | ||
|
|
eac50f9151 | ||
|
|
1a902b291c | ||
|
|
bbe4a04f9f | ||
|
|
b2f621b897 | ||
|
|
7202b3a325 | ||
|
|
35b44df94a | ||
|
|
10441efad1 | ||
|
|
3199383eef | ||
|
|
9f54115c5d | ||
|
|
2ecc6ae65f | ||
|
|
02b32e1ba7 | ||
|
|
34b9792654 | ||
|
|
537160dbc0 | ||
|
|
b0600664ab | ||
|
|
581a7692ff | ||
|
|
f73e4d5d31 | ||
|
|
a7743e6467 | ||
|
|
5d3dba666c | ||
|
|
bd53b651a3 | ||
|
|
46da801f30 | ||
|
|
58a99916bb | ||
|
|
c29392d085 | ||
|
|
46f243fea7 | ||
|
|
847fc9d268 | ||
|
|
489f57974d | ||
|
|
3fc3974cbc | ||
|
|
ca57248246 | ||
|
|
ee23043d64 | ||
|
|
9c1c061b84 | ||
|
|
d82b163e56 | ||
|
|
cd8e8a9928 | ||
|
|
8bdcc22541 | ||
|
|
2bdd279467 | ||
|
|
51535d8ef3 | ||
|
|
38f8714c09 | ||
|
|
4961d72c0f | ||
|
|
00cb8839ae | ||
|
|
689b1a4b3a | ||
|
|
d98be39344 | ||
|
|
039c60170d | ||
|
|
cd87d4f9d3 | ||
|
|
988c9894f2 | ||
|
|
ae614d919f | ||
|
|
65cde7f494 | ||
|
|
98325dcdc6 | ||
|
|
0788a535e2 | ||
|
|
b7fab49b64 | ||
|
|
463318486f | ||
|
|
7afb517a1a | ||
|
|
c589724729 | ||
|
|
9385714373 | ||
|
|
c90fc6a486 | ||
|
|
bc1840b196 | ||
|
|
095aeba0a7 | ||
|
|
e945436b6f | ||
|
|
6bfa82de65 | ||
|
|
d83fe4b540 | ||
|
|
81bdffc81c | ||
|
|
2549a38a71 | ||
|
|
5d48e7bd44 | ||
|
|
ec8b9810b4 | ||
|
|
65318a80f7 | ||
|
|
6a5aae9a84 | ||
|
|
1f94c48bdd | ||
|
|
01c5eb679c | ||
|
|
41612b3dbe | ||
|
|
c2d2ca3522 | ||
|
|
3a1ec27feb | ||
|
|
3c96bf8468 | ||
|
|
3ea6413407 | ||
|
|
885df8eb54 | ||
|
|
f4975ef32a | ||
|
|
37883a9f3a | ||
|
|
3c31d04666 | ||
|
|
e64548fb4d | ||
|
|
31f6f43cfc | ||
|
|
090ad8290e | ||
|
|
d1258ac19c | ||
|
|
48c1b6b338 | ||
|
|
40e4cd27a1 | ||
|
|
5a6d10cd53 | ||
|
|
527b51477d | ||
|
|
535343bf56 | ||
|
|
4394e42615 | ||
|
|
2e4c43c1cf | ||
|
|
965c751522 | ||
|
|
24bdd3c9fb | ||
|
|
01f0319192 | ||
|
|
517e6c9aa4 | ||
|
|
a4a9ea4ab0 | ||
|
|
eaa272ef7f | ||
|
|
70b636a360 | ||
|
|
a8fd0159be | ||
|
|
342436dfc4 | ||
|
|
77a462c930 | ||
|
|
9965d385de | ||
|
|
f0f1e51c5c | ||
|
|
4712c18a58 | ||
|
|
9e156ea168 | ||
|
|
68f4aa220e | ||
|
|
3a0e00dd7f | ||
|
|
66b4e5e020 | ||
|
|
8b8d4fa066 | ||
|
|
6253ef0c27 | ||
|
|
c6ebc7ff7c | ||
|
|
985663620f | ||
|
|
c796b9a19e | ||
|
|
6ea108a03b | ||
|
|
280eb16e77 | ||
|
|
930e94a3ea | ||
|
|
629e866ff0 | ||
|
|
c08fa5675f | ||
|
|
cc50b778eb | ||
|
|
00fa68b3a7 | ||
|
|
288eb044cb | ||
|
|
59ca4543d8 | ||
|
|
650d0dbe54 | ||
|
|
a5ec741cff | ||
|
|
fff98636f7 | ||
|
|
c72642dd35 | ||
|
|
f2d4ced8ea | ||
|
|
ae7e2eb3fb | ||
|
|
a32ffaba35 | ||
|
|
a4e75a0794 | ||
|
|
35350b1d25 | ||
|
|
263dcf75b5 | ||
|
|
7994dce0f2 | ||
|
|
fbfa148e4e | ||
|
|
9d57f21f9f | ||
|
|
3deee3a02b | ||
|
|
2002f08f2e | ||
|
|
c307505f8b | ||
|
|
6359d00fb4 | ||
|
|
b969066a20 | ||
|
|
500dcfc586 | ||
|
|
7b8dc8065e | ||
|
|
e89527c9f0 | ||
|
|
aa2239d5de | ||
|
|
8daeacc989 | ||
|
|
81d3ac3bf0 | ||
|
|
eb6f1dada8 | ||
|
|
8e9e79d276 | ||
|
|
38014fe448 | ||
|
|
8942fc21aa | ||
|
|
7f45943a9e | ||
|
|
6e1400fc45 | ||
|
|
bf26c08d51 | ||
|
|
29f7dc073b | ||
|
|
5e1b513527 | ||
|
|
f549fde874 | ||
|
|
6dfb30448c | ||
|
|
b5b5f7e019 | ||
|
|
ae7b49b034 | ||
|
|
f151c660b1 | ||
|
|
c3ef69c866 | ||
|
|
363891126c | ||
|
|
1989704abe | ||
|
|
f0a9ebfed4 | ||
|
|
7e32f80d82 | ||
|
|
966d9cfa41 | ||
|
|
92e820fdc8 | ||
|
|
c4b3971548 | ||
|
|
3faabdadb7 | ||
|
|
93a139315c | ||
|
|
10ca1ace6b | ||
|
|
c3dfd08ba8 | ||
|
|
510a1e8140 | ||
|
|
159ede2d5c | ||
|
|
291a857fb8 | ||
|
|
57a5236e71 | ||
|
|
23c8656080 | ||
|
|
ec3ae17e4d | ||
|
|
69d047ae7d | ||
|
|
327f62526a | ||
|
|
d540d363a7 | ||
|
|
db93891373 | ||
|
|
0f488996b3 | ||
|
|
a6f524ca08 | ||
|
|
811c7e2494 | ||
|
|
ebaa99aba2 | ||
|
|
d66e6dc25f | ||
|
|
336d28f112 | ||
|
|
916afb5220 | ||
|
|
5daf2fa7f0 | ||
|
|
733a3bd031 | ||
|
|
2e8e278441 | ||
|
|
0bae38c062 | ||
|
|
a09b086729 | ||
|
|
df1c6c9e8d | ||
|
|
789d86f7b0 | ||
|
|
e148b318b7 | ||
|
|
0cad775427 | ||
|
|
00d6841f84 | ||
|
|
8a8f7b3e90 | ||
|
|
c526caae7b | ||
|
|
b1c07488bd | ||
|
|
92f8e03160 | ||
|
|
f6fd43e574 | ||
|
|
854484babf | ||
|
|
e4ff1ea778 | ||
|
|
26fb6b8788 | ||
|
|
4214ae205d | ||
|
|
d9d4f895bc | ||
|
|
48db7cf07a | ||
|
|
802d165572 | ||
|
|
f7f41dc3a0 | ||
|
|
1fcfb69bf7 | ||
|
|
fa96cb9c6e | ||
|
|
cc30bfc94b | ||
|
|
880c0a7477 | ||
|
|
eabf3caeb9 | ||
|
|
c9326fc199 | ||
|
|
d7481f4593 | ||
|
|
f3f728ec27 | ||
|
|
c619caefdd | ||
|
|
c559af51ce | ||
|
|
d1e0a4640c | ||
|
|
f9e71ec515 | ||
|
|
ef538c9707 | ||
|
|
2f405daa98 | ||
|
|
a9c85b7c27 | ||
|
|
897d83c589 | ||
|
|
0a125e5d4d | ||
|
|
38d2276592 | ||
|
|
d58004a864 | ||
|
|
5fd833aa18 | ||
|
|
44f83015cd | ||
|
|
9a1c9ae15a | ||
|
|
a3a6cf1c07 | ||
|
|
47a676111a | ||
|
|
1df5ad470a | ||
|
|
506dd75818 | ||
|
|
c8ecd64022 | ||
|
|
ca376a4cff | ||
|
|
7532d99e5b | ||
|
|
181b5f6236 | ||
|
|
6314f09c14 | ||
|
|
4b4b7832aa | ||
|
|
4280307013 | ||
|
|
9b09a7e766 | ||
|
|
3fc0367b93 | ||
|
|
954a6ca88e | ||
|
|
0c03a3ee10 | ||
|
|
53330a518f | ||
|
|
892bdebaac | ||
|
|
18121300f3 | ||
|
|
d6d4446f46 | ||
|
|
26cc924ea2 | ||
|
|
4dd866d5c4 | ||
|
|
beab4cc2c2 | ||
|
|
567a91191a | ||
|
|
434d82bbe2 | ||
|
|
2929774acb | ||
|
|
6e61a46a84 | ||
|
|
2daf4b805a | ||
|
|
7342e650c0 | ||
|
|
8c2e2ecc95 | ||
|
|
25a2b739e6 | ||
|
|
85c16926c4 | ||
|
|
2e78fdec43 | ||
|
|
1fcb920eb4 | ||
|
|
b1e89c344b | ||
|
|
befbedacdc | ||
|
|
2cc738fb17 | ||
|
|
71b20698bb | ||
|
|
3df18dcde1 | ||
|
|
a898c2ea3a | ||
|
|
bf777298c8 | ||
|
|
93fad99f7f | ||
|
|
057848deb8 | ||
|
|
1de06452d3 | ||
|
|
58f60629a1 | ||
|
|
39a47c9b8c | ||
|
|
ea88044f2e | ||
|
|
e6f6f7aff1 | ||
|
|
48e97b47af | ||
|
|
fe120e3cbf | ||
|
|
f2dd774660 | ||
|
|
e7ff0f17c8 | ||
|
|
2ed756c72c | ||
|
|
054f4be185 | ||
|
|
e3e1e9af50 | ||
|
|
c8389cf96d | ||
|
|
c5442d418d | ||
|
|
fa95a61c4e | ||
|
|
9f3c2bd861 | ||
|
|
c2f78224ae | ||
|
|
14f9e21d5c | ||
|
|
8e4bab5181 | ||
|
|
3c32013eb1 |
+6
-1
@@ -11,6 +11,7 @@ adamdotdevin
|
|||||||
-agusbasari29 AI PR slop
|
-agusbasari29 AI PR slop
|
||||||
ariane-emory
|
ariane-emory
|
||||||
-atharvau AI review spamming literally every PR
|
-atharvau AI review spamming literally every PR
|
||||||
|
-borealbytes
|
||||||
-danieljoshuanazareth
|
-danieljoshuanazareth
|
||||||
-danieljoshuanazareth
|
-danieljoshuanazareth
|
||||||
edemaine
|
edemaine
|
||||||
@@ -21,8 +22,12 @@ jayair
|
|||||||
kitlangton
|
kitlangton
|
||||||
kommander
|
kommander
|
||||||
-opencode2026
|
-opencode2026
|
||||||
|
-opencodeengineer bot that spams issues
|
||||||
r44vc0rp
|
r44vc0rp
|
||||||
rekram1-node
|
rekram1-node
|
||||||
|
-ricardo-m-l
|
||||||
|
-robinmordasiewicz
|
||||||
|
simonklee
|
||||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||||
thdxr
|
thdxr
|
||||||
-OpenCodeEngineer bot that spams issues
|
-toastythebot
|
||||||
|
|||||||
@@ -389,6 +389,7 @@ jobs:
|
|||||||
needs:
|
needs:
|
||||||
- build-cli
|
- build-cli
|
||||||
- version
|
- version
|
||||||
|
if: github.repository == 'anomalyco/opencode'
|
||||||
continue-on-error: false
|
continue-on-error: false
|
||||||
env:
|
env:
|
||||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
@@ -421,7 +422,6 @@ jobs:
|
|||||||
target: aarch64-unknown-linux-gnu
|
target: aarch64-unknown-linux-gnu
|
||||||
platform_flag: --linux
|
platform_flag: --linux
|
||||||
runs-on: ${{ matrix.settings.host }}
|
runs-on: ${{ matrix.settings.host }}
|
||||||
# if: github.ref_name == 'beta'
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
@@ -547,6 +547,7 @@ jobs:
|
|||||||
- sign-cli-windows
|
- sign-cli-windows
|
||||||
- build-tauri
|
- build-tauri
|
||||||
- build-electron
|
- build-electron
|
||||||
|
if: always() && !failure() && !cancelled()
|
||||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ concurrency:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
checks: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
unit:
|
unit:
|
||||||
@@ -37,6 +41,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: ./.github/actions/setup-bun
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
@@ -45,8 +54,39 @@ jobs:
|
|||||||
git config --global user.email "bot@opencode.ai"
|
git config --global user.email "bot@opencode.ai"
|
||||||
git config --global user.name "opencode"
|
git config --global user.name "opencode"
|
||||||
|
|
||||||
|
- name: Cache Turbo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules/.cache/turbo
|
||||||
|
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-
|
||||||
|
turbo-${{ runner.os }}-
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: bun turbo test
|
run: bun turbo test:ci
|
||||||
|
env:
|
||||||
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||||
|
|
||||||
|
- name: Publish unit reports
|
||||||
|
if: always()
|
||||||
|
uses: mikepenz/action-junit-report@v6
|
||||||
|
with:
|
||||||
|
report_paths: packages/*/.artifacts/unit/junit.xml
|
||||||
|
check_name: "unit results (${{ matrix.settings.name }})"
|
||||||
|
detailed_summary: true
|
||||||
|
include_time_in_summary: true
|
||||||
|
fail_on_failure: false
|
||||||
|
|
||||||
|
- name: Upload unit artifacts
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
||||||
|
include-hidden-files: true
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
|
path: packages/*/.artifacts/unit/junit.xml
|
||||||
|
|
||||||
e2e:
|
e2e:
|
||||||
name: e2e (${{ matrix.settings.name }})
|
name: e2e (${{ matrix.settings.name }})
|
||||||
@@ -70,6 +110,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
|
||||||
- name: Setup Bun
|
- name: Setup Bun
|
||||||
uses: ./.github/actions/setup-bun
|
uses: ./.github/actions/setup-bun
|
||||||
|
|
||||||
@@ -100,15 +145,17 @@ jobs:
|
|||||||
run: bun --cwd packages/app test:e2e:local
|
run: bun --cwd packages/app test:e2e:local
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
|
PLAYWRIGHT_JUNIT_OUTPUT: e2e/junit-${{ matrix.settings.name }}.xml
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|
||||||
- name: Upload Playwright artifacts
|
- name: Upload Playwright artifacts
|
||||||
if: failure()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
path: |
|
path: |
|
||||||
|
packages/app/e2e/junit-*.xml
|
||||||
packages/app/e2e/test-results
|
packages/app/e2e/test-results
|
||||||
packages/app/e2e/playwright-report
|
packages/app/e2e/playwright-report
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ plans
|
|||||||
package.json
|
package.json
|
||||||
bun.lock
|
bun.lock
|
||||||
.gitignore
|
.gitignore
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
references/
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
---
|
---
|
||||||
model: opencode/kimi-k2.5
|
model: opencode/gpt-5.4
|
||||||
---
|
---
|
||||||
|
|
||||||
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.
|
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.
|
||||||
If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely.
|
If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely.
|
||||||
Do not preserve, merge, or reuse text from the existing file.
|
Do not preserve, merge, or reuse text from the existing file.
|
||||||
|
|
||||||
Any command arguments are passed directly to `bun script/changelog.ts`.
|
|
||||||
Use `--from` / `-f` and `--to` / `-t` to preview a specific release range.
|
|
||||||
|
|
||||||
The input already contains the exact commit range since the last non-draft release.
|
The input already contains the exact commit range since the last non-draft release.
|
||||||
The commits are already filtered to the release-relevant packages and grouped into
|
The commits are already filtered to the release-relevant packages and grouped into
|
||||||
the release sections. Do not fetch GitHub releases, PRs, or build your own commit list.
|
the release sections. Do not fetch GitHub releases, PRs, or build your own commit list.
|
||||||
The input may also include a `## Community Contributors Input` section.
|
The input may also include a `## Community Contributors Input` section.
|
||||||
|
|
||||||
Before writing any entry you keep, inspect the real diff with
|
Before writing any entry you keep, inspect the real diff with
|
||||||
`git show --stat --format='' <hash>` or `git show --format='' <hash>` so the
|
`git show --stat --format='' <hash>` or `git show --format='' <hash>` so you can
|
||||||
summary reflects the actual user-facing change and not just the commit message.
|
understand the actual code changes and not just the commit message (they may be misleading).
|
||||||
Do not use `git log` or author metadata when deciding attribution.
|
Do not use `git log` or author metadata when deciding attribution.
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
@@ -38,7 +35,12 @@ Rules:
|
|||||||
- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block
|
- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block
|
||||||
- Do not derive the thank-you section from the main summary bullets
|
- Do not derive the thank-you section from the main summary bullets
|
||||||
- Do not include the heading `## Community Contributors Input` in the final file
|
- Do not include the heading `## Community Contributors Input` in the final file
|
||||||
|
- Focus on writing the least words to get your point across - users will skim read the changelog, so we should be precise
|
||||||
|
|
||||||
## Changelog Input
|
**Importantly, the changelog is for users (who are at least slightly technical), they may use the TUI, Desktop, SDK, Plugins and so forth. Be thorough in understanding flow on effects may not be immediately apparent. e.g. a package upgrade looks internal but may patch a bug. Or a refactor may also stabilise some race condition that fixes bugs for users. The PR title/body + commit message will give you the authors context, usually containing the outcome not just technical detail**
|
||||||
|
|
||||||
!`bun script/changelog.ts $ARGUMENTS`
|
<changelog_input>
|
||||||
|
|
||||||
|
!`bun script/raw-changelog.ts $ARGUMENTS`
|
||||||
|
|
||||||
|
</changelog_input>
|
||||||
|
|||||||
@@ -653,23 +653,30 @@ const home = (api: TuiPluginApi, input: Cfg) => ({
|
|||||||
const skin = look(ctx.theme.current)
|
const skin = look(ctx.theme.current)
|
||||||
type Prompt = (props: {
|
type Prompt = (props: {
|
||||||
workspaceID?: string
|
workspaceID?: string
|
||||||
|
visible?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
onSubmit?: () => void
|
||||||
hint?: JSX.Element
|
hint?: JSX.Element
|
||||||
|
right?: JSX.Element
|
||||||
|
showPlaceholder?: boolean
|
||||||
placeholders?: {
|
placeholders?: {
|
||||||
normal?: string[]
|
normal?: string[]
|
||||||
shell?: string[]
|
shell?: string[]
|
||||||
}
|
}
|
||||||
}) => JSX.Element
|
}) => JSX.Element
|
||||||
if (!("Prompt" in api.ui)) return null
|
type Slot = (
|
||||||
const view = api.ui.Prompt
|
props: { name: string; mode?: unknown; children?: JSX.Element } & Record<string, unknown>,
|
||||||
if (typeof view !== "function") return null
|
) => JSX.Element | null
|
||||||
const Prompt = view as Prompt
|
const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot }
|
||||||
|
const Prompt = ui.Prompt
|
||||||
|
const Slot = ui.Slot
|
||||||
const normal = [
|
const normal = [
|
||||||
`[SMOKE] route check for ${input.label}`,
|
`[SMOKE] route check for ${input.label}`,
|
||||||
"[SMOKE] confirm home_prompt slot override",
|
"[SMOKE] confirm home_prompt slot override",
|
||||||
"[SMOKE] verify api.ui.Prompt rendering",
|
"[SMOKE] verify prompt-right slot passthrough",
|
||||||
]
|
]
|
||||||
const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"]
|
const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"]
|
||||||
const Hint = (
|
const hint = (
|
||||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||||
<text fg={skin.muted}>
|
<text fg={skin.muted}>
|
||||||
<span style={{ fg: skin.accent }}>•</span> smoke home prompt
|
<span style={{ fg: skin.accent }}>•</span> smoke home prompt
|
||||||
@@ -677,7 +684,46 @@ const home = (api: TuiPluginApi, input: Cfg) => ({
|
|||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
|
|
||||||
return <Prompt workspaceID={value.workspace_id} hint={Hint} placeholders={{ normal, shell }} />
|
return (
|
||||||
|
<Prompt
|
||||||
|
workspaceID={value.workspace_id}
|
||||||
|
hint={hint}
|
||||||
|
right={
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<Slot name="home_prompt_right" workspace_id={value.workspace_id} />
|
||||||
|
<Slot name="smoke_prompt_right" workspace_id={value.workspace_id} label={input.label} />
|
||||||
|
</box>
|
||||||
|
}
|
||||||
|
placeholders={{ normal, shell }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
home_prompt_right(ctx, value) {
|
||||||
|
const skin = look(ctx.theme.current)
|
||||||
|
const id = value.workspace_id?.slice(0, 8) ?? "none"
|
||||||
|
return (
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
<span style={{ fg: skin.accent }}>{input.label}</span> home:{id}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
session_prompt_right(ctx, value) {
|
||||||
|
const skin = look(ctx.theme.current)
|
||||||
|
return (
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
<span style={{ fg: skin.accent }}>{input.label}</span> session:{value.session_id.slice(0, 8)}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
smoke_prompt_right(ctx, value) {
|
||||||
|
const skin = look(ctx.theme.current)
|
||||||
|
const id = typeof value.workspace_id === "string" ? value.workspace_id.slice(0, 8) : "none"
|
||||||
|
const label = typeof value.label === "string" ? value.label : input.label
|
||||||
|
return (
|
||||||
|
<text fg={skin.muted}>
|
||||||
|
<span style={{ fg: skin.accent }}>{label}</span> custom:{id}
|
||||||
|
</text>
|
||||||
|
)
|
||||||
},
|
},
|
||||||
home_bottom(ctx) {
|
home_bottom(ctx) {
|
||||||
const skin = look(ctx.theme.current)
|
const skin = look(ctx.theme.current)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: effect
|
||||||
|
description: Answer questions about the Effect framework
|
||||||
|
---
|
||||||
|
|
||||||
|
# Effect
|
||||||
|
|
||||||
|
This codebase uses Effect, a framework for writing typescript.
|
||||||
|
|
||||||
|
## How to Answer Effect Questions
|
||||||
|
|
||||||
|
1. Clone the Effect repository: `https://github.com/Effect-TS/effect-smol` to
|
||||||
|
`.opencode/references/effect-smol` in this project NOT the skill folder.
|
||||||
|
2. Use the explore agent to search the codebase for answers about Effect patterns, APIs, and concepts
|
||||||
|
3. Provide responses based on the actual Effect source code and documentation
|
||||||
|
|
||||||
|
## Guidelines
|
||||||
|
|
||||||
|
- Always use the explore agent with the cloned repository when answering Effect-related questions
|
||||||
|
- Reference specific files and patterns found in the Effect codebase
|
||||||
|
- Do not answer from memory - always verify against the source
|
||||||
@@ -116,8 +116,8 @@
|
|||||||
"light": "nord5"
|
"light": "nord5"
|
||||||
},
|
},
|
||||||
"diffLineNumber": {
|
"diffLineNumber": {
|
||||||
"dark": "nord2",
|
"dark": "#abafb7",
|
||||||
"light": "nord4"
|
"light": "textMuted"
|
||||||
},
|
},
|
||||||
"diffAddedLineNumberBg": {
|
"diffAddedLineNumberBg": {
|
||||||
"dark": "#3B4252",
|
"dark": "#3B4252",
|
||||||
|
|||||||
@@ -109,6 +109,12 @@ const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50",
|
|||||||
appliesToProducts: [zenLiteProduct.id],
|
appliesToProducts: [zenLiteProduct.id],
|
||||||
duration: "once",
|
duration: "once",
|
||||||
})
|
})
|
||||||
|
const zenLiteCouponFirstMonth100 = new stripe.Coupon("ZenLiteCouponFirstMonth100", {
|
||||||
|
name: "First month 100% off",
|
||||||
|
percentOff: 100,
|
||||||
|
appliesToProducts: [zenLiteProduct.id],
|
||||||
|
duration: "once",
|
||||||
|
})
|
||||||
const zenLitePrice = new stripe.Price("ZenLitePrice", {
|
const zenLitePrice = new stripe.Price("ZenLitePrice", {
|
||||||
product: zenLiteProduct.id,
|
product: zenLiteProduct.id,
|
||||||
currency: "usd",
|
currency: "usd",
|
||||||
@@ -124,6 +130,7 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
|
|||||||
price: zenLitePrice.id,
|
price: zenLitePrice.id,
|
||||||
priceInr: 92900,
|
priceInr: 92900,
|
||||||
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
|
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
|
||||||
|
firstMonth100Coupon: zenLiteCouponFirstMonth100.id,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -229,6 +236,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
SALESFORCE_INSTANCE_URL,
|
SALESFORCE_INSTANCE_URL,
|
||||||
ZEN_BLACK_PRICE,
|
ZEN_BLACK_PRICE,
|
||||||
ZEN_LITE_PRICE,
|
ZEN_LITE_PRICE,
|
||||||
|
new sst.Secret("ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES"),
|
||||||
new sst.Secret("ZEN_LIMITS"),
|
new sst.Secret("ZEN_LIMITS"),
|
||||||
new sst.Secret("ZEN_SESSION_SECRET"),
|
new sst.Secret("ZEN_SESSION_SECRET"),
|
||||||
...ZEN_MODELS,
|
...ZEN_MODELS,
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-0JgEA54d1ZZ0IWUmxCJP2fnQ2cpmLO25G+hafUbHFLw=",
|
"x86_64-linux": "sha256-1qeXIfqPCo5Px0ebdTvL09kav5Ib79E35Ed1FgiLnPY=",
|
||||||
"aarch64-linux": "sha256-oB4Ptc+MH76MEw0DZodmCCz87qOmbzi26751ZM4DYyE=",
|
"aarch64-linux": "sha256-987XZxDqDf6gm0kjXg606BSEYvryrUD8vZopsADQIN8=",
|
||||||
"aarch64-darwin": "sha256-712rb7B5gTRz1uTx4cJQSrmq9DoBUe+UxbvawYV4XTE=",
|
"aarch64-darwin": "sha256-RI3D3bPxotudTWmdQNIPZ/oBrmDl5PAdJGc93M4bKHs=",
|
||||||
"x86_64-darwin": "sha256-GRCiEBDDEyVx1et04xqdIEQr3ykRMOBJoQy/xddSsCA="
|
"x86_64-darwin": "sha256-LE6hrDPXWKjcePc1nv+O6tIN0ZXUrzWI1XBN6Fm/NKw="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-7
@@ -12,7 +12,9 @@
|
|||||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||||
"dev:storybook": "bun --cwd packages/storybook storybook",
|
"dev:storybook": "bun --cwd packages/storybook storybook",
|
||||||
"typecheck": "bun turbo typecheck",
|
"typecheck": "bun turbo typecheck",
|
||||||
|
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
|
"generate:check": "bun ./script/check-generate.ts",
|
||||||
"random": "echo 'Random script'",
|
"random": "echo 'Random script'",
|
||||||
"hello": "echo 'Hello World!'",
|
"hello": "echo 'Hello World!'",
|
||||||
"test": "echo 'do not run tests from root' && exit 1"
|
"test": "echo 'do not run tests from root' && exit 1"
|
||||||
@@ -25,8 +27,9 @@
|
|||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@effect/platform-node": "4.0.0-beta.42",
|
"@effect/platform-node": "4.0.0-beta.46",
|
||||||
"@types/bun": "1.3.11",
|
"@types/bun": "1.3.11",
|
||||||
|
"@types/cross-spawn": "6.0.6",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
@@ -45,8 +48,9 @@
|
|||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||||
"effect": "4.0.0-beta.42",
|
"effect": "4.0.0-beta.46",
|
||||||
"ai": "6.0.138",
|
"ai": "6.0.158",
|
||||||
|
"cross-spawn": "7.0.6",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
"fuzzysort": "3.1.0",
|
"fuzzysort": "3.1.0",
|
||||||
@@ -68,7 +72,8 @@
|
|||||||
"@solidjs/router": "0.15.4",
|
"@solidjs/router": "0.15.4",
|
||||||
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
|
||||||
"solid-js": "1.9.10",
|
"solid-js": "1.9.10",
|
||||||
"vite-plugin-solid": "2.11.10"
|
"vite-plugin-solid": "2.11.10",
|
||||||
|
"@lydell/node-pty": "1.2.0-beta.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -88,6 +93,7 @@
|
|||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
"heap-snapshot-toolkit": "1.1.3",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -101,6 +107,7 @@
|
|||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"esbuild",
|
"esbuild",
|
||||||
|
"node-pty",
|
||||||
"protobufjs",
|
"protobufjs",
|
||||||
"tree-sitter",
|
"tree-sitter",
|
||||||
"tree-sitter-bash",
|
"tree-sitter-bash",
|
||||||
@@ -114,8 +121,6 @@
|
|||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
|
||||||
"@ai-sdk/provider-utils@4.0.21": "patches/@ai-sdk%2Fprovider-utils@4.0.21.patch",
|
|
||||||
"@ai-sdk/anthropic@3.0.64": "patches/@ai-sdk%2Fanthropic@3.0.64.patch"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,8 +59,10 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
|||||||
### Using Fixtures
|
### Using Fixtures
|
||||||
|
|
||||||
- `page` - Playwright page
|
- `page` - Playwright page
|
||||||
- `sdk` - OpenCode SDK client for API calls
|
- `llm` - Mock LLM server for queuing responses (`text`, `tool`, `toolMatch`, `textMatch`, etc.)
|
||||||
- `gotoSession(sessionID?)` - Navigate to session
|
- `project` - Golden-path project fixture (call `project.open()` first, then use `project.sdk`, `project.prompt(...)`, `project.gotoSession(...)`, `project.trackSession(...)`)
|
||||||
|
- `sdk` - OpenCode SDK client for API calls (worker-scoped, shared directory)
|
||||||
|
- `gotoSession(sessionID?)` - Navigate to session (worker-scoped, shared directory)
|
||||||
|
|
||||||
### Helper Functions
|
### Helper Functions
|
||||||
|
|
||||||
@@ -73,12 +75,9 @@ test("test description", async ({ page, sdk, gotoSession }) => {
|
|||||||
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
|
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
|
||||||
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
|
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
|
||||||
- `withSession(sdk, title, callback)` - Create temp session
|
- `withSession(sdk, title, callback)` - Create temp session
|
||||||
- `withProject(...)` - Create temp project/workspace
|
|
||||||
- `sessionIDFromUrl(url)` - Read session ID from URL
|
- `sessionIDFromUrl(url)` - Read session ID from URL
|
||||||
- `slugFromUrl(url)` - Read workspace slug from URL
|
- `slugFromUrl(url)` - Read workspace slug from URL
|
||||||
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
|
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
|
||||||
- `trackSession(sessionID, directory?)` - Register session for fixture cleanup
|
|
||||||
- `trackDirectory(directory)` - Register directory for fixture cleanup
|
|
||||||
- `clickListItem(container, filter)` - Click list item by key/text
|
- `clickListItem(container, filter)` - Click list item by key/text
|
||||||
|
|
||||||
**Selectors** (`selectors.ts`):
|
**Selectors** (`selectors.ts`):
|
||||||
@@ -128,9 +127,9 @@ test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
- Prefer `withSession(...)` for temp sessions
|
- Prefer the `project` fixture for tests that need a dedicated project with LLM mocking — call `project.open()` then use `project.prompt(...)`, `project.trackSession(...)`, etc.
|
||||||
- In `withProject(...)` tests that create sessions or extra workspaces, call `trackSession(sessionID, directory?)` and `trackDirectory(directory)`
|
- Use `withSession(sdk, title, callback)` for lightweight temp sessions on the shared worker directory
|
||||||
- This lets fixture teardown abort, wait for idle, and clean up safely under CI concurrency
|
- Call `project.trackSession(sessionID, directory?)` and `project.trackDirectory(directory)` for any resources created outside the fixture so teardown can clean them up
|
||||||
- Avoid calling `sdk.session.delete(...)` directly
|
- Avoid calling `sdk.session.delete(...)` directly
|
||||||
|
|
||||||
### Timeouts
|
### Timeouts
|
||||||
|
|||||||
+93
-165
@@ -7,7 +7,6 @@ import { execSync } from "node:child_process"
|
|||||||
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
|
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
|
||||||
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||||
import {
|
import {
|
||||||
dropdownMenuTriggerSelector,
|
|
||||||
dropdownMenuContentSelector,
|
dropdownMenuContentSelector,
|
||||||
projectSwitchSelector,
|
projectSwitchSelector,
|
||||||
projectMenuTriggerSelector,
|
projectMenuTriggerSelector,
|
||||||
@@ -206,7 +205,7 @@ export async function closeDialog(page: Page, dialog: Locator) {
|
|||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isSidebarClosed(page: Page) {
|
async function isSidebarClosed(page: Page) {
|
||||||
const button = await waitSidebarButton(page, "isSidebarClosed")
|
const button = await waitSidebarButton(page, "isSidebarClosed")
|
||||||
return (await button.getAttribute("aria-expanded")) !== "true"
|
return (await button.getAttribute("aria-expanded")) !== "true"
|
||||||
}
|
}
|
||||||
@@ -237,7 +236,7 @@ async function errorBoundaryText(page: Page) {
|
|||||||
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
|
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function assertHealthy(page: Page, context: string) {
|
async function assertHealthy(page: Page, context: string) {
|
||||||
const text = await errorBoundaryText(page)
|
const text = await errorBoundaryText(page)
|
||||||
if (!text) return
|
if (!text) return
|
||||||
console.log(`[e2e:error-boundary][${context}]\n${text}`)
|
console.log(`[e2e:error-boundary][${context}]\n${text}`)
|
||||||
@@ -312,61 +311,7 @@ export async function openSettings(page: Page) {
|
|||||||
return dialog
|
return dialog
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
|
export async function createTestProject(input?: { serverUrl?: string }) {
|
||||||
await page.addInitScript(
|
|
||||||
(args: { directory: string; serverUrl: string; extra: string[] }) => {
|
|
||||||
const key = "opencode.global.dat:server"
|
|
||||||
const raw = localStorage.getItem(key)
|
|
||||||
const parsed = (() => {
|
|
||||||
if (!raw) return undefined
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw) as unknown
|
|
||||||
} catch {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
|
|
||||||
const list = Array.isArray(store.list) ? store.list : []
|
|
||||||
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
|
||||||
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
|
||||||
const nextProjects = { ...(projects as Record<string, unknown>) }
|
|
||||||
|
|
||||||
const add = (origin: string, directory: string) => {
|
|
||||||
const current = nextProjects[origin]
|
|
||||||
const items = Array.isArray(current) ? current : []
|
|
||||||
const existing = items.filter(
|
|
||||||
(p): p is { worktree: string; expanded?: boolean } =>
|
|
||||||
!!p &&
|
|
||||||
typeof p === "object" &&
|
|
||||||
"worktree" in p &&
|
|
||||||
typeof (p as { worktree?: unknown }).worktree === "string",
|
|
||||||
)
|
|
||||||
|
|
||||||
if (existing.some((p) => p.worktree === directory)) return
|
|
||||||
nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
|
|
||||||
}
|
|
||||||
|
|
||||||
const directories = [args.directory, ...args.extra]
|
|
||||||
for (const directory of directories) {
|
|
||||||
add("local", directory)
|
|
||||||
add(args.serverUrl, directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorage.setItem(
|
|
||||||
key,
|
|
||||||
JSON.stringify({
|
|
||||||
list,
|
|
||||||
projects: nextProjects,
|
|
||||||
lastProject,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{ directory: input.directory, serverUrl, extra: input.extra ?? [] },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createTestProject() {
|
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
||||||
const id = `e2e-${path.basename(root)}`
|
const id = `e2e-${path.basename(root)}`
|
||||||
|
|
||||||
@@ -375,13 +320,14 @@ export async function createTestProject() {
|
|||||||
execSync("git init", { cwd: root, stdio: "ignore" })
|
execSync("git init", { cwd: root, stdio: "ignore" })
|
||||||
await fs.writeFile(path.join(root, ".git", "opencode"), id)
|
await fs.writeFile(path.join(root, ".git", "opencode"), id)
|
||||||
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
||||||
|
execSync("git config commit.gpgsign false", { cwd: root, stdio: "ignore" })
|
||||||
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
||||||
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
||||||
cwd: root,
|
cwd: root,
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
})
|
})
|
||||||
|
|
||||||
return resolveDirectory(root)
|
return resolveDirectory(root, input?.serverUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupTestProject(directory: string) {
|
export async function cleanupTestProject(directory: string) {
|
||||||
@@ -430,22 +376,22 @@ export async function waitSlug(page: Page, skip: string[] = []) {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveSlug(slug: string) {
|
export async function resolveSlug(slug: string, input?: { serverUrl?: string }) {
|
||||||
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}`)
|
||||||
const resolved = await resolveDirectory(directory)
|
const resolved = await resolveDirectory(directory, input?.serverUrl)
|
||||||
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
|
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitDir(page: Page, directory: string) {
|
export async function waitDir(page: Page, directory: string, input?: { serverUrl?: string }) {
|
||||||
const target = await resolveDirectory(directory)
|
const target = await resolveDirectory(directory, input?.serverUrl)
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
await assertHealthy(page, "waitDir")
|
await assertHealthy(page, "waitDir")
|
||||||
const slug = slugFromUrl(page.url())
|
const slug = slugFromUrl(page.url())
|
||||||
if (!slug) return ""
|
if (!slug) return ""
|
||||||
return resolveSlug(slug)
|
return resolveSlug(slug, input)
|
||||||
.then((item) => item.directory)
|
.then((item) => item.directory)
|
||||||
.catch(() => "")
|
.catch(() => "")
|
||||||
},
|
},
|
||||||
@@ -455,25 +401,33 @@ export async function waitDir(page: Page, directory: string) {
|
|||||||
return { directory: target, slug: base64Encode(target) }
|
return { directory: target, slug: base64Encode(target) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
|
export async function waitSession(
|
||||||
const target = await resolveDirectory(input.directory)
|
page: Page,
|
||||||
|
input: {
|
||||||
|
directory: string
|
||||||
|
sessionID?: string
|
||||||
|
serverUrl?: string
|
||||||
|
allowAnySession?: boolean
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const target = await resolveDirectory(input.directory, input.serverUrl)
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
await assertHealthy(page, "waitSession")
|
await assertHealthy(page, "waitSession")
|
||||||
const slug = slugFromUrl(page.url())
|
const slug = slugFromUrl(page.url())
|
||||||
if (!slug) return false
|
if (!slug) return false
|
||||||
const resolved = await resolveSlug(slug).catch(() => undefined)
|
const resolved = await resolveSlug(slug, { serverUrl: input.serverUrl }).catch(() => undefined)
|
||||||
if (!resolved || resolved.directory !== target) return false
|
if (!resolved || resolved.directory !== target) return false
|
||||||
const current = sessionIDFromUrl(page.url())
|
const current = sessionIDFromUrl(page.url())
|
||||||
if (input.sessionID && current !== input.sessionID) return false
|
if (input.sessionID && current !== input.sessionID) return false
|
||||||
if (!input.sessionID && current) return false
|
if (!input.sessionID && !input.allowAnySession && current) return false
|
||||||
|
|
||||||
const state = await probeSession(page)
|
const state = await probeSession(page)
|
||||||
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
||||||
if (!input.sessionID && state?.sessionID) return false
|
if (!input.sessionID && !input.allowAnySession && state?.sessionID) return false
|
||||||
if (state?.dir) {
|
if (state?.dir) {
|
||||||
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
|
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
|
||||||
if (dir !== target) return false
|
if (dir !== target) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -489,9 +443,9 @@ export async function waitSession(page: Page, input: { directory: string; sessio
|
|||||||
return { directory: target, slug: base64Encode(target) }
|
return { directory: target, slug: base64Encode(target) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
|
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000, serverUrl?: string) {
|
||||||
const sdk = createSdk(directory)
|
const sdk = createSdk(directory, serverUrl)
|
||||||
const target = await resolveDirectory(directory)
|
const target = await resolveDirectory(directory, serverUrl)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
@@ -501,7 +455,7 @@ export async function waitSessionSaved(directory: string, sessionID: string, tim
|
|||||||
.then((x) => x.data)
|
.then((x) => x.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
if (!data?.directory) return ""
|
if (!data?.directory) return ""
|
||||||
return resolveDirectory(data.directory).catch(() => data.directory)
|
return resolveDirectory(data.directory, serverUrl).catch(() => data.directory)
|
||||||
},
|
},
|
||||||
{ timeout },
|
{ timeout },
|
||||||
)
|
)
|
||||||
@@ -578,12 +532,15 @@ export async function confirmDialog(page: Page, buttonName: string | RegExp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function openSharePopover(page: Page) {
|
export async function openSharePopover(page: Page) {
|
||||||
const rightSection = page.locator(titlebarRightSelector)
|
const scroller = page.locator(".scroll-view__viewport").first()
|
||||||
const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
|
await expect(scroller).toBeVisible()
|
||||||
await expect(shareButton).toBeVisible()
|
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
|
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
|
||||||
|
await expect(menuTrigger).toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
const popoverBody = page
|
const popoverBody = page
|
||||||
.locator(popoverBodySelector)
|
.locator('[data-component="popover-content"]')
|
||||||
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
|
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
|
||||||
.first()
|
.first()
|
||||||
|
|
||||||
@@ -593,16 +550,13 @@ export async function openSharePopover(page: Page) {
|
|||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
if (!opened) {
|
if (!opened) {
|
||||||
await shareButton.click()
|
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||||
await expect(popoverBody).toBeVisible()
|
await menuTrigger.click()
|
||||||
|
await clickMenuItem(menu, /share/i)
|
||||||
|
await expect(menu).toHaveCount(0)
|
||||||
|
await expect(popoverBody).toBeVisible({ timeout: 30_000 })
|
||||||
}
|
}
|
||||||
return { rightSection, popoverBody }
|
return { rightSection: scroller, popoverBody }
|
||||||
}
|
|
||||||
|
|
||||||
export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
|
|
||||||
const button = page.getByRole("button").filter({ hasText: buttonName }).first()
|
|
||||||
await expect(button).toBeVisible()
|
|
||||||
await button.click()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function clickListItem(
|
export async function clickListItem(
|
||||||
@@ -666,8 +620,9 @@ export async function cleanupSession(input: {
|
|||||||
sessionID: string
|
sessionID: string
|
||||||
directory?: string
|
directory?: string
|
||||||
sdk?: ReturnType<typeof createSdk>
|
sdk?: ReturnType<typeof createSdk>
|
||||||
|
serverUrl?: string
|
||||||
}) {
|
}) {
|
||||||
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
|
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory, input.serverUrl) : undefined)
|
||||||
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
|
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
|
||||||
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
|
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
|
||||||
const current = await status(sdk, input.sessionID).catch(() => undefined)
|
const current = await status(sdk, input.sessionID).catch(() => undefined)
|
||||||
@@ -769,40 +724,6 @@ export async function seedSessionQuestion(
|
|||||||
return { id: result.id }
|
return { id: result.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedSessionPermission(
|
|
||||||
sdk: ReturnType<typeof createSdk>,
|
|
||||||
input: {
|
|
||||||
sessionID: string
|
|
||||||
permission: string
|
|
||||||
patterns: string[]
|
|
||||||
description?: string
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const text = [
|
|
||||||
"Your only valid response is one bash tool call.",
|
|
||||||
`Use this JSON input: ${JSON.stringify({
|
|
||||||
command: input.patterns[0] ? `ls ${JSON.stringify(input.patterns[0])}` : "pwd",
|
|
||||||
workdir: "/",
|
|
||||||
description: input.description ?? `seed ${input.permission} permission request`,
|
|
||||||
})}`,
|
|
||||||
"Do not output plain text.",
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
const result = await seed({
|
|
||||||
sdk,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
prompt: text,
|
|
||||||
timeout: 30_000,
|
|
||||||
probe: async () => {
|
|
||||||
const list = await sdk.permission.list().then((x) => x.data ?? [])
|
|
||||||
return list.find((item) => item.sessionID === input.sessionID)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result) throw new Error("Timed out seeding permission request")
|
|
||||||
return { id: result.id }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function seedSessionTask(
|
export async function seedSessionTask(
|
||||||
sdk: ReturnType<typeof createSdk>,
|
sdk: ReturnType<typeof createSdk>,
|
||||||
input: {
|
input: {
|
||||||
@@ -861,36 +782,6 @@ export async function seedSessionTask(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedSessionTodos(
|
|
||||||
sdk: ReturnType<typeof createSdk>,
|
|
||||||
input: {
|
|
||||||
sessionID: string
|
|
||||||
todos: Array<{ content: string; status: string; priority: string }>
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const text = [
|
|
||||||
"Your only valid response is one todowrite tool call.",
|
|
||||||
`Use this JSON input: ${JSON.stringify({ todos: input.todos })}`,
|
|
||||||
"Do not output plain text.",
|
|
||||||
].join("\n")
|
|
||||||
const target = JSON.stringify(input.todos)
|
|
||||||
|
|
||||||
const result = await seed({
|
|
||||||
sdk,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
prompt: text,
|
|
||||||
timeout: 30_000,
|
|
||||||
probe: async () => {
|
|
||||||
const todos = await sdk.session.todo({ sessionID: input.sessionID }).then((x) => x.data ?? [])
|
|
||||||
if (JSON.stringify(todos) !== target) return
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!result) throw new Error("Timed out seeding todos")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||||
const [questions, permissions] = await Promise.all([
|
const [questions, permissions] = await Promise.all([
|
||||||
sdk.question.list().then((x) => x.data ?? []),
|
sdk.question.list().then((x) => x.data ?? []),
|
||||||
@@ -980,30 +871,57 @@ export async function openProjectMenu(page: Page, projectSlug: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
||||||
const current = await page
|
const current = () =>
|
||||||
.getByRole("button", { name: "New workspace" })
|
page
|
||||||
.first()
|
.getByRole("button", { name: "New workspace" })
|
||||||
.isVisible()
|
.first()
|
||||||
.then((x) => x)
|
.isVisible()
|
||||||
.catch(() => false)
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
if (current === enabled) return
|
if ((await current()) === enabled) return
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
await page.reload()
|
||||||
|
await openSidebar(page)
|
||||||
|
if ((await current()) === enabled) return
|
||||||
|
}
|
||||||
|
|
||||||
const flip = async (timeout?: number) => {
|
const flip = async (timeout?: number) => {
|
||||||
const menu = await openProjectMenu(page, projectSlug)
|
const menu = await openProjectMenu(page, projectSlug)
|
||||||
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
||||||
await expect(toggle).toBeVisible()
|
await expect(toggle).toBeVisible()
|
||||||
return toggle.click({ force: true, timeout })
|
await expect(toggle).toBeEnabled({ timeout: 30_000 })
|
||||||
|
const clicked = await toggle
|
||||||
|
.click({ force: true, timeout })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (clicked) return
|
||||||
|
await toggle.focus()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
}
|
}
|
||||||
|
|
||||||
const flipped = await flip(1500)
|
for (const timeout of [1500, undefined, undefined]) {
|
||||||
.then(() => true)
|
if ((await current()) === enabled) break
|
||||||
.catch(() => false)
|
await flip(timeout)
|
||||||
|
.then(() => undefined)
|
||||||
|
.catch(() => undefined)
|
||||||
|
const matched = await expect
|
||||||
|
.poll(current, { timeout: 5_000 })
|
||||||
|
.toBe(enabled)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (matched) break
|
||||||
|
}
|
||||||
|
|
||||||
if (!flipped) await flip()
|
if ((await current()) !== enabled) {
|
||||||
|
await page.reload()
|
||||||
|
await openSidebar(page)
|
||||||
|
}
|
||||||
|
|
||||||
const expected = enabled ? "New workspace" : "New session"
|
const expected = enabled ? "New workspace" : "New session"
|
||||||
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
|
await expect.poll(current, { timeout: 60_000 }).toBe(enabled)
|
||||||
|
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible({ timeout: 30_000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
||||||
@@ -1019,3 +937,13 @@ export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
|||||||
await expect(menu).toBeVisible()
|
await expect(menu).toBeVisible()
|
||||||
return menu
|
return menu
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function assistantText(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||||
|
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
return messages
|
||||||
|
.filter((m) => m.info.role === "assistant")
|
||||||
|
.flatMap((m) => m.parts)
|
||||||
|
.filter((p) => p.type === "text")
|
||||||
|
.map((p) => p.text)
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { spawn } from "node:child_process"
|
||||||
|
import fs from "node:fs/promises"
|
||||||
|
import net from "node:net"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
type Handle = {
|
||||||
|
url: string
|
||||||
|
stop: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function freePort() {
|
||||||
|
return new Promise<number>((resolve, reject) => {
|
||||||
|
const server = net.createServer()
|
||||||
|
server.once("error", reject)
|
||||||
|
server.listen(0, () => {
|
||||||
|
const address = server.address()
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close(() => reject(new Error("Failed to acquire a free port")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) reject(err)
|
||||||
|
else resolve(address.port)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForHealth(url: string, probe = "/global/health") {
|
||||||
|
const end = Date.now() + 120_000
|
||||||
|
let last = ""
|
||||||
|
while (Date.now() < end) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}${probe}`)
|
||||||
|
if (res.ok) return
|
||||||
|
last = `status ${res.status}`
|
||||||
|
} catch (err) {
|
||||||
|
last = err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function done(proc: ReturnType<typeof spawn>) {
|
||||||
|
return proc.exitCode !== null || proc.signalCode !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
|
||||||
|
if (done(proc)) return
|
||||||
|
await Promise.race([
|
||||||
|
new Promise<void>((resolve) => proc.once("exit", () => resolve())),
|
||||||
|
new Promise<void>((resolve) => setTimeout(resolve, timeout)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_CAP = 100
|
||||||
|
|
||||||
|
function cap(input: string[]) {
|
||||||
|
if (input.length > LOG_CAP) input.splice(0, input.length - LOG_CAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
function tail(input: string[]) {
|
||||||
|
return input.slice(-40).join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startBackend(label: string, input?: { llmUrl?: string }): Promise<Handle> {
|
||||||
|
const port = await freePort()
|
||||||
|
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
|
||||||
|
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||||
|
const repoDir = path.resolve(appDir, "../..")
|
||||||
|
const opencodeDir = path.join(repoDir, "packages", "opencode")
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
||||||
|
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
||||||
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
|
||||||
|
OPENCODE_TEST_HOME: path.join(sandbox, "home"),
|
||||||
|
XDG_DATA_HOME: path.join(sandbox, "share"),
|
||||||
|
XDG_CACHE_HOME: path.join(sandbox, "cache"),
|
||||||
|
XDG_CONFIG_HOME: path.join(sandbox, "config"),
|
||||||
|
XDG_STATE_HOME: path.join(sandbox, "state"),
|
||||||
|
OPENCODE_CLIENT: "app",
|
||||||
|
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
||||||
|
OPENCODE_E2E_LLM_URL: input?.llmUrl,
|
||||||
|
} satisfies Record<string, string | undefined>
|
||||||
|
const out: string[] = []
|
||||||
|
const err: string[] = []
|
||||||
|
const proc = spawn(
|
||||||
|
"bun",
|
||||||
|
["run", "--conditions=browser", "./src/index.ts", "serve", "--port", String(port), "--hostname", "127.0.0.1"],
|
||||||
|
{
|
||||||
|
cwd: opencodeDir,
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
proc.stdout?.on("data", (chunk) => {
|
||||||
|
out.push(String(chunk))
|
||||||
|
cap(out)
|
||||||
|
})
|
||||||
|
proc.stderr?.on("data", (chunk) => {
|
||||||
|
err.push(String(chunk))
|
||||||
|
cap(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = `http://127.0.0.1:${port}`
|
||||||
|
try {
|
||||||
|
await waitForHealth(url)
|
||||||
|
} catch (error) {
|
||||||
|
proc.kill("SIGTERM")
|
||||||
|
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`Failed to start isolated e2e backend for ${label}`,
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
tail(out),
|
||||||
|
tail(err),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
async stop() {
|
||||||
|
if (!done(proc)) {
|
||||||
|
proc.kill("SIGTERM")
|
||||||
|
await waitExit(proc)
|
||||||
|
}
|
||||||
|
if (!done(proc)) {
|
||||||
|
proc.kill("SIGKILL")
|
||||||
|
await waitExit(proc)
|
||||||
|
}
|
||||||
|
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
+532
-82
@@ -1,41 +1,251 @@
|
|||||||
import { test as base, expect, type Page } from "@playwright/test"
|
import { test as base, expect, type Page } from "@playwright/test"
|
||||||
|
import { ManagedRuntime } from "effect"
|
||||||
import type { E2EWindow } from "../src/testing/terminal"
|
import type { E2EWindow } from "../src/testing/terminal"
|
||||||
|
import type { Item, Reply, Usage } from "../../opencode/test/lib/llm-server"
|
||||||
|
import { TestLLMServer } from "../../opencode/test/lib/llm-server"
|
||||||
|
import { startBackend } from "./backend"
|
||||||
import {
|
import {
|
||||||
healthPhase,
|
healthPhase,
|
||||||
cleanupSession,
|
cleanupSession,
|
||||||
cleanupTestProject,
|
cleanupTestProject,
|
||||||
createTestProject,
|
createTestProject,
|
||||||
setHealthPhase,
|
setHealthPhase,
|
||||||
seedProjects,
|
|
||||||
sessionIDFromUrl,
|
sessionIDFromUrl,
|
||||||
waitSlug,
|
|
||||||
waitSession,
|
waitSession,
|
||||||
|
waitSessionIdle,
|
||||||
|
waitSessionSaved,
|
||||||
|
waitSlug,
|
||||||
} from "./actions"
|
} from "./actions"
|
||||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
import { promptSelector } from "./selectors"
|
||||||
|
import { createSdk, dirSlug, getWorktree, serverUrl, sessionPath } from "./utils"
|
||||||
|
|
||||||
|
type LLMFixture = {
|
||||||
|
url: string
|
||||||
|
push: (...input: (Item | Reply)[]) => Promise<void>
|
||||||
|
pushMatch: (
|
||||||
|
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
||||||
|
...input: (Item | Reply)[]
|
||||||
|
) => Promise<void>
|
||||||
|
textMatch: (
|
||||||
|
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
||||||
|
value: string,
|
||||||
|
opts?: { usage?: Usage },
|
||||||
|
) => Promise<void>
|
||||||
|
toolMatch: (
|
||||||
|
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
||||||
|
name: string,
|
||||||
|
input: unknown,
|
||||||
|
) => Promise<void>
|
||||||
|
text: (value: string, opts?: { usage?: Usage }) => Promise<void>
|
||||||
|
tool: (name: string, input: unknown) => Promise<void>
|
||||||
|
toolHang: (name: string, input: unknown) => Promise<void>
|
||||||
|
reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise<void>
|
||||||
|
fail: (message?: unknown) => Promise<void>
|
||||||
|
error: (status: number, body: unknown) => Promise<void>
|
||||||
|
hang: () => Promise<void>
|
||||||
|
hold: (value: string, wait: PromiseLike<unknown>) => Promise<void>
|
||||||
|
hits: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
|
||||||
|
calls: () => Promise<number>
|
||||||
|
wait: (count: number) => Promise<void>
|
||||||
|
inputs: () => Promise<Record<string, unknown>[]>
|
||||||
|
pending: () => Promise<number>
|
||||||
|
misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
|
||||||
|
}
|
||||||
|
|
||||||
|
type LLMWorker = LLMFixture & {
|
||||||
|
reset: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssistantFixture = {
|
||||||
|
reply: LLMFixture["text"]
|
||||||
|
tool: LLMFixture["tool"]
|
||||||
|
toolHang: LLMFixture["toolHang"]
|
||||||
|
reason: LLMFixture["reason"]
|
||||||
|
fail: LLMFixture["fail"]
|
||||||
|
error: LLMFixture["error"]
|
||||||
|
hang: LLMFixture["hang"]
|
||||||
|
hold: LLMFixture["hold"]
|
||||||
|
calls: LLMFixture["calls"]
|
||||||
|
pending: LLMFixture["pending"]
|
||||||
|
}
|
||||||
|
|
||||||
export const settingsKey = "settings.v3"
|
export const settingsKey = "settings.v3"
|
||||||
|
|
||||||
|
const seedModel = (() => {
|
||||||
|
const [providerID = "opencode", modelID = "big-pickle"] = (
|
||||||
|
process.env.OPENCODE_E2E_MODEL ?? "opencode/big-pickle"
|
||||||
|
).split("/")
|
||||||
|
return {
|
||||||
|
providerID: providerID || "opencode",
|
||||||
|
modelID: modelID || "big-pickle",
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
function clean(value: string | null) {
|
||||||
|
return (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function visit(page: Page, url: string) {
|
||||||
|
let err: unknown
|
||||||
|
for (const _ of [0, 1, 2]) {
|
||||||
|
try {
|
||||||
|
await page.goto(url)
|
||||||
|
return
|
||||||
|
} catch (cause) {
|
||||||
|
err = cause
|
||||||
|
if (!String(cause).includes("ERR_CONNECTION_REFUSED")) throw cause
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptSend(page: Page) {
|
||||||
|
return page
|
||||||
|
.evaluate(() => {
|
||||||
|
const win = window as E2EWindow
|
||||||
|
const sent = win.__opencode_e2e?.prompt?.sent
|
||||||
|
return {
|
||||||
|
started: sent?.started ?? 0,
|
||||||
|
count: sent?.count ?? 0,
|
||||||
|
sessionID: sent?.sessionID,
|
||||||
|
directory: sent?.directory,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => ({ started: 0, count: 0, sessionID: undefined, directory: undefined }))
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectHandle = {
|
||||||
|
directory: string
|
||||||
|
slug: string
|
||||||
|
gotoSession: (sessionID?: string) => Promise<void>
|
||||||
|
trackSession: (sessionID: string, directory?: string) => void
|
||||||
|
trackDirectory: (directory: string) => void
|
||||||
|
sdk: ReturnType<typeof createSdk>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectOptions = {
|
||||||
|
extra?: string[]
|
||||||
|
model?: { providerID: string; modelID: string }
|
||||||
|
setup?: (directory: string) => Promise<void>
|
||||||
|
beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectFixture = ProjectHandle & {
|
||||||
|
open: (options?: ProjectOptions) => Promise<void>
|
||||||
|
prompt: (text: string) => Promise<string>
|
||||||
|
user: (text: string) => Promise<string>
|
||||||
|
shell: (cmd: string) => Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
type TestFixtures = {
|
type TestFixtures = {
|
||||||
|
llm: LLMFixture
|
||||||
|
assistant: AssistantFixture
|
||||||
|
project: ProjectFixture
|
||||||
sdk: ReturnType<typeof createSdk>
|
sdk: ReturnType<typeof createSdk>
|
||||||
gotoSession: (sessionID?: string) => Promise<void>
|
gotoSession: (sessionID?: string) => Promise<void>
|
||||||
withProject: <T>(
|
|
||||||
callback: (project: {
|
|
||||||
directory: string
|
|
||||||
slug: string
|
|
||||||
gotoSession: (sessionID?: string) => Promise<void>
|
|
||||||
trackSession: (sessionID: string, directory?: string) => void
|
|
||||||
trackDirectory: (directory: string) => void
|
|
||||||
}) => Promise<T>,
|
|
||||||
options?: { extra?: string[] },
|
|
||||||
) => Promise<T>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkerFixtures = {
|
type WorkerFixtures = {
|
||||||
|
_llm: LLMWorker
|
||||||
|
backend: {
|
||||||
|
url: string
|
||||||
|
sdk: (directory?: string) => ReturnType<typeof createSdk>
|
||||||
|
}
|
||||||
directory: string
|
directory: string
|
||||||
slug: string
|
slug: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||||
|
_llm: [
|
||||||
|
async ({}, use) => {
|
||||||
|
const rt = ManagedRuntime.make(TestLLMServer.layer)
|
||||||
|
try {
|
||||||
|
const svc = await rt.runPromise(TestLLMServer.asEffect())
|
||||||
|
await use({
|
||||||
|
url: svc.url,
|
||||||
|
push: (...input) => rt.runPromise(svc.push(...input)),
|
||||||
|
pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
|
||||||
|
textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
|
||||||
|
toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)),
|
||||||
|
text: (value, opts) => rt.runPromise(svc.text(value, opts)),
|
||||||
|
tool: (name, input) => rt.runPromise(svc.tool(name, input)),
|
||||||
|
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
|
||||||
|
reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
|
||||||
|
fail: (message) => rt.runPromise(svc.fail(message)),
|
||||||
|
error: (status, body) => rt.runPromise(svc.error(status, body)),
|
||||||
|
hang: () => rt.runPromise(svc.hang),
|
||||||
|
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
|
||||||
|
reset: () => rt.runPromise(svc.reset),
|
||||||
|
hits: () => rt.runPromise(svc.hits),
|
||||||
|
calls: () => rt.runPromise(svc.calls),
|
||||||
|
wait: (count) => rt.runPromise(svc.wait(count)),
|
||||||
|
inputs: () => rt.runPromise(svc.inputs),
|
||||||
|
pending: () => rt.runPromise(svc.pending),
|
||||||
|
misses: () => rt.runPromise(svc.misses),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await rt.dispose()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ scope: "worker" },
|
||||||
|
],
|
||||||
|
backend: [
|
||||||
|
async ({ _llm }, use, workerInfo) => {
|
||||||
|
const handle = await startBackend(`w${workerInfo.workerIndex}`, { llmUrl: _llm.url })
|
||||||
|
try {
|
||||||
|
await use({
|
||||||
|
url: handle.url,
|
||||||
|
sdk: (directory?: string) => createSdk(directory, handle.url),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await handle.stop()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ scope: "worker" },
|
||||||
|
],
|
||||||
|
llm: async ({ _llm }, use) => {
|
||||||
|
await _llm.reset()
|
||||||
|
await use({
|
||||||
|
url: _llm.url,
|
||||||
|
push: _llm.push,
|
||||||
|
pushMatch: _llm.pushMatch,
|
||||||
|
textMatch: _llm.textMatch,
|
||||||
|
toolMatch: _llm.toolMatch,
|
||||||
|
text: _llm.text,
|
||||||
|
tool: _llm.tool,
|
||||||
|
toolHang: _llm.toolHang,
|
||||||
|
reason: _llm.reason,
|
||||||
|
fail: _llm.fail,
|
||||||
|
error: _llm.error,
|
||||||
|
hang: _llm.hang,
|
||||||
|
hold: _llm.hold,
|
||||||
|
hits: _llm.hits,
|
||||||
|
calls: _llm.calls,
|
||||||
|
wait: _llm.wait,
|
||||||
|
inputs: _llm.inputs,
|
||||||
|
pending: _llm.pending,
|
||||||
|
misses: _llm.misses,
|
||||||
|
})
|
||||||
|
const pending = await _llm.pending()
|
||||||
|
if (pending > 0) {
|
||||||
|
throw new Error(`TestLLMServer still has ${pending} queued response(s) after the test finished`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
assistant: async ({ llm }, use) => {
|
||||||
|
await use({
|
||||||
|
reply: llm.text,
|
||||||
|
tool: llm.tool,
|
||||||
|
toolHang: llm.toolHang,
|
||||||
|
reason: llm.reason,
|
||||||
|
fail: llm.fail,
|
||||||
|
error: llm.error,
|
||||||
|
hang: llm.hang,
|
||||||
|
hold: llm.hold,
|
||||||
|
calls: llm.calls,
|
||||||
|
pending: llm.pending,
|
||||||
|
})
|
||||||
|
},
|
||||||
page: async ({ page }, use) => {
|
page: async ({ page }, use) => {
|
||||||
let boundary: string | undefined
|
let boundary: string | undefined
|
||||||
setHealthPhase(page, "test")
|
setHealthPhase(page, "test")
|
||||||
@@ -60,9 +270,8 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
if (boundary) throw new Error(boundary)
|
if (boundary) throw new Error(boundary)
|
||||||
},
|
},
|
||||||
directory: [
|
directory: [
|
||||||
async ({}, use) => {
|
async ({ backend }, use) => {
|
||||||
const directory = await getWorktree()
|
await use(await getWorktree(backend.url))
|
||||||
await use(directory)
|
|
||||||
},
|
},
|
||||||
{ scope: "worker" },
|
{ scope: "worker" },
|
||||||
],
|
],
|
||||||
@@ -72,83 +281,324 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
},
|
},
|
||||||
{ scope: "worker" },
|
{ scope: "worker" },
|
||||||
],
|
],
|
||||||
sdk: async ({ directory }, use) => {
|
sdk: async ({ directory, backend }, use) => {
|
||||||
await use(createSdk(directory))
|
await use(backend.sdk(directory))
|
||||||
},
|
},
|
||||||
gotoSession: async ({ page, directory }, use) => {
|
gotoSession: async ({ page, directory, backend }, use) => {
|
||||||
await seedStorage(page, { directory })
|
await seedStorage(page, { directory, serverUrl: backend.url })
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
const gotoSession = async (sessionID?: string) => {
|
||||||
await page.goto(sessionPath(directory, sessionID))
|
await visit(page, sessionPath(directory, sessionID))
|
||||||
await waitSession(page, { directory, sessionID })
|
await waitSession(page, {
|
||||||
|
directory,
|
||||||
|
sessionID,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
allowAnySession: !sessionID,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
await use(gotoSession)
|
await use(gotoSession)
|
||||||
},
|
},
|
||||||
withProject: async ({ page }, use) => {
|
project: async ({ page, llm, backend }, use) => {
|
||||||
await use(async (callback, options) => {
|
const item = makeProject(page, llm, backend)
|
||||||
const root = await createTestProject()
|
try {
|
||||||
const sessions = new Map<string, string>()
|
await use(item.project)
|
||||||
const dirs = new Set<string>()
|
} finally {
|
||||||
await seedStorage(page, { directory: root, extra: options?.extra })
|
await item.cleanup()
|
||||||
|
}
|
||||||
const gotoSession = async (sessionID?: string) => {
|
|
||||||
await page.goto(sessionPath(root, sessionID))
|
|
||||||
await waitSession(page, { directory: root, sessionID })
|
|
||||||
const current = sessionIDFromUrl(page.url())
|
|
||||||
if (current) trackSession(current)
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackSession = (sessionID: string, directory?: string) => {
|
|
||||||
sessions.set(sessionID, directory ?? root)
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackDirectory = (directory: string) => {
|
|
||||||
if (directory !== root) dirs.add(directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await gotoSession()
|
|
||||||
const slug = await waitSlug(page)
|
|
||||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
|
||||||
} finally {
|
|
||||||
setHealthPhase(page, "cleanup")
|
|
||||||
await Promise.allSettled(
|
|
||||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
|
||||||
)
|
|
||||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
|
||||||
await cleanupTestProject(root)
|
|
||||||
setHealthPhase(page, "test")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
|
function makeProject(
|
||||||
await seedProjects(page, input)
|
page: Page,
|
||||||
await page.addInitScript(() => {
|
llm: LLMFixture,
|
||||||
const win = window as E2EWindow
|
backend: { url: string; sdk: (directory?: string) => ReturnType<typeof createSdk> },
|
||||||
win.__opencode_e2e = {
|
) {
|
||||||
...win.__opencode_e2e,
|
let state:
|
||||||
model: {
|
| {
|
||||||
enabled: true,
|
directory: string
|
||||||
},
|
slug: string
|
||||||
prompt: {
|
sdk: ReturnType<typeof createSdk>
|
||||||
enabled: true,
|
sessions: Map<string, string>
|
||||||
},
|
dirs: Set<string>
|
||||||
terminal: {
|
}
|
||||||
enabled: true,
|
| undefined
|
||||||
terminals: {},
|
|
||||||
},
|
const need = () => {
|
||||||
|
if (state) return state
|
||||||
|
throw new Error("project.open() must be called first")
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackSession = (sessionID: string, directory?: string) => {
|
||||||
|
const cur = need()
|
||||||
|
cur.sessions.set(sessionID, directory ?? cur.directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackDirectory = (directory: string) => {
|
||||||
|
const cur = need()
|
||||||
|
if (directory !== cur.directory) cur.dirs.add(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
const gotoSession = async (sessionID?: string) => {
|
||||||
|
const cur = need()
|
||||||
|
await visit(page, sessionPath(cur.directory, sessionID))
|
||||||
|
await waitSession(page, {
|
||||||
|
directory: cur.directory,
|
||||||
|
sessionID,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
allowAnySession: !sessionID,
|
||||||
|
})
|
||||||
|
const current = sessionIDFromUrl(page.url())
|
||||||
|
if (current) trackSession(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = async (options?: ProjectOptions) => {
|
||||||
|
if (state) return
|
||||||
|
const directory = await createTestProject({ serverUrl: backend.url })
|
||||||
|
const sdk = backend.sdk(directory)
|
||||||
|
await options?.setup?.(directory)
|
||||||
|
await seedStorage(page, {
|
||||||
|
directory,
|
||||||
|
extra: options?.extra,
|
||||||
|
model: options?.model,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
})
|
||||||
|
state = {
|
||||||
|
directory,
|
||||||
|
slug: "",
|
||||||
|
sdk,
|
||||||
|
sessions: new Map(),
|
||||||
|
dirs: new Set(),
|
||||||
}
|
}
|
||||||
localStorage.setItem(
|
await options?.beforeGoto?.({ directory, sdk })
|
||||||
"opencode.global.dat:model",
|
await gotoSession()
|
||||||
JSON.stringify({
|
need().slug = await waitSlug(page)
|
||||||
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
|
}
|
||||||
user: [],
|
|
||||||
variant: {},
|
const send = async (text: string, input: { noReply: boolean; shell: boolean }) => {
|
||||||
}),
|
if (input.noReply) {
|
||||||
|
const cur = need()
|
||||||
|
const state = await page.evaluate(() => {
|
||||||
|
const model = (window as E2EWindow).__opencode_e2e?.model?.current
|
||||||
|
if (!model) return null
|
||||||
|
return {
|
||||||
|
dir: model.dir,
|
||||||
|
sessionID: model.sessionID,
|
||||||
|
agent: model.agent,
|
||||||
|
model: model.model ? { providerID: model.model.providerID, modelID: model.model.modelID } : undefined,
|
||||||
|
variant: model.variant ?? undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const dir = state?.dir ?? cur.directory
|
||||||
|
const sdk = backend.sdk(dir)
|
||||||
|
const sessionID = state?.sessionID
|
||||||
|
? state.sessionID
|
||||||
|
: await sdk.session.create({ directory: dir, title: "E2E Session" }).then((res) => {
|
||||||
|
if (!res.data?.id) throw new Error("Failed to create no-reply session")
|
||||||
|
return res.data.id
|
||||||
|
})
|
||||||
|
await sdk.session.prompt({
|
||||||
|
sessionID,
|
||||||
|
agent: state?.agent,
|
||||||
|
model: state?.model,
|
||||||
|
variant: state?.variant,
|
||||||
|
noReply: true,
|
||||||
|
parts: [{ type: "text", text }],
|
||||||
|
})
|
||||||
|
await visit(page, sessionPath(dir, sessionID))
|
||||||
|
const active = await waitSession(page, {
|
||||||
|
directory: dir,
|
||||||
|
sessionID,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
})
|
||||||
|
trackSession(sessionID, active.directory)
|
||||||
|
await waitSessionSaved(active.directory, sessionID, 90_000, backend.url)
|
||||||
|
return sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = await promptSend(page)
|
||||||
|
if (!input.noReply && !input.shell && (await llm.pending()) === 0) {
|
||||||
|
await llm.text("ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector).first()
|
||||||
|
const submit = async () => {
|
||||||
|
await expect(prompt).toBeVisible()
|
||||||
|
await prompt.click()
|
||||||
|
if (input.shell) {
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
||||||
|
}
|
||||||
|
await page.keyboard.type(text)
|
||||||
|
await expect.poll(async () => clean(await prompt.textContent())).toBe(text)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
const started = await expect
|
||||||
|
.poll(async () => (await promptSend(page)).started, { timeout: 5_000 })
|
||||||
|
.toBeGreaterThan(prev.started)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (started) return
|
||||||
|
const send = page.getByRole("button", { name: "Send" }).first()
|
||||||
|
const enabled = await send
|
||||||
|
.isEnabled()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
if (enabled) {
|
||||||
|
await send.click()
|
||||||
|
} else {
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
}
|
||||||
|
await expect.poll(async () => (await promptSend(page)).started, { timeout: 5_000 }).toBeGreaterThan(prev.started)
|
||||||
|
}
|
||||||
|
|
||||||
|
await submit()
|
||||||
|
|
||||||
|
let next: { sessionID: string; directory: string } | undefined
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const sent = await promptSend(page)
|
||||||
|
if (sent.count <= prev.count) return ""
|
||||||
|
if (!sent.sessionID || !sent.directory) return ""
|
||||||
|
next = { sessionID: sent.sessionID, directory: sent.directory }
|
||||||
|
return sent.sessionID
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.not.toBe("")
|
||||||
|
|
||||||
|
if (!next) throw new Error("Failed to observe prompt submission in e2e prompt probe")
|
||||||
|
const active = await waitSession(page, {
|
||||||
|
directory: next.directory,
|
||||||
|
sessionID: next.sessionID,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
})
|
||||||
|
trackSession(next.sessionID, active.directory)
|
||||||
|
if (!input.shell) {
|
||||||
|
await waitSessionSaved(active.directory, next.sessionID, 90_000, backend.url)
|
||||||
|
}
|
||||||
|
await waitSessionIdle(backend.sdk(active.directory), next.sessionID, 90_000).catch(() => undefined)
|
||||||
|
return next.sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = async (text: string) => {
|
||||||
|
return send(text, { noReply: false, shell: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = async (text: string) => {
|
||||||
|
return send(text, { noReply: true, shell: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const shell = async (cmd: string) => {
|
||||||
|
return send(cmd, { noReply: false, shell: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
const cur = state
|
||||||
|
if (!cur) return
|
||||||
|
setHealthPhase(page, "cleanup")
|
||||||
|
await Promise.allSettled(
|
||||||
|
Array.from(cur.sessions, ([sessionID, directory]) =>
|
||||||
|
cleanupSession({ sessionID, directory, serverUrl: backend.url }),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
})
|
await Promise.allSettled(Array.from(cur.dirs, (directory) => cleanupTestProject(directory)))
|
||||||
|
await cleanupTestProject(cur.directory)
|
||||||
|
state = undefined
|
||||||
|
setHealthPhase(page, "test")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
open,
|
||||||
|
prompt,
|
||||||
|
user,
|
||||||
|
shell,
|
||||||
|
gotoSession,
|
||||||
|
trackSession,
|
||||||
|
trackDirectory,
|
||||||
|
get directory() {
|
||||||
|
return need().directory
|
||||||
|
},
|
||||||
|
get slug() {
|
||||||
|
return need().slug
|
||||||
|
},
|
||||||
|
get sdk() {
|
||||||
|
return need().sdk
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cleanup,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedStorage(
|
||||||
|
page: Page,
|
||||||
|
input: {
|
||||||
|
directory: string
|
||||||
|
extra?: string[]
|
||||||
|
model?: { providerID: string; modelID: string }
|
||||||
|
serverUrl?: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const origin = input.serverUrl ?? serverUrl
|
||||||
|
await page.addInitScript(
|
||||||
|
(args: {
|
||||||
|
directory: string
|
||||||
|
serverUrl: string
|
||||||
|
extra: string[]
|
||||||
|
model: { providerID: string; modelID: string }
|
||||||
|
}) => {
|
||||||
|
const key = "opencode.global.dat:server"
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
const parsed = (() => {
|
||||||
|
if (!raw) return undefined
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as unknown
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
|
||||||
|
const list = Array.isArray(store.list) ? store.list : []
|
||||||
|
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
||||||
|
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
||||||
|
const next = { ...(projects as Record<string, unknown>) }
|
||||||
|
const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
|
||||||
|
|
||||||
|
const add = (origin: string, directory: string) => {
|
||||||
|
const current = next[origin]
|
||||||
|
const items = Array.isArray(current) ? current : []
|
||||||
|
const existing = items.filter(
|
||||||
|
(p): p is { worktree: string; expanded?: boolean } =>
|
||||||
|
!!p &&
|
||||||
|
typeof p === "object" &&
|
||||||
|
"worktree" in p &&
|
||||||
|
typeof (p as { worktree?: unknown }).worktree === "string",
|
||||||
|
)
|
||||||
|
if (existing.some((p) => p.worktree === directory)) return
|
||||||
|
next[origin] = [{ worktree: directory, expanded: true }, ...existing]
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const directory of [args.directory, ...args.extra]) {
|
||||||
|
add("local", directory)
|
||||||
|
add(args.serverUrl, directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(key, JSON.stringify({ list: nextList, projects: next, lastProject }))
|
||||||
|
localStorage.setItem("opencode.settings.dat:defaultServerUrl", args.serverUrl)
|
||||||
|
|
||||||
|
const win = window as E2EWindow
|
||||||
|
win.__opencode_e2e = {
|
||||||
|
...win.__opencode_e2e,
|
||||||
|
model: { enabled: true },
|
||||||
|
prompt: { enabled: true },
|
||||||
|
terminal: { enabled: true, terminals: {} },
|
||||||
|
}
|
||||||
|
localStorage.setItem("opencode.global.dat:model", JSON.stringify({ recent: [args.model], user: [], variant: {} }))
|
||||||
|
},
|
||||||
|
{ directory: input.directory, serverUrl: origin, extra: input.extra ?? [], model: input.model ?? seedModel },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { expect }
|
export { expect }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { test, expect } from "../fixtures"
|
|||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { clickListItem } from "../actions"
|
import { clickListItem } from "../actions"
|
||||||
|
|
||||||
test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
|
test.fixme("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
await page.locator(promptSelector).click()
|
||||||
|
|||||||
@@ -1,43 +1,49 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
|
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
|
||||||
|
|
||||||
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
test("dialog edit project updates name and startup script", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async ({ slug }) => {
|
await project.open()
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const open = async () => {
|
const open = async () => {
|
||||||
const menu = await openProjectMenu(page, slug)
|
const menu = await openProjectMenu(page, project.slug)
|
||||||
await clickMenuItem(menu, /^Edit$/i, { force: true })
|
await clickMenuItem(menu, /^Edit$/i, { force: true })
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog")
|
const dialog = page.getByRole("dialog")
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
|
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
|
||||||
return dialog
|
return dialog
|
||||||
}
|
}
|
||||||
|
|
||||||
const name = `e2e project ${Date.now()}`
|
const name = `e2e project ${Date.now()}`
|
||||||
const startup = `echo e2e_${Date.now()}`
|
const startup = `echo e2e_${Date.now()}`
|
||||||
|
|
||||||
const dialog = await open()
|
const dialog = await open()
|
||||||
|
|
||||||
const nameInput = dialog.getByLabel("Name")
|
const nameInput = dialog.getByLabel("Name")
|
||||||
await nameInput.fill(name)
|
await nameInput.fill(name)
|
||||||
|
|
||||||
const startupInput = dialog.getByLabel("Workspace startup script")
|
const startupInput = dialog.getByLabel("Workspace startup script")
|
||||||
await startupInput.fill(startup)
|
await startupInput.fill(startup)
|
||||||
|
|
||||||
await dialog.getByRole("button", { name: "Save" }).click()
|
await dialog.getByRole("button", { name: "Save" }).click()
|
||||||
await expect(dialog).toHaveCount(0)
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
const header = page.locator(".group\\/project").first()
|
await expect
|
||||||
await expect(header).toContainText(name)
|
.poll(
|
||||||
|
async () => {
|
||||||
const reopened = await open()
|
await page.reload()
|
||||||
await expect(reopened.getByLabel("Name")).toHaveValue(name)
|
await openSidebar(page)
|
||||||
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
|
const reopened = await open()
|
||||||
await reopened.getByRole("button", { name: "Cancel" }).click()
|
const value = await reopened.getByLabel("Name").inputValue()
|
||||||
await expect(reopened).toHaveCount(0)
|
const next = await reopened.getByLabel("Workspace startup script").inputValue()
|
||||||
})
|
await reopened.getByRole("button", { name: "Cancel" }).click()
|
||||||
|
await expect(reopened).toHaveCount(0)
|
||||||
|
return `${value}\n${next}`
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(`${name}\n${startup}`)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,51 +3,46 @@ import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, open
|
|||||||
import { projectSwitchSelector } from "../selectors"
|
import { projectSwitchSelector } from "../selectors"
|
||||||
import { dirSlug } from "../utils"
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
test("closing active project navigates to another open project", async ({ page, withProject }) => {
|
test("closing active project navigates to another open project", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await project.open({ extra: [other] })
|
||||||
async ({ slug }) => {
|
await openSidebar(page)
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
await expect(otherButton).toBeVisible()
|
await expect(otherButton).toBeVisible()
|
||||||
await otherButton.click()
|
await otherButton.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||||
|
|
||||||
const menu = await openProjectMenu(page, otherSlug)
|
const menu = await openProjectMenu(page, otherSlug)
|
||||||
|
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||||
|
|
||||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => {
|
||||||
|
const pathname = new URL(page.url()).pathname
|
||||||
|
if (new RegExp(`^/${project.slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
||||||
|
if (pathname === "/") return "home"
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toMatch(/^(project|home)$/)
|
||||||
|
|
||||||
await expect
|
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
|
||||||
.poll(
|
await expect
|
||||||
() => {
|
.poll(
|
||||||
const pathname = new URL(page.url()).pathname
|
async () => {
|
||||||
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
return await page.locator(projectSwitchSelector(otherSlug)).count()
|
||||||
if (pathname === "/") return "home"
|
},
|
||||||
return ""
|
{ timeout: 15_000 },
|
||||||
},
|
)
|
||||||
{ timeout: 15_000 },
|
.toBe(0)
|
||||||
)
|
|
||||||
.toMatch(/^(project|home)$/)
|
|
||||||
|
|
||||||
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
return await page.locator(projectSwitchSelector(otherSlug)).count()
|
|
||||||
},
|
|
||||||
{ timeout: 15_000 },
|
|
||||||
)
|
|
||||||
.toBe(0)
|
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,111 +5,89 @@ import {
|
|||||||
createTestProject,
|
createTestProject,
|
||||||
cleanupTestProject,
|
cleanupTestProject,
|
||||||
openSidebar,
|
openSidebar,
|
||||||
sessionIDFromUrl,
|
|
||||||
setWorkspacesEnabled,
|
setWorkspacesEnabled,
|
||||||
waitSession,
|
waitSession,
|
||||||
waitSessionSaved,
|
|
||||||
waitSlug,
|
waitSlug,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { projectSwitchSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { dirSlug, resolveDirectory } from "../utils"
|
import { dirSlug, resolveDirectory } from "../utils"
|
||||||
|
|
||||||
test("can switch between projects from sidebar", async ({ page, withProject }) => {
|
test("can switch between projects from sidebar", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await project.open({ extra: [other] })
|
||||||
async ({ directory }) => {
|
await defocus(page)
|
||||||
await defocus(page)
|
|
||||||
|
|
||||||
const currentSlug = dirSlug(directory)
|
const currentSlug = dirSlug(project.directory)
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
await expect(otherButton).toBeVisible()
|
await expect(otherButton).toBeVisible()
|
||||||
await otherButton.click()
|
await otherButton.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||||
|
|
||||||
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
|
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
|
||||||
await expect(currentButton).toBeVisible()
|
await expect(currentButton).toBeVisible()
|
||||||
await currentButton.click()
|
await currentButton.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("switching back to a project opens the latest workspace session", async ({ page, withProject }) => {
|
test("switching back to a project opens the latest workspace session", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const otherSlug = dirSlug(other)
|
const otherSlug = dirSlug(other)
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await project.open({ extra: [other] })
|
||||||
async ({ directory, slug, trackSession, trackDirectory }) => {
|
await defocus(page)
|
||||||
await defocus(page)
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
await openSidebar(page)
|
||||||
await openSidebar(page)
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
const raw = await waitSlug(page, [slug])
|
const raw = await waitSlug(page, [project.slug])
|
||||||
const dir = base64Decode(raw)
|
const dir = base64Decode(raw)
|
||||||
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
|
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
|
||||||
const space = await resolveDirectory(dir)
|
const space = await resolveDirectory(dir)
|
||||||
const next = dirSlug(space)
|
const next = dirSlug(space)
|
||||||
trackDirectory(space)
|
project.trackDirectory(space)
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
|
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
|
||||||
await expect(item).toBeVisible()
|
await expect(item).toBeVisible()
|
||||||
await item.hover()
|
await item.hover()
|
||||||
|
|
||||||
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
|
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
|
||||||
await expect(btn).toBeVisible()
|
await expect(btn).toBeVisible()
|
||||||
await btn.click({ force: true })
|
await btn.click({ force: true })
|
||||||
|
|
||||||
await waitSession(page, { directory: space })
|
await waitSession(page, { directory: space })
|
||||||
|
|
||||||
// Create a session by sending a prompt
|
const created = await project.user("test")
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await expect(prompt).toBeVisible()
|
|
||||||
await prompt.fill("test")
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
// Wait for the URL to update with the new session ID
|
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
|
||||||
|
|
||||||
const created = sessionIDFromUrl(page.url())
|
await openSidebar(page)
|
||||||
if (!created) throw new Error(`Failed to get session ID from url: ${page.url()}`)
|
|
||||||
trackSession(created, space)
|
|
||||||
await waitSessionSaved(space, created)
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
|
await expect(otherButton).toBeVisible()
|
||||||
|
await otherButton.click({ force: true })
|
||||||
|
await waitSession(page, { directory: other })
|
||||||
|
|
||||||
await openSidebar(page)
|
const rootButton = page.locator(projectSwitchSelector(project.slug)).first()
|
||||||
|
await expect(rootButton).toBeVisible()
|
||||||
|
await rootButton.click({ force: true })
|
||||||
|
|
||||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
await waitSession(page, { directory: space, sessionID: created })
|
||||||
await expect(otherButton).toBeVisible()
|
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
||||||
await otherButton.click({ force: true })
|
|
||||||
await waitSession(page, { directory: other })
|
|
||||||
|
|
||||||
const rootButton = page.locator(projectSwitchSelector(slug)).first()
|
|
||||||
await expect(rootButton).toBeVisible()
|
|
||||||
await rootButton.click({ force: true })
|
|
||||||
|
|
||||||
await waitSession(page, { directory: space, sessionID: created })
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,9 @@ import {
|
|||||||
setWorkspacesEnabled,
|
setWorkspacesEnabled,
|
||||||
waitDir,
|
waitDir,
|
||||||
waitSession,
|
waitSession,
|
||||||
waitSessionSaved,
|
|
||||||
waitSlug,
|
waitSlug,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
import { workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
import { createSdk } from "../utils"
|
|
||||||
|
|
||||||
function item(space: { slug: string; raw: string }) {
|
function item(space: { slug: string; raw: string }) {
|
||||||
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
|
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
|
||||||
@@ -50,45 +48,31 @@ async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createSessionFromWorkspace(
|
async function createSessionFromWorkspace(
|
||||||
|
project: Parameters<typeof test>[0]["project"],
|
||||||
page: Page,
|
page: Page,
|
||||||
space: { slug: string; raw: string; directory: string },
|
space: { slug: string; raw: string; directory: string },
|
||||||
text: string,
|
text: string,
|
||||||
) {
|
) {
|
||||||
await openWorkspaceNewSession(page, space)
|
await openWorkspaceNewSession(page, space)
|
||||||
|
return project.user(text)
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await expect(prompt).toBeVisible()
|
|
||||||
await prompt.fill(text)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
|
|
||||||
const sessionID = sessionIDFromUrl(page.url())
|
|
||||||
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
|
||||||
|
|
||||||
await waitSessionSaved(space.directory, sessionID)
|
|
||||||
await createSdk(space.directory)
|
|
||||||
.session.abort({ sessionID })
|
|
||||||
.catch(() => undefined)
|
|
||||||
return sessionID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => {
|
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async ({ slug: root, trackDirectory, trackSession }) => {
|
await project.open()
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await setWorkspacesEnabled(page, root, true)
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
|
|
||||||
const first = await createWorkspace(page, root, [])
|
const first = await createWorkspace(page, project.slug, [])
|
||||||
trackDirectory(first.directory)
|
project.trackDirectory(first.directory)
|
||||||
await waitWorkspaceReady(page, first)
|
await waitWorkspaceReady(page, first)
|
||||||
|
|
||||||
const second = await createWorkspace(page, root, [first.slug])
|
const second = await createWorkspace(page, project.slug, [first.slug])
|
||||||
trackDirectory(second.directory)
|
project.trackDirectory(second.directory)
|
||||||
await waitWorkspaceReady(page, second)
|
await waitWorkspaceReady(page, second)
|
||||||
|
|
||||||
trackSession(await createSessionFromWorkspace(page, first, `workspace one ${Date.now()}`), first.directory)
|
await createSessionFromWorkspace(project, page, first, `workspace one ${Date.now()}`)
|
||||||
trackSession(await createSessionFromWorkspace(page, second, `workspace two ${Date.now()}`), second.directory)
|
await createSessionFromWorkspace(project, page, second, `workspace two ${Date.now()}`)
|
||||||
trackSession(await createSessionFromWorkspace(page, first, `workspace one again ${Date.now()}`), first.directory)
|
await createSessionFromWorkspace(project, page, first, `workspace one again ${Date.now()}`)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ import {
|
|||||||
waitDir,
|
waitDir,
|
||||||
waitSlug,
|
waitSlug,
|
||||||
} from "../actions"
|
} from "../actions"
|
||||||
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
|
import { inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||||
import { createSdk, dirSlug } from "../utils"
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
async function setupWorkspaceTest(page: Page, project: { slug: string; trackDirectory: (directory: string) => void }) {
|
||||||
const rootSlug = project.slug
|
const rootSlug = project.slug
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
|||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
|
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
|
||||||
await waitDir(page, next.directory)
|
await waitDir(page, next.directory)
|
||||||
|
project.trackDirectory(next.directory)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
|
||||||
@@ -52,44 +53,192 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
|||||||
return { rootSlug, slug: next.slug, directory: next.directory }
|
return { rootSlug, slug: next.slug, directory: next.directory }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
|
test("can enable and disable workspaces from project menu", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
await withProject(async ({ slug }) => {
|
await openSidebar(page)
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||||
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
||||||
|
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
|
await expect(page.locator(workspaceItemSelector(project.slug)).first()).toBeVisible()
|
||||||
|
|
||||||
await setWorkspacesEnabled(page, slug, false)
|
await setWorkspacesEnabled(page, project.slug, false)
|
||||||
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
await expect(page.locator(workspaceItemSelector(project.slug))).toHaveCount(0)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("can create a workspace", async ({ page, withProject }) => {
|
test("can create a workspace", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
const next = await resolveSlug(await waitSlug(page, [project.slug]))
|
||||||
|
await waitDir(page, next.directory)
|
||||||
|
project.trackDirectory(next.directory)
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const item = page.locator(workspaceItemSelector(next.slug)).first()
|
||||||
|
try {
|
||||||
|
await item.hover({ timeout: 500 })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("non-git projects keep workspace mode disabled", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
await withProject(async ({ slug }) => {
|
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
|
||||||
await openSidebar(page)
|
const nonGitSlug = dirSlug(nonGit)
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
|
||||||
|
|
||||||
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
|
||||||
|
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
try {
|
||||||
const next = await resolveSlug(await waitSlug(page, [slug]))
|
await project.open({ extra: [nonGit] })
|
||||||
await waitDir(page, next.directory)
|
await page.goto(`/${nonGitSlug}/session`)
|
||||||
|
|
||||||
|
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
||||||
|
|
||||||
|
const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
|
||||||
|
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
|
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
||||||
|
await expect(page.getByRole("button", { name: "Create Git repository" })).toBeVisible()
|
||||||
|
} finally {
|
||||||
|
await cleanupTestProject(nonGit)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can rename a workspace", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
|
const { slug } = await setupWorkspaceTest(page, project)
|
||||||
|
|
||||||
|
const rename = `e2e workspace ${Date.now()}`
|
||||||
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
|
await clickMenuItem(menu, /^Rename$/i, { force: true })
|
||||||
|
|
||||||
|
await expect(menu).toHaveCount(0)
|
||||||
|
|
||||||
|
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
const input = item.locator(inlineInputSelector).first()
|
||||||
|
const shown = await input
|
||||||
|
.isVisible()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
if (!shown) {
|
||||||
|
const retry = await openWorkspaceMenu(page, slug)
|
||||||
|
await clickMenuItem(retry, /^Rename$/i, { force: true })
|
||||||
|
await expect(retry).toHaveCount(0)
|
||||||
|
}
|
||||||
|
await expect(input).toBeVisible()
|
||||||
|
await input.fill(rename)
|
||||||
|
await input.press("Enter")
|
||||||
|
await expect(item).toContainText(rename)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can reset a workspace", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
|
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
|
||||||
|
|
||||||
|
const readme = path.join(createdDir, "README.md")
|
||||||
|
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
|
||||||
|
const original = await fs.readFile(readme, "utf8")
|
||||||
|
const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
|
||||||
|
await fs.writeFile(readme, dirty, "utf8")
|
||||||
|
await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await fs
|
||||||
|
.stat(extra)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
})
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const files = await project.sdk.file
|
||||||
|
.status({ directory: createdDir })
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [])
|
||||||
|
return files.length
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
|
await clickMenuItem(menu, /^Reset$/i, { force: true })
|
||||||
|
await confirmDialog(page, /^Reset workspace$/i)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const files = await project.sdk.file
|
||||||
|
.status({ directory: createdDir })
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [])
|
||||||
|
return files.length
|
||||||
|
},
|
||||||
|
{ timeout: 120_000 },
|
||||||
|
)
|
||||||
|
.toBe(0)
|
||||||
|
|
||||||
|
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 120_000 }).toBe(original)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await fs
|
||||||
|
.stat(extra)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
})
|
||||||
|
.toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can reorder workspaces by drag and drop", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
const rootSlug = project.slug
|
||||||
|
|
||||||
|
const listSlugs = async () => {
|
||||||
|
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
|
||||||
|
const slugs = await nodes.evaluateAll((els) => {
|
||||||
|
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
|
||||||
|
})
|
||||||
|
return slugs
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitReady = async (slug: string) => {
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const item = page.locator(workspaceItemSelector(next.slug)).first()
|
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||||
try {
|
try {
|
||||||
await item.hover({ timeout: 500 })
|
await item.hover({ timeout: 500 })
|
||||||
return true
|
return true
|
||||||
@@ -100,276 +249,120 @@ test("can create a workspace", async ({ page, withProject }) => {
|
|||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
)
|
)
|
||||||
.toBe(true)
|
.toBe(true)
|
||||||
|
|
||||||
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
|
|
||||||
|
|
||||||
await cleanupTestProject(next.directory)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
|
|
||||||
const nonGitSlug = dirSlug(nonGit)
|
|
||||||
|
|
||||||
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withProject(async () => {
|
|
||||||
await page.goto(`/${nonGitSlug}/session`)
|
|
||||||
|
|
||||||
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
|
||||||
|
|
||||||
const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
|
|
||||||
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
|
|
||||||
|
|
||||||
await openSidebar(page)
|
|
||||||
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
|
||||||
|
|
||||||
const trigger = page.locator('[data-action="project-menu"]').first()
|
|
||||||
const hasMenu = await trigger
|
|
||||||
.isVisible()
|
|
||||||
.then((x) => x)
|
|
||||||
.catch(() => false)
|
|
||||||
if (!hasMenu) return
|
|
||||||
|
|
||||||
await trigger.click({ force: true })
|
|
||||||
|
|
||||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
|
||||||
await expect(menu).toBeVisible()
|
|
||||||
|
|
||||||
const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
|
|
||||||
|
|
||||||
await expect(toggle).toBeVisible()
|
|
||||||
await expect(toggle).toBeDisabled()
|
|
||||||
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
await cleanupTestProject(nonGit)
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
test("can rename a workspace", async ({ page, withProject }) => {
|
const drag = async (from: string, to: string) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
const src = page.locator(workspaceItemSelector(from)).first()
|
||||||
|
const dst = page.locator(workspaceItemSelector(to)).first()
|
||||||
|
|
||||||
await withProject(async (project) => {
|
const a = await src.boundingBox()
|
||||||
const { slug } = await setupWorkspaceTest(page, project)
|
const b = await dst.boundingBox()
|
||||||
|
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
||||||
|
|
||||||
const rename = `e2e workspace ${Date.now()}`
|
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
|
||||||
const menu = await openWorkspaceMenu(page, slug)
|
await page.mouse.down()
|
||||||
await clickMenuItem(menu, /^Rename$/i, { force: true })
|
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
|
||||||
|
await page.mouse.up()
|
||||||
|
}
|
||||||
|
|
||||||
await expect(menu).toHaveCount(0)
|
await openSidebar(page)
|
||||||
|
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
await setWorkspacesEnabled(page, rootSlug, true)
|
||||||
await expect(item).toBeVisible()
|
|
||||||
const input = item.locator(inlineInputSelector).first()
|
|
||||||
await expect(input).toBeVisible()
|
|
||||||
await input.fill(rename)
|
|
||||||
await input.press("Enter")
|
|
||||||
await expect(item).toContainText(rename)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("can reset a workspace", async ({ page, sdk, withProject }) => {
|
const workspaces = [] as { directory: string; slug: string }[]
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
for (const _ of [0, 1]) {
|
||||||
|
const prev = slugFromUrl(page.url())
|
||||||
await withProject(async (project) => {
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
|
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
|
||||||
|
await waitDir(page, next.directory)
|
||||||
const readme = path.join(createdDir, "README.md")
|
project.trackDirectory(next.directory)
|
||||||
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
|
workspaces.push(next)
|
||||||
const original = await fs.readFile(readme, "utf8")
|
|
||||||
const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
|
|
||||||
await fs.writeFile(readme, dirty, "utf8")
|
|
||||||
await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await fs
|
|
||||||
.stat(extra)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
})
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const files = await sdk.file
|
|
||||||
.status({ directory: createdDir })
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
.catch(() => [])
|
|
||||||
return files.length
|
|
||||||
})
|
|
||||||
.toBeGreaterThan(0)
|
|
||||||
|
|
||||||
const menu = await openWorkspaceMenu(page, slug)
|
|
||||||
await clickMenuItem(menu, /^Reset$/i, { force: true })
|
|
||||||
await confirmDialog(page, /^Reset workspace$/i)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const files = await sdk.file
|
|
||||||
.status({ directory: createdDir })
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
.catch(() => [])
|
|
||||||
return files.length
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(0)
|
|
||||||
|
|
||||||
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
return await fs
|
|
||||||
.stat(extra)
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false)
|
|
||||||
})
|
|
||||||
.toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("can delete a workspace", async ({ page, withProject }) => {
|
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
|
||||||
|
|
||||||
await withProject(async (project) => {
|
|
||||||
const sdk = createSdk(project.directory)
|
|
||||||
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const worktrees = await sdk.worktree
|
|
||||||
.list()
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
.catch(() => [] as string[])
|
|
||||||
return worktrees.includes(directory)
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
const menu = await openWorkspaceMenu(page, slug)
|
|
||||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
|
||||||
await confirmDialog(page, /^Delete workspace$/i)
|
|
||||||
|
|
||||||
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const worktrees = await sdk.worktree
|
|
||||||
.list()
|
|
||||||
.then((r) => r.data ?? [])
|
|
||||||
.catch(() => [] as string[])
|
|
||||||
return worktrees.includes(directory)
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(false)
|
|
||||||
|
|
||||||
await project.gotoSession()
|
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
|
}
|
||||||
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
|
||||||
})
|
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
|
||||||
|
|
||||||
|
const a = workspaces[0].slug
|
||||||
|
const b = workspaces[1].slug
|
||||||
|
|
||||||
|
await waitReady(a)
|
||||||
|
await waitReady(b)
|
||||||
|
|
||||||
|
const list = async () => {
|
||||||
|
const slugs = await listSlugs()
|
||||||
|
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const slugs = await list()
|
||||||
|
return slugs.length === 2
|
||||||
|
})
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const before = await list()
|
||||||
|
const from = before[1]
|
||||||
|
const to = before[0]
|
||||||
|
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
|
||||||
|
|
||||||
|
await drag(from, to)
|
||||||
|
|
||||||
|
await expect.poll(async () => await list()).toEqual([from, to])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
|
test("can delete a workspace", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
await withProject(async ({ slug: rootSlug }) => {
|
await project.open()
|
||||||
const workspaces = [] as { directory: string; slug: string }[]
|
|
||||||
|
|
||||||
const listSlugs = async () => {
|
const rootSlug = project.slug
|
||||||
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
|
await openSidebar(page)
|
||||||
const slugs = await nodes.evaluateAll((els) => {
|
await setWorkspacesEnabled(page, rootSlug, true)
|
||||||
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
|
|
||||||
})
|
|
||||||
return slugs
|
|
||||||
}
|
|
||||||
|
|
||||||
const waitReady = async (slug: string) => {
|
const created = await project.sdk.worktree.create({ directory: project.directory }).then((res) => res.data)
|
||||||
await expect
|
if (!created?.directory) throw new Error("Failed to create workspace for delete test")
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
|
||||||
try {
|
|
||||||
await item.hover({ timeout: 500 })
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const drag = async (from: string, to: string) => {
|
const directory = created.directory
|
||||||
const src = page.locator(workspaceItemSelector(from)).first()
|
const slug = dirSlug(directory)
|
||||||
const dst = page.locator(workspaceItemSelector(to)).first()
|
project.trackDirectory(directory)
|
||||||
|
|
||||||
const a = await src.boundingBox()
|
await page.reload()
|
||||||
const b = await dst.boundingBox()
|
await openSidebar(page)
|
||||||
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible({ timeout: 60_000 })
|
||||||
|
|
||||||
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
|
await expect
|
||||||
await page.mouse.down()
|
.poll(
|
||||||
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
|
async () => {
|
||||||
await page.mouse.up()
|
const worktrees = await project.sdk.worktree
|
||||||
}
|
.list()
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
return worktrees.includes(directory)
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
try {
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
await openSidebar(page)
|
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||||
|
await confirmDialog(page, /^Delete workspace$/i)
|
||||||
|
|
||||||
await setWorkspacesEnabled(page, rootSlug, true)
|
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
|
||||||
|
|
||||||
for (const _ of [0, 1]) {
|
await expect
|
||||||
const prev = slugFromUrl(page.url())
|
.poll(
|
||||||
await page.getByRole("button", { name: "New workspace" }).first().click()
|
async () => {
|
||||||
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
|
const worktrees = await project.sdk.worktree
|
||||||
await waitDir(page, next.directory)
|
.list()
|
||||||
workspaces.push(next)
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
return worktrees.includes(directory)
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(false)
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
}
|
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
|
||||||
|
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
||||||
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
|
|
||||||
|
|
||||||
const a = workspaces[0].slug
|
|
||||||
const b = workspaces[1].slug
|
|
||||||
|
|
||||||
await waitReady(a)
|
|
||||||
await waitReady(b)
|
|
||||||
|
|
||||||
const list = async () => {
|
|
||||||
const slugs = await listSlugs()
|
|
||||||
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => {
|
|
||||||
const slugs = await list()
|
|
||||||
return slugs.length === 2
|
|
||||||
})
|
|
||||||
.toBe(true)
|
|
||||||
|
|
||||||
const before = await list()
|
|
||||||
const from = before[1]
|
|
||||||
const to = before[0]
|
|
||||||
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
|
|
||||||
|
|
||||||
await drag(from, to)
|
|
||||||
|
|
||||||
await expect.poll(async () => await list()).toEqual([from, to])
|
|
||||||
} finally {
|
|
||||||
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
type Hit = { body: Record<string, unknown> }
|
||||||
|
|
||||||
|
export function bodyText(hit: Hit) {
|
||||||
|
return JSON.stringify(hit.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match requests whose body contains the exact serialized tool input.
|
||||||
|
* The seed prompts embed JSON.stringify(input) in the prompt text, which
|
||||||
|
* gets escaped again inside the JSON body — so we double-escape to match.
|
||||||
|
*/
|
||||||
|
export function inputMatch(input: unknown) {
|
||||||
|
const escaped = JSON.stringify(JSON.stringify(input)).slice(1, -1)
|
||||||
|
return (hit: Hit) => bodyText(hit).includes(escaped)
|
||||||
|
}
|
||||||
@@ -1,47 +1,25 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
import { assistantText, withSession } from "../actions"
|
||||||
|
|
||||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
|
||||||
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
||||||
// the connection open while the agent works, causing "Failed to fetch" over
|
// the connection open while the agent works, causing "Failed to fetch" over
|
||||||
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
||||||
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
|
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, project, assistant }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
// Simulate Tailscale/VPN killing the long-lived sync connection
|
// Simulate Tailscale/VPN killing the long-lived sync connection
|
||||||
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const token = `E2E_ASYNC_${Date.now()}`
|
const token = `E2E_ASYNC_${Date.now()}`
|
||||||
await page.locator(promptSelector).click()
|
await project.open()
|
||||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
await assistant.reply(token)
|
||||||
await page.keyboard.press("Enter")
|
const sessionID = await project.prompt(`Reply with exactly: ${token}`)
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
|
||||||
const sessionID = sessionIDFromUrl(page.url())!
|
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
||||||
|
|
||||||
try {
|
|
||||||
// Agent response arrives via SSE despite sync endpoint being dead
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
|
||||||
return messages
|
|
||||||
.filter((m) => m.info.role === "assistant")
|
|
||||||
.flatMap((m) => m.parts)
|
|
||||||
.filter((p) => p.type === "text")
|
|
||||||
.map((p) => p.text)
|
|
||||||
.join("\n")
|
|
||||||
},
|
|
||||||
{ timeout: 90_000 },
|
|
||||||
)
|
|
||||||
.toContain(token)
|
|
||||||
} finally {
|
|
||||||
await cleanupSession({ sdk, sessionID })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { Locator, Page } from "@playwright/test"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptAgentSelector, promptModelSelector, promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
type Probe = {
|
||||||
|
agent?: string
|
||||||
|
model?: { providerID: string; modelID: string; name?: string }
|
||||||
|
models?: Array<{ providerID: string; modelID: string; name: string }>
|
||||||
|
agents?: Array<{ name: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(page: Page): Promise<Probe | null> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const win = window as Window & {
|
||||||
|
__opencode_e2e?: {
|
||||||
|
model?: {
|
||||||
|
current?: Probe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return win.__opencode_e2e?.model?.current ?? null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function state(page: Page) {
|
||||||
|
const value = await probe(page)
|
||||||
|
if (!value) throw new Error("Failed to resolve model selection probe")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ready(page: Page) {
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await prompt.click()
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
|
await prompt.pressSequentially("focus")
|
||||||
|
return prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
async function body(prompt: Locator) {
|
||||||
|
return prompt.evaluate((el) => (el as HTMLElement).innerText)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("agent select returns focus to the prompt", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = await ready(page)
|
||||||
|
|
||||||
|
const info = await state(page)
|
||||||
|
const next = info.agents?.map((item) => item.name).find((name) => name !== info.agent)
|
||||||
|
test.skip(!next, "only one agent available")
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
await page.locator(`${promptAgentSelector} [data-slot="select-select-trigger"]`).first().click()
|
||||||
|
|
||||||
|
const item = page.locator('[data-slot="select-select-item"]').filter({ hasText: next }).first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click({ force: true })
|
||||||
|
|
||||||
|
await expect(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()).toHaveText(
|
||||||
|
next,
|
||||||
|
)
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
|
await prompt.pressSequentially(" agent")
|
||||||
|
await expect.poll(() => body(prompt)).toContain("focus agent")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("model select returns focus to the prompt", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = await ready(page)
|
||||||
|
|
||||||
|
const info = await state(page)
|
||||||
|
const key = info.model ? `${info.model.providerID}:${info.model.modelID}` : null
|
||||||
|
const next = info.models?.find((item) => `${item.providerID}:${item.modelID}` !== key)
|
||||||
|
test.skip(!next, "only one model available")
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
await page.locator(`${promptModelSelector} [data-action="prompt-model"]`).first().click()
|
||||||
|
|
||||||
|
const item = page.locator(`[data-slot="list-item"][data-key="${next.providerID}:${next.modelID}"]`).first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click({ force: true })
|
||||||
|
|
||||||
|
await expect(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()).toHaveText(next.name)
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
|
await prompt.pressSequentially(" model")
|
||||||
|
await expect.poll(() => body(prompt)).toContain("focus model")
|
||||||
|
})
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||||
import type { Page } from "@playwright/test"
|
import type { Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { withSession } from "../actions"
|
import { assistantText } from "../actions"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
type Sdk = ReturnType<typeof createSdk>
|
||||||
|
|
||||||
const isBash = (part: unknown): part is ToolPart => {
|
const isBash = (part: unknown): part is ToolPart => {
|
||||||
if (!part || typeof part !== "object") return false
|
if (!part || typeof part !== "object") return false
|
||||||
@@ -13,54 +15,15 @@ const isBash = (part: unknown): part is ToolPart => {
|
|||||||
return "state" in part
|
return "state" in part
|
||||||
}
|
}
|
||||||
|
|
||||||
async function edge(page: Page, pos: "start" | "end") {
|
|
||||||
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
|
|
||||||
const selection = window.getSelection()
|
|
||||||
if (!selection) return
|
|
||||||
|
|
||||||
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
|
|
||||||
const nodes: Text[] = []
|
|
||||||
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
|
|
||||||
nodes.push(node as Text)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nodes.length === 0) {
|
|
||||||
const node = document.createTextNode("")
|
|
||||||
el.appendChild(node)
|
|
||||||
nodes.push(node)
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
|
|
||||||
const range = document.createRange()
|
|
||||||
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
|
|
||||||
range.collapse(true)
|
|
||||||
selection.removeAllRanges()
|
|
||||||
selection.addRange(range)
|
|
||||||
}, pos)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function wait(page: Page, value: string) {
|
async function wait(page: Page, value: string) {
|
||||||
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
|
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
|
async function reply(sdk: Sdk, sessionID: string, token: string) {
|
||||||
await expect
|
await expect.poll(() => assistantText(sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
|
||||||
return messages
|
|
||||||
.filter((item) => item.info.role === "assistant")
|
|
||||||
.flatMap((item) => item.parts)
|
|
||||||
.filter((item) => item.type === "text")
|
|
||||||
.map((item) => item.text)
|
|
||||||
.join("\n")
|
|
||||||
},
|
|
||||||
{ timeout: 90_000 },
|
|
||||||
)
|
|
||||||
.toContain(token)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) {
|
async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) {
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
@@ -79,106 +42,105 @@ async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string,
|
|||||||
.toContain(token)
|
.toContain(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
|
test("prompt history restores unsent draft with arrow navigation", async ({ page, project, assistant }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
|
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
||||||
await gotoSession(session.id)
|
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
||||||
|
const first = `Reply with exactly: ${firstToken}`
|
||||||
|
const second = `Reply with exactly: ${secondToken}`
|
||||||
|
const draft = `draft ${Date.now()}`
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
await project.open()
|
||||||
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
await assistant.reply(firstToken)
|
||||||
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
const sessionID = await project.prompt(first)
|
||||||
const first = `Reply with exactly: ${firstToken}`
|
await wait(page, "")
|
||||||
const second = `Reply with exactly: ${secondToken}`
|
await reply(project.sdk, sessionID, firstToken)
|
||||||
const draft = `draft ${Date.now()}`
|
|
||||||
|
|
||||||
await prompt.click()
|
await assistant.reply(secondToken)
|
||||||
await page.keyboard.type(first)
|
await project.prompt(second)
|
||||||
await page.keyboard.press("Enter")
|
await wait(page, "")
|
||||||
await wait(page, "")
|
await reply(project.sdk, sessionID, secondToken)
|
||||||
await reply(sdk, session.id, firstToken)
|
|
||||||
|
|
||||||
await prompt.click()
|
const prompt = page.locator(promptSelector)
|
||||||
await page.keyboard.type(second)
|
await prompt.click()
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.type(draft)
|
||||||
await wait(page, "")
|
await wait(page, draft)
|
||||||
await reply(sdk, session.id, secondToken)
|
|
||||||
|
|
||||||
await prompt.click()
|
await prompt.fill("")
|
||||||
await page.keyboard.type(draft)
|
await wait(page, "")
|
||||||
await wait(page, draft)
|
|
||||||
|
|
||||||
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
|
await page.keyboard.press("ArrowUp")
|
||||||
await prompt.fill("")
|
await wait(page, second)
|
||||||
await wait(page, "")
|
|
||||||
|
|
||||||
await page.keyboard.press("ArrowUp")
|
await page.keyboard.press("ArrowUp")
|
||||||
await wait(page, second)
|
await wait(page, first)
|
||||||
|
|
||||||
await page.keyboard.press("ArrowUp")
|
await page.keyboard.press("ArrowDown")
|
||||||
await wait(page, first)
|
await wait(page, second)
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
await page.keyboard.press("ArrowDown")
|
||||||
await wait(page, second)
|
await wait(page, "")
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
|
||||||
await wait(page, "")
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
|
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
||||||
await gotoSession(session.id)
|
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
||||||
|
const normalToken = `E2E_NORMAL_${Date.now()}`
|
||||||
|
const first = `echo ${firstToken}`
|
||||||
|
const second = `echo ${secondToken}`
|
||||||
|
const normal = `Reply with exactly: ${normalToken}`
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
await gotoSession()
|
||||||
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
|
||||||
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
|
||||||
const normalToken = `E2E_NORMAL_${Date.now()}`
|
|
||||||
const first = `echo ${firstToken}`
|
|
||||||
const second = `echo ${secondToken}`
|
|
||||||
const normal = `Reply with exactly: ${normalToken}`
|
|
||||||
|
|
||||||
await prompt.click()
|
const prompt = page.locator(promptSelector)
|
||||||
await page.keyboard.type("!")
|
|
||||||
await page.keyboard.type(first)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
await wait(page, "")
|
|
||||||
await shell(sdk, session.id, first, firstToken)
|
|
||||||
|
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.type("!")
|
await page.keyboard.type("!")
|
||||||
await page.keyboard.type(second)
|
await page.keyboard.type(first)
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
await wait(page, "")
|
await wait(page, "")
|
||||||
await shell(sdk, session.id, second, secondToken)
|
|
||||||
|
|
||||||
await prompt.click()
|
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||||
await page.keyboard.type("!")
|
const sessionID = sessionIDFromUrl(page.url())!
|
||||||
await page.keyboard.press("ArrowUp")
|
await shell(sdk, sessionID, first, firstToken)
|
||||||
await wait(page, second)
|
|
||||||
|
|
||||||
await page.keyboard.press("ArrowUp")
|
await prompt.click()
|
||||||
await wait(page, first)
|
await page.keyboard.type("!")
|
||||||
|
await page.keyboard.type(second)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await shell(sdk, sessionID, second, secondToken)
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
await page.keyboard.press("Escape")
|
||||||
await wait(page, second)
|
await wait(page, "")
|
||||||
|
|
||||||
await page.keyboard.press("ArrowDown")
|
await prompt.click()
|
||||||
await wait(page, "")
|
await page.keyboard.type("!")
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, second)
|
||||||
|
|
||||||
await page.keyboard.press("Escape")
|
await page.keyboard.press("ArrowUp")
|
||||||
await wait(page, "")
|
await wait(page, first)
|
||||||
|
|
||||||
await prompt.click()
|
await page.keyboard.press("ArrowDown")
|
||||||
await page.keyboard.type(normal)
|
await wait(page, second)
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
await wait(page, "")
|
|
||||||
await reply(sdk, session.id, normalToken)
|
|
||||||
|
|
||||||
await prompt.click()
|
await page.keyboard.press("ArrowDown")
|
||||||
await page.keyboard.press("ArrowUp")
|
await wait(page, "")
|
||||||
await wait(page, normal)
|
|
||||||
})
|
await page.keyboard.press("Escape")
|
||||||
|
await wait(page, "")
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(normal)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await reply(sdk, sessionID, normalToken)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, normal)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { sessionIDFromUrl } from "../actions"
|
import { closeDialog, openSettings, withSession } from "../actions"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptModelSelector, promptSelector, promptVariantSelector } from "../selectors"
|
||||||
import { createSdk } from "../utils"
|
|
||||||
|
|
||||||
const isBash = (part: unknown): part is ToolPart => {
|
const isBash = (part: unknown): part is ToolPart => {
|
||||||
if (!part || typeof part !== "object") return false
|
if (!part || typeof part !== "object") return false
|
||||||
@@ -11,34 +10,34 @@ const isBash = (part: unknown): part is ToolPart => {
|
|||||||
return "state" in part
|
return "state" in part
|
||||||
}
|
}
|
||||||
|
|
||||||
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
|
test("shell mode runs a command in the project directory", async ({ page, project }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
await project.open()
|
||||||
const sdk = createSdk(directory)
|
const cmd = process.platform === "win32" ? "dir" : "command ls"
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
const cmd = process.platform === "win32" ? "dir" : "ls"
|
|
||||||
|
|
||||||
await gotoSession()
|
await withSession(project.sdk, `e2e shell ${Date.now()}`, async (session) => {
|
||||||
await prompt.click()
|
project.trackSession(session.id)
|
||||||
await page.keyboard.type("!")
|
await project.gotoSession(session.id)
|
||||||
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
const dialog = await openSettings(page)
|
||||||
|
const toggle = dialog.locator('[data-action="settings-auto-accept-permissions"]').first()
|
||||||
await page.keyboard.type(cmd)
|
const input = toggle.locator('[data-slot="switch-input"]').first()
|
||||||
await page.keyboard.press("Enter")
|
await expect(toggle).toBeVisible()
|
||||||
|
if ((await input.getAttribute("aria-checked")) !== "true") {
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
await toggle.locator('[data-slot="switch-control"]').click()
|
||||||
|
await expect(input).toHaveAttribute("aria-checked", "true")
|
||||||
const id = sessionIDFromUrl(page.url())
|
}
|
||||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
await closeDialog(page, dialog)
|
||||||
trackSession(id, directory)
|
await project.shell(cmd)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const list = await sdk.session.messages({ sessionID: id, limit: 50 }).then((x) => x.data ?? [])
|
const list = await project.sdk.session
|
||||||
|
.messages({ sessionID: session.id, limit: 50 })
|
||||||
|
.then((x) => x.data ?? [])
|
||||||
const msg = list.findLast(
|
const msg = list.findLast(
|
||||||
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === directory,
|
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === project.directory,
|
||||||
)
|
)
|
||||||
if (!msg) return
|
if (!msg) return
|
||||||
|
|
||||||
@@ -51,12 +50,25 @@ test("shell mode runs a command in the project directory", async ({ page, withPr
|
|||||||
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
||||||
if (!output.includes("README.md")) return
|
if (!output.includes("README.md")) return
|
||||||
|
|
||||||
return { cwd: directory, output }
|
return { cwd: project.directory, output }
|
||||||
},
|
},
|
||||||
{ timeout: 90_000 },
|
{ timeout: 90_000 },
|
||||||
)
|
)
|
||||||
.toEqual(expect.objectContaining({ cwd: directory, output: expect.stringContaining("README.md") }))
|
.toEqual(expect.objectContaining({ cwd: project.directory, output: expect.stringContaining("README.md") }))
|
||||||
|
|
||||||
await expect(prompt).toHaveText("")
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("shell mode unmounts model and variant controls", async ({ page, project }) => {
|
||||||
|
await project.open()
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector).first()
|
||||||
|
await expect(page.locator(promptModelSelector)).toHaveCount(1)
|
||||||
|
await expect(page.locator(promptVariantSelector)).toHaveCount(1)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
|
||||||
|
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
||||||
|
await expect(page.locator(promptModelSelector)).toHaveCount(0)
|
||||||
|
await expect(page.locator(promptVariantSelector)).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|||||||
@@ -22,14 +22,16 @@ async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
|
|||||||
.toBeGreaterThan(0)
|
.toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
|
test("/share and /unshare update session share state", async ({ page, project }) => {
|
||||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||||
|
|
||||||
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
await project.open()
|
||||||
|
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
||||||
|
project.trackSession(session.id)
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
|
|
||||||
await seed(sdk, session.id)
|
await seed(project.sdk, session.id)
|
||||||
await gotoSession(session.id)
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.type("/share")
|
await page.keyboard.type("/share")
|
||||||
@@ -39,7 +41,7 @@ test("/share and /unshare update session share state", async ({ page, sdk, gotoS
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.share?.url || undefined
|
return data?.share?.url || undefined
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
@@ -54,7 +56,7 @@ test("/share and /unshare update session share state", async ({ page, sdk, gotoS
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.share?.url || undefined
|
return data?.share?.url || undefined
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { assistantText } from "../actions"
|
||||||
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
|
||||||
|
|
||||||
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
test("can send a prompt and receive a reply", async ({ page, project, assistant }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const pageErrors: string[] = []
|
const pageErrors: string[] = []
|
||||||
@@ -11,42 +10,16 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
|
|||||||
}
|
}
|
||||||
page.on("pageerror", onPageError)
|
page.on("pageerror", onPageError)
|
||||||
|
|
||||||
await gotoSession()
|
|
||||||
|
|
||||||
const token = `E2E_OK_${Date.now()}`
|
|
||||||
|
|
||||||
const prompt = page.locator(promptSelector)
|
|
||||||
await prompt.click()
|
|
||||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
|
||||||
|
|
||||||
const sessionID = (() => {
|
|
||||||
const id = sessionIDFromUrl(page.url())
|
|
||||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
|
||||||
return id
|
|
||||||
})()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await expect
|
const token = `E2E_OK_${Date.now()}`
|
||||||
.poll(
|
await project.open()
|
||||||
async () => {
|
await assistant.reply(token)
|
||||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
const sessionID = await project.prompt(`Reply with exactly: ${token}`)
|
||||||
return messages
|
|
||||||
.filter((m) => m.info.role === "assistant")
|
|
||||||
.flatMap((m) => m.parts)
|
|
||||||
.filter((p) => p.type === "text")
|
|
||||||
.map((p) => p.text)
|
|
||||||
.join("\n")
|
|
||||||
},
|
|
||||||
{ timeout: 90_000 },
|
|
||||||
)
|
|
||||||
|
|
||||||
.toContain(token)
|
await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
|
||||||
|
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
|
||||||
} finally {
|
} finally {
|
||||||
page.off("pageerror", onPageError)
|
page.off("pageerror", onPageError)
|
||||||
await cleanupSession({ sdk, sessionID })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageErrors.length > 0) {
|
if (pageErrors.length > 0) {
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
export const promptSelector = '[data-component="prompt-input"]'
|
export const promptSelector = '[data-component="prompt-input"]'
|
||||||
export const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
|
const terminalPanelSelector = '#terminal-panel[aria-hidden="false"]'
|
||||||
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
|
export const terminalSelector = `${terminalPanelSelector} [data-component="terminal"]`
|
||||||
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
export const sessionComposerDockSelector = '[data-component="session-prompt-dock"]'
|
||||||
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
export const questionDockSelector = '[data-component="dock-prompt"][data-kind="question"]'
|
||||||
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
export const permissionDockSelector = '[data-component="dock-prompt"][data-kind="permission"]'
|
||||||
export const permissionRejectSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(1)`
|
|
||||||
export const permissionAllowAlwaysSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(2)`
|
|
||||||
export const permissionAllowOnceSelector = `${permissionDockSelector} [data-slot="permission-footer-actions"] [data-component="button"]:nth-child(3)`
|
|
||||||
export const sessionTodoDockSelector = '[data-component="session-todo-dock"]'
|
|
||||||
export const sessionTodoToggleSelector = '[data-action="session-todo-toggle"]'
|
|
||||||
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
|
export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggle-button"]'
|
||||||
export const sessionTodoListSelector = '[data-slot="session-todo-list"]'
|
|
||||||
|
|
||||||
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
|
||||||
export const promptAgentSelector = '[data-component="prompt-agent-control"]'
|
export const promptAgentSelector = '[data-component="prompt-agent-control"]'
|
||||||
@@ -30,7 +24,7 @@ export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-error
|
|||||||
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
|
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
|
||||||
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
|
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
|
||||||
|
|
||||||
export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
|
||||||
|
|
||||||
export const projectSwitchSelector = (slug: string) =>
|
export const projectSwitchSelector = (slug: string) =>
|
||||||
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
|
||||||
@@ -40,9 +34,6 @@ export const projectMenuTriggerSelector = (slug: string) =>
|
|||||||
|
|
||||||
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
|
||||||
|
|
||||||
export const projectClearNotificationsSelector = (slug: string) =>
|
|
||||||
`[data-action="project-clear-notifications"][data-project="${slug}"]`
|
|
||||||
|
|
||||||
export const projectWorkspacesToggleSelector = (slug: string) =>
|
export const projectWorkspacesToggleSelector = (slug: string) =>
|
||||||
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
|
||||||
|
|
||||||
@@ -50,8 +41,6 @@ export const titlebarRightSelector = "#opencode-titlebar-right"
|
|||||||
|
|
||||||
export const popoverBodySelector = '[data-slot="popover-body"]'
|
export const popoverBodySelector = '[data-slot="popover-body"]'
|
||||||
|
|
||||||
export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
|
|
||||||
|
|
||||||
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
|
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
|
||||||
|
|
||||||
export const inlineInputSelector = '[data-component="inline-input"]'
|
export const inlineInputSelector = '[data-component="inline-input"]'
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { seedSessionTask, withSession } from "../actions"
|
import { seedSessionTask, withSession } from "../actions"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
|
import { inputMatch } from "../prompt/mock"
|
||||||
|
|
||||||
test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
|
test("task tool child-session link does not trigger stale show errors", async ({ page, llm, project }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const errs: string[] = []
|
const errs: string[] = []
|
||||||
@@ -10,28 +11,54 @@ test("task tool child-session link does not trigger stale show errors", async ({
|
|||||||
}
|
}
|
||||||
page.on("pageerror", onError)
|
page.on("pageerror", onError)
|
||||||
|
|
||||||
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
try {
|
||||||
const child = await seedSessionTask(sdk, {
|
await project.open()
|
||||||
sessionID: session.id,
|
await withSession(project.sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
||||||
description: "Open child session",
|
const taskInput = {
|
||||||
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
description: "Open child session",
|
||||||
})
|
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
||||||
|
subagent_type: "general",
|
||||||
|
}
|
||||||
|
await llm.toolMatch(inputMatch(taskInput), "task", taskInput)
|
||||||
|
const child = await seedSessionTask(project.sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
description: taskInput.description,
|
||||||
|
prompt: taskInput.prompt,
|
||||||
|
})
|
||||||
|
project.trackSession(child.sessionID)
|
||||||
|
|
||||||
try {
|
await project.gotoSession(session.id)
|
||||||
await gotoSession(session.id)
|
|
||||||
|
|
||||||
const link = page
|
const header = page.locator("[data-session-title]")
|
||||||
.locator("a.subagent-link")
|
await expect(header.getByRole("button", { name: "More options" })).toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
|
const card = page
|
||||||
|
.locator('[data-component="task-tool-card"]')
|
||||||
.filter({ hasText: /open child session/i })
|
.filter({ hasText: /open child session/i })
|
||||||
.first()
|
.first()
|
||||||
await expect(link).toBeVisible({ timeout: 30_000 })
|
await expect(card).toBeVisible({ timeout: 30_000 })
|
||||||
await link.click()
|
await card.click()
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
||||||
await page.waitForTimeout(1000)
|
await expect(header.locator('[data-slot="session-title-parent"]')).toHaveText(session.title)
|
||||||
expect(errs).toEqual([])
|
await expect(header.locator('[data-slot="session-title-child"]')).toHaveText(taskInput.description)
|
||||||
} finally {
|
await expect(header.locator('[data-slot="session-title-separator"]')).toHaveText("/")
|
||||||
page.off("pageerror", onError)
|
await expect
|
||||||
}
|
.poll(
|
||||||
})
|
() =>
|
||||||
|
header.locator('[data-slot="session-title-separator"]').evaluate((el) => ({
|
||||||
|
left: getComputedStyle(el).paddingLeft,
|
||||||
|
right: getComputedStyle(el).paddingRight,
|
||||||
|
})),
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toEqual({ left: "8px", right: "8px" })
|
||||||
|
await expect(header.getByRole("button", { name: "More options" })).toHaveCount(0)
|
||||||
|
await expect(page.getByText("Subagent sessions cannot be prompted.")).toBeVisible({ timeout: 30_000 })
|
||||||
|
await expect(page.getByRole("button", { name: "Back to main session." })).toBeVisible({ timeout: 30_000 })
|
||||||
|
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
page.off("pageerror", onError)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
type ComposerProbeState,
|
type ComposerProbeState,
|
||||||
type ComposerWindow,
|
type ComposerWindow,
|
||||||
} from "../../src/testing/session-composer"
|
} from "../../src/testing/session-composer"
|
||||||
import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions"
|
import { cleanupSession, clearSessionDockSeed, closeDialog, openSettings, seedSessionQuestion } from "../actions"
|
||||||
import {
|
import {
|
||||||
permissionDockSelector,
|
permissionDockSelector,
|
||||||
promptSelector,
|
promptSelector,
|
||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
sessionComposerDockSelector,
|
sessionComposerDockSelector,
|
||||||
sessionTodoToggleButtonSelector,
|
sessionTodoToggleButtonSelector,
|
||||||
} from "../selectors"
|
} from "../selectors"
|
||||||
|
import { modKey } from "../utils"
|
||||||
|
import { inputMatch } from "../prompt/mock"
|
||||||
|
|
||||||
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
||||||
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
|
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
|
||||||
@@ -21,12 +23,13 @@ async function withDockSession<T>(
|
|||||||
sdk: Sdk,
|
sdk: Sdk,
|
||||||
title: string,
|
title: string,
|
||||||
fn: (session: { id: string; title: string }) => Promise<T>,
|
fn: (session: { id: string; title: string }) => Promise<T>,
|
||||||
opts?: { permission?: PermissionRule[] },
|
opts?: { permission?: PermissionRule[]; trackSession?: (sessionID: string) => void },
|
||||||
) {
|
) {
|
||||||
const session = await sdk.session
|
const session = await sdk.session
|
||||||
.create(opts?.permission ? { title, permission: opts.permission } : { title })
|
.create(opts?.permission ? { title, permission: opts.permission } : { title })
|
||||||
.then((r) => r.data)
|
.then((r) => r.data)
|
||||||
if (!session?.id) throw new Error("Session create did not return an id")
|
if (!session?.id) throw new Error("Session create did not return an id")
|
||||||
|
opts?.trackSession?.(session.id)
|
||||||
try {
|
try {
|
||||||
return await fn(session)
|
return await fn(session)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -34,6 +37,17 @@ async function withDockSession<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultQuestions = [
|
||||||
|
{
|
||||||
|
header: "Need input",
|
||||||
|
question: "Pick one option",
|
||||||
|
options: [
|
||||||
|
{ label: "Continue", description: "Continue now" },
|
||||||
|
{ label: "Stop", description: "Stop here" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
|
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
|
||||||
@@ -51,12 +65,14 @@ async function clearPermissionDock(page: any, label: RegExp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function setAutoAccept(page: any, enabled: boolean) {
|
async function setAutoAccept(page: any, enabled: boolean) {
|
||||||
const button = page.locator('[data-action="prompt-permissions"]').first()
|
const dialog = await openSettings(page)
|
||||||
await expect(button).toBeVisible()
|
const toggle = dialog.locator('[data-action="settings-auto-accept-permissions"]').first()
|
||||||
const pressed = (await button.getAttribute("aria-pressed")) === "true"
|
const input = toggle.locator('[data-slot="switch-input"]').first()
|
||||||
if (pressed === enabled) return
|
await expect(toggle).toBeVisible()
|
||||||
await button.click()
|
const checked = (await input.getAttribute("aria-checked")) === "true"
|
||||||
await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false")
|
if (checked !== enabled) await toggle.locator('[data-slot="switch-control"]').click()
|
||||||
|
await expect(input).toHaveAttribute("aria-checked", enabled ? "true" : "false")
|
||||||
|
await closeDialog(page, dialog)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectQuestionBlocked(page: any) {
|
async function expectQuestionBlocked(page: any) {
|
||||||
@@ -228,9 +244,7 @@ async function withMockPermission<T>(
|
|||||||
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
|
const list = Array.isArray(json) ? json : Array.isArray(json?.data) ? json.data : undefined
|
||||||
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
|
if (Array.isArray(list) && !list.some((item) => item?.id === opts.child?.id)) list.push(opts.child)
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: res.status(),
|
response: res,
|
||||||
headers: res.headers(),
|
|
||||||
contentType: "application/json",
|
|
||||||
body: JSON.stringify(json),
|
body: JSON.stringify(json),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -255,171 +269,47 @@ async function withMockPermission<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
|
test("default dock shows prompt input", async ({ page, project }) => {
|
||||||
await withDockSession(sdk, "e2e composer dock default", async (session) => {
|
await project.open()
|
||||||
await gotoSession(session.id)
|
await withDockSession(
|
||||||
|
project.sdk,
|
||||||
|
"e2e composer dock default",
|
||||||
|
async (session) => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||||
await expect(page.locator(promptSelector)).toBeVisible()
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
await expect(page.locator('[data-action="prompt-permissions"]')).toHaveCount(0)
|
||||||
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
||||||
|
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
||||||
|
|
||||||
await page.locator(promptSelector).click()
|
await page.locator(promptSelector).click()
|
||||||
await expect(page.locator(promptSelector)).toBeFocused()
|
await expect(page.locator(promptSelector)).toBeFocused()
|
||||||
})
|
},
|
||||||
|
{ trackSession: project.trackSession },
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
|
test("auto-accept toggle works before first submit", async ({ page, project }) => {
|
||||||
await gotoSession()
|
await project.open()
|
||||||
|
|
||||||
const button = page.locator('[data-action="prompt-permissions"]').first()
|
|
||||||
await expect(button).toBeVisible()
|
|
||||||
await expect(button).toHaveAttribute("aria-pressed", "false")
|
|
||||||
|
|
||||||
await setAutoAccept(page, true)
|
await setAutoAccept(page, true)
|
||||||
await setAutoAccept(page, false)
|
await setAutoAccept(page, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
|
test("blocked question flow unblocks after submit", async ({ page, llm, project }) => {
|
||||||
await withDockSession(sdk, "e2e composer dock question", async (session) => {
|
await project.open()
|
||||||
await withDockSeed(sdk, session.id, async () => {
|
await withDockSession(
|
||||||
await gotoSession(session.id)
|
project.sdk,
|
||||||
|
"e2e composer dock question",
|
||||||
|
async (session) => {
|
||||||
|
await withDockSeed(project.sdk, session.id, async () => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
await seedSessionQuestion(sdk, {
|
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
|
||||||
sessionID: session.id,
|
await seedSessionQuestion(project.sdk, {
|
||||||
questions: [
|
sessionID: session.id,
|
||||||
{
|
questions: defaultQuestions,
|
||||||
header: "Need input",
|
|
||||||
question: "Pick one option",
|
|
||||||
options: [
|
|
||||||
{ label: "Continue", description: "Continue now" },
|
|
||||||
{ label: "Stop", description: "Stop here" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
|
||||||
await expectQuestionBlocked(page)
|
|
||||||
|
|
||||||
await dock.locator('[data-slot="question-option"]').first().click()
|
|
||||||
await dock.getByRole("button", { name: /submit/i }).click()
|
|
||||||
|
|
||||||
await expectQuestionOpen(page)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
|
||||||
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
|
||||||
await gotoSession(session.id)
|
|
||||||
await setAutoAccept(page, false)
|
|
||||||
await withMockPermission(
|
|
||||||
page,
|
|
||||||
{
|
|
||||||
id: "per_e2e_once",
|
|
||||||
sessionID: session.id,
|
|
||||||
permission: "bash",
|
|
||||||
patterns: ["/tmp/opencode-e2e-perm-once"],
|
|
||||||
metadata: { description: "Need permission for command" },
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
async (state) => {
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expectPermissionBlocked(page)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /allow once/i)
|
|
||||||
await state.resolved()
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expectPermissionOpen(page)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
|
|
||||||
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
|
|
||||||
await gotoSession(session.id)
|
|
||||||
await setAutoAccept(page, false)
|
|
||||||
await withMockPermission(
|
|
||||||
page,
|
|
||||||
{
|
|
||||||
id: "per_e2e_reject",
|
|
||||||
sessionID: session.id,
|
|
||||||
permission: "bash",
|
|
||||||
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
async (state) => {
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expectPermissionBlocked(page)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /deny/i)
|
|
||||||
await state.resolved()
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expectPermissionOpen(page)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
|
|
||||||
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
|
|
||||||
await gotoSession(session.id)
|
|
||||||
await setAutoAccept(page, false)
|
|
||||||
await withMockPermission(
|
|
||||||
page,
|
|
||||||
{
|
|
||||||
id: "per_e2e_always",
|
|
||||||
sessionID: session.id,
|
|
||||||
permission: "bash",
|
|
||||||
patterns: ["/tmp/opencode-e2e-perm-always"],
|
|
||||||
metadata: { description: "Need permission for command" },
|
|
||||||
},
|
|
||||||
undefined,
|
|
||||||
async (state) => {
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expectPermissionBlocked(page)
|
|
||||||
|
|
||||||
await clearPermissionDock(page, /allow always/i)
|
|
||||||
await state.resolved()
|
|
||||||
await page.goto(page.url())
|
|
||||||
await expectPermissionOpen(page)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("child session question request blocks parent dock and unblocks after submit", async ({
|
|
||||||
page,
|
|
||||||
sdk,
|
|
||||||
gotoSession,
|
|
||||||
}) => {
|
|
||||||
await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
|
|
||||||
await gotoSession(session.id)
|
|
||||||
|
|
||||||
const child = await sdk.session
|
|
||||||
.create({
|
|
||||||
title: "e2e composer dock child question",
|
|
||||||
parentID: session.id,
|
|
||||||
})
|
|
||||||
.then((r) => r.data)
|
|
||||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
|
||||||
|
|
||||||
try {
|
|
||||||
await withDockSeed(sdk, child.id, async () => {
|
|
||||||
await seedSessionQuestion(sdk, {
|
|
||||||
sessionID: child.id,
|
|
||||||
questions: [
|
|
||||||
{
|
|
||||||
header: "Child input",
|
|
||||||
question: "Pick one child option",
|
|
||||||
options: [
|
|
||||||
{ label: "Continue", description: "Continue child" },
|
|
||||||
{ label: "Stop", description: "Stop child" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const dock = page.locator(questionDockSelector)
|
const dock = page.locator(questionDockSelector)
|
||||||
@@ -430,40 +320,92 @@ test("child session question request blocks parent dock and unblocks after submi
|
|||||||
|
|
||||||
await expectQuestionOpen(page)
|
await expectQuestionOpen(page)
|
||||||
})
|
})
|
||||||
} finally {
|
},
|
||||||
await cleanupSession({ sdk, sessionID: child.id })
|
{ trackSession: project.trackSession },
|
||||||
}
|
)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("child session permission request blocks parent dock and supports allow once", async ({
|
test("blocked question flow supports keyboard shortcuts", async ({ page, llm, project }) => {
|
||||||
page,
|
await project.open()
|
||||||
sdk,
|
await withDockSession(
|
||||||
gotoSession,
|
project.sdk,
|
||||||
}) => {
|
"e2e composer dock question keyboard",
|
||||||
await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
|
async (session) => {
|
||||||
await gotoSession(session.id)
|
await withDockSeed(project.sdk, session.id, async () => {
|
||||||
await setAutoAccept(page, false)
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
const child = await sdk.session
|
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
|
||||||
.create({
|
await seedSessionQuestion(project.sdk, {
|
||||||
title: "e2e composer dock child permission",
|
sessionID: session.id,
|
||||||
parentID: session.id,
|
questions: defaultQuestions,
|
||||||
|
})
|
||||||
|
|
||||||
|
const dock = page.locator(questionDockSelector)
|
||||||
|
const first = dock.locator('[data-slot="question-option"]').first()
|
||||||
|
const second = dock.locator('[data-slot="question-option"]').nth(1)
|
||||||
|
|
||||||
|
await expectQuestionBlocked(page)
|
||||||
|
await expect(first).toBeFocused()
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown")
|
||||||
|
await expect(second).toBeFocused()
|
||||||
|
|
||||||
|
await page.keyboard.press("Space")
|
||||||
|
await page.keyboard.press(`${modKey}+Enter`)
|
||||||
|
await expectQuestionOpen(page)
|
||||||
})
|
})
|
||||||
.then((r) => r.data)
|
},
|
||||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
{ trackSession: project.trackSession },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
test("blocked question flow supports escape dismiss", async ({ page, llm, project }) => {
|
||||||
|
await project.open()
|
||||||
|
await withDockSession(
|
||||||
|
project.sdk,
|
||||||
|
"e2e composer dock question escape",
|
||||||
|
async (session) => {
|
||||||
|
await withDockSeed(project.sdk, session.id, async () => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
|
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
|
||||||
|
await seedSessionQuestion(project.sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
questions: defaultQuestions,
|
||||||
|
})
|
||||||
|
|
||||||
|
const dock = page.locator(questionDockSelector)
|
||||||
|
const first = dock.locator('[data-slot="question-option"]').first()
|
||||||
|
|
||||||
|
await expectQuestionBlocked(page)
|
||||||
|
await expect(first).toBeFocused()
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expectQuestionOpen(page)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ trackSession: project.trackSession },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("blocked permission flow supports allow once", async ({ page, project }) => {
|
||||||
|
await project.open()
|
||||||
|
await withDockSession(
|
||||||
|
project.sdk,
|
||||||
|
"e2e composer dock permission once",
|
||||||
|
async (session) => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
await setAutoAccept(page, false)
|
||||||
await withMockPermission(
|
await withMockPermission(
|
||||||
page,
|
page,
|
||||||
{
|
{
|
||||||
id: "per_e2e_child",
|
id: "per_e2e_once",
|
||||||
sessionID: child.id,
|
sessionID: session.id,
|
||||||
permission: "bash",
|
permission: "bash",
|
||||||
patterns: ["/tmp/opencode-e2e-perm-child"],
|
patterns: ["/tmp/opencode-e2e-perm-once"],
|
||||||
metadata: { description: "Need child permission" },
|
metadata: { description: "Need permission for command" },
|
||||||
},
|
},
|
||||||
{ child },
|
undefined,
|
||||||
async (state) => {
|
async (state) => {
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
await expectPermissionBlocked(page)
|
await expectPermissionBlocked(page)
|
||||||
@@ -471,67 +413,243 @@ test("child session permission request blocks parent dock and supports allow onc
|
|||||||
await clearPermissionDock(page, /allow once/i)
|
await clearPermissionDock(page, /allow once/i)
|
||||||
await state.resolved()
|
await state.resolved()
|
||||||
await page.goto(page.url())
|
await page.goto(page.url())
|
||||||
|
|
||||||
await expectPermissionOpen(page)
|
await expectPermissionOpen(page)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} finally {
|
},
|
||||||
await cleanupSession({ sdk, sessionID: child.id })
|
{ trackSession: project.trackSession },
|
||||||
}
|
)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
|
test("blocked permission flow supports reject", async ({ page, project }) => {
|
||||||
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
|
await project.open()
|
||||||
const dock = await todoDock(page, session.id)
|
await withDockSession(
|
||||||
await gotoSession(session.id)
|
project.sdk,
|
||||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
"e2e composer dock permission reject",
|
||||||
|
async (session) => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
await setAutoAccept(page, false)
|
||||||
|
await withMockPermission(
|
||||||
|
page,
|
||||||
|
{
|
||||||
|
id: "per_e2e_reject",
|
||||||
|
sessionID: session.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
async (state) => {
|
||||||
|
await page.goto(page.url())
|
||||||
|
await expectPermissionBlocked(page)
|
||||||
|
|
||||||
try {
|
await clearPermissionDock(page, /deny/i)
|
||||||
await dock.open([
|
await state.resolved()
|
||||||
{ content: "first task", status: "pending", priority: "high" },
|
await page.goto(page.url())
|
||||||
{ content: "second task", status: "in_progress", priority: "medium" },
|
await expectPermissionOpen(page)
|
||||||
])
|
},
|
||||||
await dock.expectOpen(["pending", "in_progress"])
|
)
|
||||||
|
},
|
||||||
await dock.collapse()
|
{ trackSession: project.trackSession },
|
||||||
await dock.expectCollapsed(["pending", "in_progress"])
|
)
|
||||||
|
|
||||||
await dock.expand()
|
|
||||||
await dock.expectOpen(["pending", "in_progress"])
|
|
||||||
|
|
||||||
await dock.finish([
|
|
||||||
{ content: "first task", status: "completed", priority: "high" },
|
|
||||||
{ content: "second task", status: "cancelled", priority: "medium" },
|
|
||||||
])
|
|
||||||
await dock.expectClosed()
|
|
||||||
} finally {
|
|
||||||
await dock.clear()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
|
test("blocked permission flow supports allow always", async ({ page, project }) => {
|
||||||
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
|
await project.open()
|
||||||
await withDockSeed(sdk, session.id, async () => {
|
await withDockSession(
|
||||||
await gotoSession(session.id)
|
project.sdk,
|
||||||
|
"e2e composer dock permission always",
|
||||||
|
async (session) => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
await setAutoAccept(page, false)
|
||||||
|
await withMockPermission(
|
||||||
|
page,
|
||||||
|
{
|
||||||
|
id: "per_e2e_always",
|
||||||
|
sessionID: session.id,
|
||||||
|
permission: "bash",
|
||||||
|
patterns: ["/tmp/opencode-e2e-perm-always"],
|
||||||
|
metadata: { description: "Need permission for command" },
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
async (state) => {
|
||||||
|
await page.goto(page.url())
|
||||||
|
await expectPermissionBlocked(page)
|
||||||
|
|
||||||
await seedSessionQuestion(sdk, {
|
await clearPermissionDock(page, /allow always/i)
|
||||||
sessionID: session.id,
|
await state.resolved()
|
||||||
questions: [
|
await page.goto(page.url())
|
||||||
|
await expectPermissionOpen(page)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ trackSession: project.trackSession },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("child session question request blocks parent dock and unblocks after submit", async ({ page, llm, project }) => {
|
||||||
|
const questions = [
|
||||||
|
{
|
||||||
|
header: "Child input",
|
||||||
|
question: "Pick one child option",
|
||||||
|
options: [
|
||||||
|
{ label: "Continue", description: "Continue child" },
|
||||||
|
{ label: "Stop", description: "Stop child" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
await project.open()
|
||||||
|
await withDockSession(
|
||||||
|
project.sdk,
|
||||||
|
"e2e composer dock child question parent",
|
||||||
|
async (session) => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
|
const child = await project.sdk.session
|
||||||
|
.create({
|
||||||
|
title: "e2e composer dock child question",
|
||||||
|
parentID: session.id,
|
||||||
|
})
|
||||||
|
.then((r) => r.data)
|
||||||
|
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||||
|
project.trackSession(child.id)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withDockSeed(project.sdk, child.id, async () => {
|
||||||
|
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
|
||||||
|
await seedSessionQuestion(project.sdk, {
|
||||||
|
sessionID: child.id,
|
||||||
|
questions,
|
||||||
|
})
|
||||||
|
|
||||||
|
const dock = page.locator(questionDockSelector)
|
||||||
|
await expectQuestionBlocked(page)
|
||||||
|
|
||||||
|
await dock.locator('[data-slot="question-option"]').first().click()
|
||||||
|
await dock.getByRole("button", { name: /submit/i }).click()
|
||||||
|
|
||||||
|
await expectQuestionOpen(page)
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ trackSession: project.trackSession },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("child session permission request blocks parent dock and supports allow once", async ({ page, project }) => {
|
||||||
|
await project.open()
|
||||||
|
await withDockSession(
|
||||||
|
project.sdk,
|
||||||
|
"e2e composer dock child permission parent",
|
||||||
|
async (session) => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
await setAutoAccept(page, false)
|
||||||
|
|
||||||
|
const child = await project.sdk.session
|
||||||
|
.create({
|
||||||
|
title: "e2e composer dock child permission",
|
||||||
|
parentID: session.id,
|
||||||
|
})
|
||||||
|
.then((r) => r.data)
|
||||||
|
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||||
|
project.trackSession(child.id)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withMockPermission(
|
||||||
|
page,
|
||||||
{
|
{
|
||||||
header: "Need input",
|
id: "per_e2e_child",
|
||||||
question: "Pick one option",
|
sessionID: child.id,
|
||||||
options: [{ label: "Continue", description: "Continue now" }],
|
permission: "bash",
|
||||||
|
patterns: ["/tmp/opencode-e2e-perm-child"],
|
||||||
|
metadata: { description: "Need child permission" },
|
||||||
},
|
},
|
||||||
],
|
{ child },
|
||||||
})
|
async (state) => {
|
||||||
|
await page.goto(page.url())
|
||||||
|
await expectPermissionBlocked(page)
|
||||||
|
|
||||||
await expectQuestionBlocked(page)
|
await clearPermissionDock(page, /allow once/i)
|
||||||
|
await state.resolved()
|
||||||
|
await page.goto(page.url())
|
||||||
|
|
||||||
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
await expectPermissionOpen(page)
|
||||||
await page.keyboard.type("abc")
|
},
|
||||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
)
|
||||||
})
|
} finally {
|
||||||
})
|
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ trackSession: project.trackSession },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("todo dock transitions and collapse behavior", async ({ page, project }) => {
|
||||||
|
await project.open()
|
||||||
|
await withDockSession(
|
||||||
|
project.sdk,
|
||||||
|
"e2e composer dock todo",
|
||||||
|
async (session) => {
|
||||||
|
const dock = await todoDock(page, session.id)
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await dock.open([
|
||||||
|
{ content: "first task", status: "pending", priority: "high" },
|
||||||
|
{ content: "second task", status: "in_progress", priority: "medium" },
|
||||||
|
])
|
||||||
|
await dock.expectOpen(["pending", "in_progress"])
|
||||||
|
|
||||||
|
await dock.collapse()
|
||||||
|
await dock.expectCollapsed(["pending", "in_progress"])
|
||||||
|
|
||||||
|
await dock.expand()
|
||||||
|
await dock.expectOpen(["pending", "in_progress"])
|
||||||
|
|
||||||
|
await dock.finish([
|
||||||
|
{ content: "first task", status: "completed", priority: "high" },
|
||||||
|
{ content: "second task", status: "cancelled", priority: "medium" },
|
||||||
|
])
|
||||||
|
await dock.expectClosed()
|
||||||
|
} finally {
|
||||||
|
await dock.clear()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ trackSession: project.trackSession },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keyboard focus stays off prompt while blocked", async ({ page, llm, project }) => {
|
||||||
|
const questions = [
|
||||||
|
{
|
||||||
|
header: "Need input",
|
||||||
|
question: "Pick one option",
|
||||||
|
options: [{ label: "Continue", description: "Continue now" }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
await project.open()
|
||||||
|
await withDockSession(
|
||||||
|
project.sdk,
|
||||||
|
"e2e composer dock keyboard",
|
||||||
|
async (session) => {
|
||||||
|
await withDockSeed(project.sdk, session.id, async () => {
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
|
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
|
||||||
|
await seedSessionQuestion(project.sdk, {
|
||||||
|
sessionID: session.id,
|
||||||
|
questions,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expectQuestionBlocked(page)
|
||||||
|
|
||||||
|
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
||||||
|
await page.keyboard.type("abc")
|
||||||
|
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{ trackSession: project.trackSession },
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
import type { Locator, Page } from "@playwright/test"
|
import type { Locator, Page } from "@playwright/test"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import {
|
import { openSidebar, resolveSlug, setWorkspacesEnabled, waitSession, waitSlug } from "../actions"
|
||||||
openSidebar,
|
|
||||||
resolveSlug,
|
|
||||||
sessionIDFromUrl,
|
|
||||||
setWorkspacesEnabled,
|
|
||||||
waitSession,
|
|
||||||
waitSessionIdle,
|
|
||||||
waitSlug,
|
|
||||||
} from "../actions"
|
|
||||||
import {
|
import {
|
||||||
promptAgentSelector,
|
promptAgentSelector,
|
||||||
promptModelSelector,
|
promptModelSelector,
|
||||||
promptSelector,
|
|
||||||
promptVariantSelector,
|
promptVariantSelector,
|
||||||
workspaceItemSelector,
|
workspaceItemSelector,
|
||||||
workspaceNewSessionSelector,
|
workspaceNewSessionSelector,
|
||||||
@@ -230,32 +221,8 @@ async function goto(page: Page, directory: string, sessionID?: string) {
|
|||||||
await waitSession(page, { directory, sessionID })
|
await waitSession(page, { directory, sessionID })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit(page: Page, value: string) {
|
async function submit(project: Parameters<typeof test>[0]["project"], value: string) {
|
||||||
const prompt = page.locator(promptSelector)
|
return project.prompt(value)
|
||||||
await expect(prompt).toBeVisible()
|
|
||||||
await prompt.click()
|
|
||||||
await prompt.fill(value)
|
|
||||||
await prompt.press("Enter")
|
|
||||||
|
|
||||||
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
|
|
||||||
const id = sessionIDFromUrl(page.url())
|
|
||||||
if (!id) throw new Error(`Failed to resolve session id from ${page.url()}`)
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitUser(directory: string, sessionID: string) {
|
|
||||||
const sdk = createSdk(directory)
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const items = await sdk.session.messages({ sessionID, limit: 20 }).then((x) => x.data ?? [])
|
|
||||||
return items.some((item) => item.info.role === "user")
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
await sdk.session.abort({ sessionID }).catch(() => undefined)
|
|
||||||
await waitSessionIdle(sdk, sessionID, 30_000).catch(() => undefined)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||||
@@ -298,108 +265,98 @@ async function newWorkspaceSession(page: Page, slug: string) {
|
|||||||
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
|
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("session model restore per session without leaking into new sessions", async ({ page, withProject }) => {
|
test("session model restore per session without leaking into new sessions", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 })
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
|
||||||
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
await project.open()
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
|
|
||||||
const firstState = await chooseOtherModel(page)
|
const firstState = await chooseOtherModel(page)
|
||||||
const firstKey = await currentModel(page)
|
const firstKey = await currentModel(page)
|
||||||
const first = await submit(page, `session variant ${Date.now()}`)
|
const first = await submit(project, `session variant ${Date.now()}`)
|
||||||
trackSession(first)
|
|
||||||
await waitUser(directory, first)
|
|
||||||
|
|
||||||
await page.reload()
|
await page.reload()
|
||||||
await waitSession(page, { directory, sessionID: first })
|
await waitSession(page, { directory: project.directory, sessionID: first })
|
||||||
await waitFooter(page, firstState)
|
await waitFooter(page, firstState)
|
||||||
|
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
const fresh = await read(page)
|
const fresh = await read(page)
|
||||||
expect(fresh.model).not.toBe(firstState.model)
|
expect(fresh.model).not.toBe(firstState.model)
|
||||||
|
|
||||||
const secondState = await chooseOtherModel(page, [firstKey])
|
const secondState = await chooseOtherModel(page, [firstKey])
|
||||||
const second = await submit(page, `session model ${Date.now()}`)
|
const second = await submit(project, `session model ${Date.now()}`)
|
||||||
trackSession(second)
|
|
||||||
await waitUser(directory, second)
|
|
||||||
|
|
||||||
await goto(page, directory, first)
|
await goto(page, project.directory, first)
|
||||||
await waitFooter(page, firstState)
|
await waitFooter(page, firstState)
|
||||||
|
|
||||||
await goto(page, directory, second)
|
await goto(page, project.directory, second)
|
||||||
await waitFooter(page, secondState)
|
await waitFooter(page, secondState)
|
||||||
|
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
await waitFooter(page, fresh)
|
await page.reload()
|
||||||
})
|
await waitSession(page, { directory: project.directory })
|
||||||
|
await waitFooter(page, fresh)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session model restore across workspaces", async ({ page, withProject }) => {
|
test("session model restore across workspaces", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 })
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
|
||||||
await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
|
await project.open()
|
||||||
await gotoSession()
|
const root = project.directory
|
||||||
|
await project.gotoSession()
|
||||||
|
|
||||||
const firstState = await chooseOtherModel(page)
|
const firstState = await chooseOtherModel(page)
|
||||||
const firstKey = await currentModel(page)
|
const firstKey = await currentModel(page)
|
||||||
const first = await submit(page, `root session ${Date.now()}`)
|
const first = await submit(project, `root session ${Date.now()}`)
|
||||||
trackSession(first, root)
|
|
||||||
await waitUser(root, first)
|
|
||||||
|
|
||||||
await openSidebar(page)
|
await openSidebar(page)
|
||||||
await setWorkspacesEnabled(page, slug, true)
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
|
|
||||||
const one = await createWorkspace(page, slug, [])
|
const one = await createWorkspace(page, project.slug, [])
|
||||||
const oneDir = await newWorkspaceSession(page, one.slug)
|
const oneDir = await newWorkspaceSession(page, one.slug)
|
||||||
trackDirectory(oneDir)
|
project.trackDirectory(oneDir)
|
||||||
|
|
||||||
const secondState = await chooseOtherModel(page, [firstKey])
|
const secondState = await chooseOtherModel(page, [firstKey])
|
||||||
const secondKey = await currentModel(page)
|
const secondKey = await currentModel(page)
|
||||||
const second = await submit(page, `workspace one ${Date.now()}`)
|
const second = await submit(project, `workspace one ${Date.now()}`)
|
||||||
trackSession(second, oneDir)
|
|
||||||
await waitUser(oneDir, second)
|
|
||||||
|
|
||||||
const two = await createWorkspace(page, slug, [one.slug])
|
const two = await createWorkspace(page, project.slug, [one.slug])
|
||||||
const twoDir = await newWorkspaceSession(page, two.slug)
|
const twoDir = await newWorkspaceSession(page, two.slug)
|
||||||
trackDirectory(twoDir)
|
project.trackDirectory(twoDir)
|
||||||
|
|
||||||
const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
|
const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
|
||||||
const third = await submit(page, `workspace two ${Date.now()}`)
|
const third = await submit(project, `workspace two ${Date.now()}`)
|
||||||
trackSession(third, twoDir)
|
|
||||||
await waitUser(twoDir, third)
|
|
||||||
|
|
||||||
await goto(page, root, first)
|
await goto(page, root, first)
|
||||||
await waitFooter(page, firstState)
|
await waitFooter(page, firstState)
|
||||||
|
|
||||||
await goto(page, oneDir, second)
|
await goto(page, oneDir, second)
|
||||||
await waitFooter(page, secondState)
|
await waitFooter(page, secondState)
|
||||||
|
|
||||||
await goto(page, twoDir, third)
|
await goto(page, twoDir, third)
|
||||||
await waitFooter(page, thirdState)
|
await waitFooter(page, thirdState)
|
||||||
|
|
||||||
await goto(page, root, first)
|
await goto(page, root, first)
|
||||||
await waitFooter(page, firstState)
|
await waitFooter(page, firstState)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("variant preserved when switching agent modes", async ({ page, withProject }) => {
|
test("variant preserved when switching agent modes", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1440, height: 900 })
|
await page.setViewportSize({ width: 1440, height: 900 })
|
||||||
|
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
await project.open()
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
|
|
||||||
await ensureVariant(page, directory)
|
await ensureVariant(page, project.directory)
|
||||||
const updated = await chooseDifferentVariant(page)
|
const updated = await chooseDifferentVariant(page)
|
||||||
|
|
||||||
const available = await agents(page)
|
const available = await agents(page)
|
||||||
const other = available.find((name) => name !== updated.agent)
|
const other = available.find((name) => name !== updated.agent)
|
||||||
test.skip(!other, "only one agent available")
|
test.skip(!other, "only one agent available")
|
||||||
if (!other) return
|
if (!other) return
|
||||||
|
|
||||||
await choose(page, promptAgentSelector, other)
|
await choose(page, promptAgentSelector, other)
|
||||||
await waitFooter(page, { agent: other, variant: updated.variant })
|
await waitFooter(page, { agent: other, variant: updated.variant })
|
||||||
|
|
||||||
await choose(page, promptAgentSelector, updated.agent)
|
await choose(page, promptAgentSelector, updated.agent)
|
||||||
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
|
await waitFooter(page, { agent: updated.agent, variant: updated.variant })
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { waitSessionIdle, withSession } from "../actions"
|
import { waitSessionIdle, withSession } from "../actions"
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { createSdk } from "../utils"
|
import { bodyText } from "../prompt/mock"
|
||||||
|
|
||||||
const count = 14
|
const count = 14
|
||||||
|
|
||||||
@@ -40,8 +40,19 @@ function edit(file: string, prev: string, next: string) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) {
|
async function patchWithMock(
|
||||||
await sdk.session.promptAsync({
|
llm: Parameters<typeof test>[0]["llm"],
|
||||||
|
sdk: Parameters<typeof withSession>[0],
|
||||||
|
sessionID: string,
|
||||||
|
patchText: string,
|
||||||
|
) {
|
||||||
|
const callsBefore = await llm.calls()
|
||||||
|
await llm.toolMatch(
|
||||||
|
(hit) => bodyText(hit).includes("Your only valid response is one apply_patch tool call."),
|
||||||
|
"apply_patch",
|
||||||
|
{ patchText },
|
||||||
|
)
|
||||||
|
await sdk.session.prompt({
|
||||||
sessionID,
|
sessionID,
|
||||||
agent: "build",
|
agent: "build",
|
||||||
system: [
|
system: [
|
||||||
@@ -54,7 +65,16 @@ async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patch
|
|||||||
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
|
||||||
})
|
})
|
||||||
|
|
||||||
await waitSessionIdle(sdk, sessionID, 120_000)
|
await expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const diff = await sdk.session.diff({ sessionID }).then((res) => res.data ?? [])
|
||||||
|
return diff.length
|
||||||
|
},
|
||||||
|
{ timeout: 120_000 },
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function show(page: Parameters<typeof test>[0]["page"]) {
|
async function show(page: Parameters<typeof test>[0]["page"]) {
|
||||||
@@ -233,7 +253,7 @@ async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
|
test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, project }) => {
|
||||||
test.setTimeout(180_000)
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
const tag = `review-comment-${Date.now()}`
|
const tag = `review-comment-${Date.now()}`
|
||||||
@@ -242,47 +262,45 @@ test("review applies inline comment clicks without horizontal overflow", async (
|
|||||||
|
|
||||||
await page.setViewportSize({ width: 1280, height: 900 })
|
await page.setViewportSize({ width: 1280, height: 900 })
|
||||||
|
|
||||||
await withProject(async (project) => {
|
await project.open()
|
||||||
const sdk = createSdk(project.directory)
|
await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => {
|
||||||
|
project.trackSession(session.id)
|
||||||
|
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
|
||||||
|
|
||||||
await withSession(sdk, `e2e review comment ${tag}`, async (session) => {
|
await expect
|
||||||
await patch(sdk, session.id, seed([{ file, mark: tag }]))
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
|
return diff.length
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(1)
|
||||||
|
|
||||||
await expect
|
await project.gotoSession(session.id)
|
||||||
.poll(
|
await show(page)
|
||||||
async () => {
|
|
||||||
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
|
||||||
return diff.length
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(1)
|
|
||||||
|
|
||||||
await project.gotoSession(session.id)
|
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||||
await show(page)
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
|
||||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
await expand(page)
|
||||||
await expect(tab).toBeVisible()
|
await waitMark(page, file, tag)
|
||||||
await tab.click()
|
await comment(page, file, note)
|
||||||
|
|
||||||
await expand(page)
|
await expect
|
||||||
await waitMark(page, file, tag)
|
.poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
await comment(page, file, note)
|
.toBeLessThanOrEqual(1)
|
||||||
|
await expect
|
||||||
await expect
|
.poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
.poll(async () => (await overflow(page, file))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
.toBeLessThanOrEqual(1)
|
||||||
.toBeLessThanOrEqual(1)
|
await expect
|
||||||
await expect
|
.poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
.poll(async () => (await overflow(page, file))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
.toBeLessThanOrEqual(1)
|
||||||
.toBeLessThanOrEqual(1)
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await overflow(page, file))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
|
||||||
.toBeLessThanOrEqual(1)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
|
test("review file comments submit on click without clipping actions", async ({ page, llm, project }) => {
|
||||||
test.setTimeout(180_000)
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
const tag = `review-file-comment-${Date.now()}`
|
const tag = `review-file-comment-${Date.now()}`
|
||||||
@@ -291,49 +309,46 @@ test("review file comments submit on click without clipping actions", async ({ p
|
|||||||
|
|
||||||
await page.setViewportSize({ width: 1280, height: 900 })
|
await page.setViewportSize({ width: 1280, height: 900 })
|
||||||
|
|
||||||
await withProject(async (project) => {
|
await project.open()
|
||||||
const sdk = createSdk(project.directory)
|
await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => {
|
||||||
|
project.trackSession(session.id)
|
||||||
|
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
|
||||||
|
|
||||||
await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
|
await expect
|
||||||
await patch(sdk, session.id, seed([{ file, mark: tag }]))
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
|
return diff.length
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(1)
|
||||||
|
|
||||||
await expect
|
await project.gotoSession(session.id)
|
||||||
.poll(
|
await show(page)
|
||||||
async () => {
|
|
||||||
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
|
||||||
return diff.length
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(1)
|
|
||||||
|
|
||||||
await project.gotoSession(session.id)
|
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||||
await show(page)
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
|
||||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
await expand(page)
|
||||||
await expect(tab).toBeVisible()
|
await waitMark(page, file, tag)
|
||||||
await tab.click()
|
await openReviewFile(page, file)
|
||||||
|
await fileComment(page, note)
|
||||||
|
|
||||||
await expand(page)
|
await expect
|
||||||
await waitMark(page, file, tag)
|
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
await openReviewFile(page, file)
|
.toBeLessThanOrEqual(1)
|
||||||
await fileComment(page, note)
|
await expect
|
||||||
|
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
await expect
|
.toBeLessThanOrEqual(1)
|
||||||
.poll(async () => (await fileOverflow(page))?.width ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
await expect
|
||||||
.toBeLessThanOrEqual(1)
|
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
||||||
await expect
|
.toBeLessThanOrEqual(1)
|
||||||
.poll(async () => (await fileOverflow(page))?.pop ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
|
||||||
.toBeLessThanOrEqual(1)
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await fileOverflow(page))?.tools ?? Number.POSITIVE_INFINITY, { timeout: 10_000 })
|
|
||||||
.toBeLessThanOrEqual(1)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => {
|
test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, project }) => {
|
||||||
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
|
|
||||||
test.setTimeout(180_000)
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
const tag = `review-${Date.now()}`
|
const tag = `review-${Date.now()}`
|
||||||
@@ -343,84 +358,83 @@ test("review keeps scroll position after a live diff update", async ({ page, wit
|
|||||||
|
|
||||||
await page.setViewportSize({ width: 1600, height: 1000 })
|
await page.setViewportSize({ width: 1600, height: 1000 })
|
||||||
|
|
||||||
await withProject(async (project) => {
|
await project.open()
|
||||||
const sdk = createSdk(project.directory)
|
await withSession(project.sdk, `e2e review ${tag}`, async (session) => {
|
||||||
|
project.trackSession(session.id)
|
||||||
|
await patchWithMock(llm, project.sdk, session.id, seed(list))
|
||||||
|
|
||||||
await withSession(sdk, `e2e review ${tag}`, async (session) => {
|
await expect
|
||||||
await patch(sdk, session.id, seed(list))
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const info = await project.sdk.session.get({ sessionID: session.id }).then((res) => res.data)
|
||||||
|
return info?.summary?.files ?? 0
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(list.length)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data)
|
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
return info?.summary?.files ?? 0
|
return diff.length
|
||||||
},
|
},
|
||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
)
|
)
|
||||||
.toBe(list.length)
|
.toBe(list.length)
|
||||||
|
|
||||||
await expect
|
await project.gotoSession(session.id)
|
||||||
.poll(
|
await show(page)
|
||||||
async () => {
|
|
||||||
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
|
||||||
return diff.length
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBe(list.length)
|
|
||||||
|
|
||||||
await project.gotoSession(session.id)
|
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
||||||
await show(page)
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
|
||||||
const tab = page.getByRole("tab", { name: /Review/i }).first()
|
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
||||||
await expect(tab).toBeVisible()
|
await expect(view).toBeVisible()
|
||||||
await tab.click()
|
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
|
||||||
|
await expect(heads).toHaveCount(list.length, { timeout: 60_000 })
|
||||||
|
|
||||||
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
|
await expand(page)
|
||||||
await expect(view).toBeVisible()
|
await waitMark(page, hit.file, hit.mark)
|
||||||
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
|
|
||||||
await expect(heads).toHaveCount(list.length, {
|
const row = page
|
||||||
timeout: 60_000,
|
.getByRole("heading", {
|
||||||
|
level: 3,
|
||||||
|
name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
||||||
})
|
})
|
||||||
|
.first()
|
||||||
|
await expect(row).toBeVisible()
|
||||||
|
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
|
||||||
|
|
||||||
await expand(page)
|
await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
|
||||||
await waitMark(page, hit.file, hit.mark)
|
const prev = await spot(page, hit.file)
|
||||||
|
if (!prev) throw new Error(`missing review row for ${hit.file}`)
|
||||||
|
|
||||||
const row = page
|
await patchWithMock(llm, project.sdk, session.id, edit(hit.file, hit.mark, next))
|
||||||
.getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })
|
|
||||||
.first()
|
|
||||||
await expect(row).toBeVisible()
|
|
||||||
await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
|
|
||||||
|
|
||||||
await expect.poll(async () => (await spot(page, hit.file))?.y ?? 0).toBeGreaterThan(200)
|
await expect
|
||||||
const prev = await spot(page, hit.file)
|
.poll(
|
||||||
if (!prev) throw new Error(`missing review row for ${hit.file}`)
|
async () => {
|
||||||
|
const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
||||||
|
const item = diff.find((item) => item.file === hit.file)
|
||||||
|
return typeof item?.after === "string" ? item.after : ""
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toContain(`mark ${next}`)
|
||||||
|
|
||||||
await patch(sdk, session.id, edit(hit.file, hit.mark, next))
|
await waitMark(page, hit.file, next)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
|
const next = await spot(page, hit.file)
|
||||||
const item = diff.find((item) => item.file === hit.file)
|
if (!next) return Number.POSITIVE_INFINITY
|
||||||
return typeof item?.after === "string" ? item.after : ""
|
return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
|
||||||
},
|
},
|
||||||
{ timeout: 60_000 },
|
{ timeout: 60_000 },
|
||||||
)
|
)
|
||||||
.toContain(`mark ${next}`)
|
.toBeLessThanOrEqual(32)
|
||||||
|
|
||||||
await waitMark(page, hit.file, next)
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const next = await spot(page, hit.file)
|
|
||||||
if (!next) return Number.POSITIVE_INFINITY
|
|
||||||
return Math.max(Math.abs(next.top - prev.top), Math.abs(next.y - prev.y))
|
|
||||||
},
|
|
||||||
{ timeout: 60_000 },
|
|
||||||
)
|
|
||||||
.toBeLessThanOrEqual(32)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -49,185 +49,185 @@ async function seedConversation(input: {
|
|||||||
return { prompt, userMessageID }
|
return { prompt, userMessageID }
|
||||||
}
|
}
|
||||||
|
|
||||||
test("slash undo sets revert and restores prior prompt", async ({ page, withProject }) => {
|
test("slash undo sets revert and restores prior prompt", async ({ page, project }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const token = `undo_${Date.now()}`
|
const token = `undo_${Date.now()}`
|
||||||
|
|
||||||
await withProject(async (project) => {
|
await project.open()
|
||||||
const sdk = createSdk(project.directory)
|
const sdk = project.sdk
|
||||||
|
|
||||||
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
|
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
|
||||||
await project.gotoSession(session.id)
|
project.trackSession(session.id)
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
||||||
|
|
||||||
await seeded.prompt.click()
|
await seeded.prompt.click()
|
||||||
await page.keyboard.type("/undo")
|
await page.keyboard.type("/undo")
|
||||||
|
|
||||||
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
||||||
await expect(undo).toBeVisible()
|
await expect(undo).toBeVisible()
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
})
|
})
|
||||||
.toBe(seeded.userMessageID)
|
.toBe(seeded.userMessageID)
|
||||||
|
|
||||||
await expect(seeded.prompt).toContainText(token)
|
await expect(seeded.prompt).toContainText(token)
|
||||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
|
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("slash redo clears revert and restores latest state", async ({ page, withProject }) => {
|
test("slash redo clears revert and restores latest state", async ({ page, project }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const token = `redo_${Date.now()}`
|
const token = `redo_${Date.now()}`
|
||||||
|
|
||||||
await withProject(async (project) => {
|
await project.open()
|
||||||
const sdk = createSdk(project.directory)
|
const sdk = project.sdk
|
||||||
|
|
||||||
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
|
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
|
||||||
await project.gotoSession(session.id)
|
project.trackSession(session.id)
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
|
||||||
|
|
||||||
await seeded.prompt.click()
|
await seeded.prompt.click()
|
||||||
await page.keyboard.type("/undo")
|
await page.keyboard.type("/undo")
|
||||||
|
|
||||||
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
||||||
await expect(undo).toBeVisible()
|
await expect(undo).toBeVisible()
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
})
|
})
|
||||||
.toBe(seeded.userMessageID)
|
.toBe(seeded.userMessageID)
|
||||||
|
|
||||||
await seeded.prompt.click()
|
await seeded.prompt.click()
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
await page.keyboard.press("Backspace")
|
await page.keyboard.press("Backspace")
|
||||||
await page.keyboard.type("/redo")
|
await page.keyboard.type("/redo")
|
||||||
|
|
||||||
const redo = page.locator('[data-slash-id="session.redo"]').first()
|
const redo = page.locator('[data-slash-id="session.redo"]').first()
|
||||||
await expect(redo).toBeVisible()
|
await expect(redo).toBeVisible()
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
})
|
})
|
||||||
.toBeUndefined()
|
.toBeUndefined()
|
||||||
|
|
||||||
await expect(seeded.prompt).not.toContainText(token)
|
await expect(seeded.prompt).not.toContainText(token)
|
||||||
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
|
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(1)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("slash undo/redo traverses multi-step revert stack", async ({ page, withProject }) => {
|
test("slash undo/redo traverses multi-step revert stack", async ({ page, project }) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const firstToken = `undo_redo_first_${Date.now()}`
|
const firstToken = `undo_redo_first_${Date.now()}`
|
||||||
const secondToken = `undo_redo_second_${Date.now()}`
|
const secondToken = `undo_redo_second_${Date.now()}`
|
||||||
|
|
||||||
await withProject(async (project) => {
|
await project.open()
|
||||||
const sdk = createSdk(project.directory)
|
const sdk = project.sdk
|
||||||
|
|
||||||
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
|
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
|
||||||
await project.gotoSession(session.id)
|
project.trackSession(session.id)
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
|
||||||
const first = await seedConversation({
|
const first = await seedConversation({
|
||||||
page,
|
page,
|
||||||
sdk,
|
sdk,
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
token: firstToken,
|
token: firstToken,
|
||||||
})
|
|
||||||
const second = await seedConversation({
|
|
||||||
page,
|
|
||||||
sdk,
|
|
||||||
sessionID: session.id,
|
|
||||||
token: secondToken,
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(first.userMessageID).not.toBe(second.userMessageID)
|
|
||||||
|
|
||||||
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
|
|
||||||
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
|
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(1)
|
|
||||||
await expect(secondMessage).toHaveCount(1)
|
|
||||||
|
|
||||||
await second.prompt.click()
|
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
|
||||||
await page.keyboard.press("Backspace")
|
|
||||||
await page.keyboard.type("/undo")
|
|
||||||
|
|
||||||
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
|
||||||
await expect(undo).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
.toBe(second.userMessageID)
|
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(1)
|
|
||||||
await expect(secondMessage).toHaveCount(0)
|
|
||||||
|
|
||||||
await second.prompt.click()
|
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
|
||||||
await page.keyboard.press("Backspace")
|
|
||||||
await page.keyboard.type("/undo")
|
|
||||||
await expect(undo).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
.toBe(first.userMessageID)
|
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(0)
|
|
||||||
await expect(secondMessage).toHaveCount(0)
|
|
||||||
|
|
||||||
await second.prompt.click()
|
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
|
||||||
await page.keyboard.press("Backspace")
|
|
||||||
await page.keyboard.type("/redo")
|
|
||||||
|
|
||||||
const redo = page.locator('[data-slash-id="session.redo"]').first()
|
|
||||||
await expect(redo).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
.toBe(second.userMessageID)
|
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(1)
|
|
||||||
await expect(secondMessage).toHaveCount(0)
|
|
||||||
|
|
||||||
await second.prompt.click()
|
|
||||||
await page.keyboard.press(`${modKey}+A`)
|
|
||||||
await page.keyboard.press("Backspace")
|
|
||||||
await page.keyboard.type("/redo")
|
|
||||||
await expect(redo).toBeVisible()
|
|
||||||
await page.keyboard.press("Enter")
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
|
||||||
timeout: 30_000,
|
|
||||||
})
|
|
||||||
.toBeUndefined()
|
|
||||||
|
|
||||||
await expect(firstMessage).toHaveCount(1)
|
|
||||||
await expect(secondMessage).toHaveCount(1)
|
|
||||||
})
|
})
|
||||||
|
const second = await seedConversation({
|
||||||
|
page,
|
||||||
|
sdk,
|
||||||
|
sessionID: session.id,
|
||||||
|
token: secondToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(first.userMessageID).not.toBe(second.userMessageID)
|
||||||
|
|
||||||
|
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
|
||||||
|
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
|
||||||
|
|
||||||
|
await expect(firstMessage).toHaveCount(1)
|
||||||
|
await expect(secondMessage).toHaveCount(1)
|
||||||
|
|
||||||
|
await second.prompt.click()
|
||||||
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
|
await page.keyboard.press("Backspace")
|
||||||
|
await page.keyboard.type("/undo")
|
||||||
|
|
||||||
|
const undo = page.locator('[data-slash-id="session.undo"]').first()
|
||||||
|
await expect(undo).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBe(second.userMessageID)
|
||||||
|
|
||||||
|
await expect(firstMessage).toHaveCount(1)
|
||||||
|
await expect(secondMessage).toHaveCount(0)
|
||||||
|
|
||||||
|
await second.prompt.click()
|
||||||
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
|
await page.keyboard.press("Backspace")
|
||||||
|
await page.keyboard.type("/undo")
|
||||||
|
await expect(undo).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBe(first.userMessageID)
|
||||||
|
|
||||||
|
await expect(firstMessage).toHaveCount(0)
|
||||||
|
await expect(secondMessage).toHaveCount(0)
|
||||||
|
|
||||||
|
await second.prompt.click()
|
||||||
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
|
await page.keyboard.press("Backspace")
|
||||||
|
await page.keyboard.type("/redo")
|
||||||
|
|
||||||
|
const redo = page.locator('[data-slash-id="session.redo"]').first()
|
||||||
|
await expect(redo).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBe(second.userMessageID)
|
||||||
|
|
||||||
|
await expect(firstMessage).toHaveCount(1)
|
||||||
|
await expect(secondMessage).toHaveCount(0)
|
||||||
|
|
||||||
|
await second.prompt.click()
|
||||||
|
await page.keyboard.press(`${modKey}+A`)
|
||||||
|
await page.keyboard.press("Backspace")
|
||||||
|
await page.keyboard.type("/redo")
|
||||||
|
await expect(redo).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
|
||||||
|
timeout: 30_000,
|
||||||
|
})
|
||||||
|
.toBeUndefined()
|
||||||
|
|
||||||
|
await expect(firstMessage).toHaveCount(1)
|
||||||
|
await expect(secondMessage).toHaveCount(1)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,14 +31,16 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
|
|||||||
.toBeGreaterThan(0)
|
.toBeGreaterThan(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
|
test("session can be renamed via header menu", async ({ page, project }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const originalTitle = `e2e rename test ${stamp}`
|
const originalTitle = `e2e rename test ${stamp}`
|
||||||
const renamedTitle = `e2e renamed ${stamp}`
|
const renamedTitle = `e2e renamed ${stamp}`
|
||||||
|
|
||||||
await withSession(sdk, originalTitle, async (session) => {
|
await project.open()
|
||||||
await seedMessage(sdk, session.id)
|
await withSession(project.sdk, originalTitle, async (session) => {
|
||||||
await gotoSession(session.id)
|
project.trackSession(session.id)
|
||||||
|
await seedMessage(project.sdk, session.id)
|
||||||
|
await project.gotoSession(session.id)
|
||||||
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
|
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
|
||||||
|
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
@@ -54,7 +56,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.title
|
return data?.title
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
@@ -65,20 +67,22 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session can be archived via header menu", async ({ page, sdk, gotoSession }) => {
|
test("session can be archived via header menu", async ({ page, project }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const title = `e2e archive test ${stamp}`
|
const title = `e2e archive test ${stamp}`
|
||||||
|
|
||||||
await withSession(sdk, title, async (session) => {
|
await project.open()
|
||||||
await seedMessage(sdk, session.id)
|
await withSession(project.sdk, title, async (session) => {
|
||||||
await gotoSession(session.id)
|
project.trackSession(session.id)
|
||||||
|
await seedMessage(project.sdk, session.id)
|
||||||
|
await project.gotoSession(session.id)
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /archive/i)
|
await clickMenuItem(menu, /archive/i)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.time?.archived
|
return data?.time?.archived
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
@@ -90,13 +94,15 @@ test("session can be archived via header menu", async ({ page, sdk, gotoSession
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session can be deleted via header menu", async ({ page, sdk, gotoSession }) => {
|
test("session can be deleted via header menu", async ({ page, project }) => {
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const title = `e2e delete test ${stamp}`
|
const title = `e2e delete test ${stamp}`
|
||||||
|
|
||||||
await withSession(sdk, title, async (session) => {
|
await project.open()
|
||||||
await seedMessage(sdk, session.id)
|
await withSession(project.sdk, title, async (session) => {
|
||||||
await gotoSession(session.id)
|
project.trackSession(session.id)
|
||||||
|
await seedMessage(project.sdk, session.id)
|
||||||
|
await project.gotoSession(session.id)
|
||||||
const menu = await openSessionMoreMenu(page, session.id)
|
const menu = await openSessionMoreMenu(page, session.id)
|
||||||
await clickMenuItem(menu, /delete/i)
|
await clickMenuItem(menu, /delete/i)
|
||||||
await confirmDialog(page, /delete/i)
|
await confirmDialog(page, /delete/i)
|
||||||
@@ -104,7 +110,7 @@ test("session can be deleted via header menu", async ({ page, sdk, gotoSession }
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await sdk.session
|
const data = await project.sdk.session
|
||||||
.get({ sessionID: session.id })
|
.get({ sessionID: session.id })
|
||||||
.then((r) => r.data)
|
.then((r) => r.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
@@ -119,15 +125,17 @@ test("session can be deleted via header menu", async ({ page, sdk, gotoSession }
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => {
|
test("session can be shared and unshared via header button", async ({ page, project }) => {
|
||||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||||
|
|
||||||
const stamp = Date.now()
|
const stamp = Date.now()
|
||||||
const title = `e2e share test ${stamp}`
|
const title = `e2e share test ${stamp}`
|
||||||
|
|
||||||
await withSession(sdk, title, async (session) => {
|
await project.open()
|
||||||
await seedMessage(sdk, session.id)
|
await withSession(project.sdk, title, async (session) => {
|
||||||
await gotoSession(session.id)
|
project.trackSession(session.id)
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
await project.prompt(`share seed ${stamp}`)
|
||||||
|
|
||||||
const shared = await openSharePopover(page)
|
const shared = await openSharePopover(page)
|
||||||
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
||||||
@@ -141,7 +149,7 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.share?.url || undefined
|
return data?.share?.url || undefined
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
@@ -159,7 +167,7 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
|
|||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||||
return data?.share?.url || undefined
|
return data?.share?.url || undefined
|
||||||
},
|
},
|
||||||
{ timeout: 30_000 },
|
{ timeout: 30_000 },
|
||||||
|
|||||||
@@ -88,10 +88,20 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
|
|||||||
return document.documentElement.getAttribute("data-theme")
|
return document.documentElement.getAttribute("data-theme")
|
||||||
})
|
})
|
||||||
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
||||||
|
const trigger = select.locator('[data-slot="select-select-trigger"]')
|
||||||
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"]')
|
||||||
|
|
||||||
|
await trigger.click()
|
||||||
|
const open = await expect
|
||||||
|
.poll(async () => (await items.count()) > 0, { timeout: 5_000 })
|
||||||
|
.toBe(true)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (!open) {
|
||||||
|
await trigger.click()
|
||||||
|
await expect.poll(async () => (await items.count()) > 0, { timeout: 10_000 }).toBe(true)
|
||||||
|
}
|
||||||
|
await expect(items.first()).toBeVisible()
|
||||||
const count = await items.count()
|
const count = await items.count()
|
||||||
expect(count).toBeGreaterThan(1)
|
expect(count).toBeGreaterThan(1)
|
||||||
|
|
||||||
|
|||||||
@@ -48,70 +48,61 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
|
test("open sidebar project popover stays closed after clicking avatar", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const slug = dirSlug(other)
|
const slug = dirSlug(other)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await project.open({ extra: [other] })
|
||||||
async () => {
|
await openSidebar(page)
|
||||||
await openSidebar(page)
|
|
||||||
|
|
||||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
const projectButton = page.locator(projectSwitchSelector(slug)).first()
|
||||||
const card = page.locator('[data-component="hover-card-content"]')
|
const card = page.locator('[data-component="hover-card-content"]')
|
||||||
|
|
||||||
await expect(project).toBeVisible()
|
await expect(projectButton).toBeVisible()
|
||||||
await project.hover()
|
await projectButton.hover()
|
||||||
await expect(card.getByText(/recent sessions/i)).toBeVisible()
|
await expect(card.getByText(/recent sessions/i)).toBeVisible()
|
||||||
|
|
||||||
await page.mouse.down()
|
await projectButton.click()
|
||||||
await expect(card).toHaveCount(0)
|
await expect(card).toHaveCount(0)
|
||||||
await page.mouse.up()
|
|
||||||
|
|
||||||
await waitSession(page, { directory: other })
|
await waitSession(page, { directory: other })
|
||||||
await expect(card).toHaveCount(0)
|
await expect(card).toHaveCount(0)
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
|
test("open sidebar project switch activates on first tabbed enter", async ({ page, project }) => {
|
||||||
await page.setViewportSize({ width: 1400, height: 800 })
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
const other = await createTestProject()
|
const other = await createTestProject()
|
||||||
const slug = dirSlug(other)
|
const slug = dirSlug(other)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await project.open({ extra: [other] })
|
||||||
async () => {
|
await openSidebar(page)
|
||||||
await openSidebar(page)
|
await defocus(page)
|
||||||
await defocus(page)
|
|
||||||
|
|
||||||
const project = page.locator(projectSwitchSelector(slug)).first()
|
const projectButton = page.locator(projectSwitchSelector(slug)).first()
|
||||||
|
|
||||||
await expect(project).toBeVisible()
|
await expect(projectButton).toBeVisible()
|
||||||
|
|
||||||
let hit = false
|
let hit = false
|
||||||
for (let i = 0; i < 20; i++) {
|
for (let i = 0; i < 20; i++) {
|
||||||
hit = await project.evaluate((el) => {
|
hit = await projectButton.evaluate((el) => {
|
||||||
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
|
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
|
||||||
})
|
})
|
||||||
if (hit) break
|
if (hit) break
|
||||||
await page.keyboard.press("Tab")
|
await page.keyboard.press("Tab")
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(hit).toBe(true)
|
expect(hit).toBe(true)
|
||||||
|
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
await waitSession(page, { directory: other })
|
await waitSession(page, { directory: other })
|
||||||
},
|
|
||||||
{ extra: [other] },
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
await cleanupTestProject(other)
|
await cleanupTestProject(other)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,35 +12,34 @@ async function open(page: Page) {
|
|||||||
return term
|
return term
|
||||||
}
|
}
|
||||||
|
|
||||||
test("terminal reconnects without replacing the pty", async ({ page, withProject }) => {
|
test("terminal reconnects without replacing the pty", async ({ page, project }) => {
|
||||||
await withProject(async ({ gotoSession }) => {
|
await project.open()
|
||||||
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
|
const name = `OPENCODE_E2E_RECONNECT_${Date.now()}`
|
||||||
const token = `E2E_RECONNECT_${Date.now()}`
|
const token = `E2E_RECONNECT_${Date.now()}`
|
||||||
|
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
|
|
||||||
const term = await open(page)
|
const term = await open(page)
|
||||||
const id = await term.getAttribute("data-pty-id")
|
const id = await term.getAttribute("data-pty-id")
|
||||||
if (!id) throw new Error("Active terminal missing data-pty-id")
|
if (!id) throw new Error("Active terminal missing data-pty-id")
|
||||||
|
|
||||||
const prev = await terminalConnects(page, { term })
|
const prev = await terminalConnects(page, { term })
|
||||||
|
|
||||||
await runTerminal(page, {
|
await runTerminal(page, {
|
||||||
term,
|
term,
|
||||||
cmd: `export ${name}=${token}; echo ${token}`,
|
cmd: `export ${name}=${token}; echo ${token}`,
|
||||||
token,
|
token,
|
||||||
})
|
})
|
||||||
|
|
||||||
await disconnectTerminal(page, { term })
|
await disconnectTerminal(page, { term })
|
||||||
|
|
||||||
await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev)
|
await expect.poll(() => terminalConnects(page, { term }), { timeout: 15_000 }).toBeGreaterThan(prev)
|
||||||
await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id)
|
await expect.poll(() => term.getAttribute("data-pty-id"), { timeout: 5_000 }).toBe(id)
|
||||||
|
|
||||||
await runTerminal(page, {
|
await runTerminal(page, {
|
||||||
term,
|
term,
|
||||||
cmd: `echo $${name}`,
|
cmd: `echo $${name}`,
|
||||||
token,
|
token,
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -36,133 +36,130 @@ async function store(page: Page, key: string) {
|
|||||||
}, key)
|
}, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
|
test("inactive terminal tab buffers persist across tab switches", async ({ page, project }) => {
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
await project.open()
|
||||||
const key = workspacePersistKey(directory, "terminal")
|
const key = workspacePersistKey(project.directory, "terminal")
|
||||||
const one = `E2E_TERM_ONE_${Date.now()}`
|
const one = `E2E_TERM_ONE_${Date.now()}`
|
||||||
const two = `E2E_TERM_TWO_${Date.now()}`
|
const two = `E2E_TERM_TWO_${Date.now()}`
|
||||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||||
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||||
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||||
|
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
await open(page)
|
await open(page)
|
||||||
|
|
||||||
await runTerminal(page, { cmd: `echo ${one}`, token: one })
|
await runTerminal(page, { cmd: `echo ${one}`, token: one })
|
||||||
|
|
||||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||||
await expect(tabs).toHaveCount(2)
|
await expect(tabs).toHaveCount(2)
|
||||||
|
|
||||||
await runTerminal(page, { cmd: `echo ${two}`, token: two })
|
await runTerminal(page, { cmd: `echo ${two}`, token: two })
|
||||||
|
|
||||||
await first.click()
|
await first.click()
|
||||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const state = await store(page, key)
|
const state = await store(page, key)
|
||||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||||
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||||
return {
|
return {
|
||||||
first: first.includes(one),
|
first: first.includes(one),
|
||||||
second: second.includes(two),
|
second: second.includes(two),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ timeout: 5_000 },
|
{ timeout: 5_000 },
|
||||||
)
|
)
|
||||||
.toEqual({ first: false, second: true })
|
.toEqual({ first: false, second: true })
|
||||||
|
|
||||||
await second.click()
|
await second.click()
|
||||||
await expect(second).toHaveAttribute("aria-selected", "true")
|
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const state = await store(page, key)
|
const state = await store(page, key)
|
||||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||||
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||||
return {
|
return {
|
||||||
first: first.includes(one),
|
first: first.includes(one),
|
||||||
second: second.includes(two),
|
second: second.includes(two),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ timeout: 5_000 },
|
{ timeout: 5_000 },
|
||||||
)
|
)
|
||||||
.toEqual({ first: true, second: false })
|
.toEqual({ first: true, second: false })
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("closing the active terminal tab falls back to the previous tab", async ({ page, withProject }) => {
|
test("closing the active terminal tab falls back to the previous tab", async ({ page, project }) => {
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
await project.open()
|
||||||
const key = workspacePersistKey(directory, "terminal")
|
const key = workspacePersistKey(project.directory, "terminal")
|
||||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||||
|
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
await open(page)
|
await open(page)
|
||||||
|
|
||||||
await page.getByRole("button", { name: /new terminal/i }).click()
|
await page.getByRole("button", { name: /new terminal/i }).click()
|
||||||
await expect(tabs).toHaveCount(2)
|
await expect(tabs).toHaveCount(2)
|
||||||
|
|
||||||
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||||
await second.click()
|
await second.click()
|
||||||
await expect(second).toHaveAttribute("aria-selected", "true")
|
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
await second.hover()
|
await second.hover()
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: /close terminal/i })
|
.getByRole("button", { name: /close terminal/i })
|
||||||
.nth(1)
|
.nth(1)
|
||||||
.click({ force: true })
|
.click({ force: true })
|
||||||
|
|
||||||
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||||
await expect(tabs).toHaveCount(1)
|
await expect(tabs).toHaveCount(1)
|
||||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const state = await store(page, key)
|
const state = await store(page, key)
|
||||||
return {
|
return {
|
||||||
count: state?.all.length ?? 0,
|
count: state?.all.length ?? 0,
|
||||||
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
|
first: state?.all.some((item) => item.titleNumber === 1) ?? false,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
)
|
)
|
||||||
.toEqual({ count: 1, first: true })
|
.toEqual({ count: 1, first: true })
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("terminal tab can be renamed from the context menu", async ({ page, withProject }) => {
|
test("terminal tab can be renamed from the context menu", async ({ page, project }) => {
|
||||||
await withProject(async ({ directory, gotoSession }) => {
|
await project.open()
|
||||||
const key = workspacePersistKey(directory, "terminal")
|
const key = workspacePersistKey(project.directory, "terminal")
|
||||||
const rename = `E2E term ${Date.now()}`
|
const rename = `E2E term ${Date.now()}`
|
||||||
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
|
const tab = page.locator('#terminal-panel [data-slot="tabs-trigger"]').first()
|
||||||
|
|
||||||
await gotoSession()
|
await project.gotoSession()
|
||||||
await open(page)
|
await open(page)
|
||||||
|
|
||||||
await expect(tab).toContainText(/Terminal 1/)
|
await expect(tab).toContainText(/Terminal 1/)
|
||||||
await tab.click({ button: "right" })
|
await tab.click({ button: "right" })
|
||||||
|
|
||||||
const menu = page.locator(dropdownMenuContentSelector).first()
|
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||||
await expect(menu).toBeVisible()
|
await expect(menu).toBeVisible()
|
||||||
await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
|
await menu.getByRole("menuitem", { name: /^Rename$/i }).click()
|
||||||
await expect(menu).toHaveCount(0)
|
await expect(menu).toHaveCount(0)
|
||||||
|
|
||||||
const input = page.locator('#terminal-panel input[type="text"]').first()
|
const input = page.locator('#terminal-panel input[type="text"]').first()
|
||||||
await expect(input).toBeVisible()
|
await expect(input).toBeVisible()
|
||||||
await input.fill(rename)
|
await input.fill(rename)
|
||||||
await input.press("Enter")
|
await input.press("Enter")
|
||||||
|
|
||||||
await expect(input).toHaveCount(0)
|
await expect(input).toHaveCount(0)
|
||||||
await expect(tab).toContainText(rename)
|
await expect(tab).toContainText(rename)
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const state = await store(page, key)
|
const state = await store(page, key)
|
||||||
return state?.all[0]?.title
|
return state?.all[0]?.title
|
||||||
},
|
},
|
||||||
{ timeout: 5_000 },
|
{ timeout: 5_000 },
|
||||||
)
|
)
|
||||||
.toBe(rename)
|
.toBe(rename)
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,21 +26,21 @@ export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("
|
|||||||
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
||||||
export const terminalToggleKey = "Control+Backquote"
|
export const terminalToggleKey = "Control+Backquote"
|
||||||
|
|
||||||
export function createSdk(directory?: string) {
|
export function createSdk(directory?: string, baseUrl = serverUrl) {
|
||||||
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
return createOpencodeClient({ baseUrl, directory, throwOnError: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveDirectory(directory: string) {
|
export async function resolveDirectory(directory: string, baseUrl = serverUrl) {
|
||||||
return createSdk(directory)
|
return createSdk(directory, baseUrl)
|
||||||
.path.get()
|
.path.get()
|
||||||
.then((x) => x.data?.directory ?? directory)
|
.then((x) => x.data?.directory ?? directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWorktree() {
|
export async function getWorktree(baseUrl = serverUrl) {
|
||||||
const sdk = createSdk()
|
const sdk = createSdk(undefined, baseUrl)
|
||||||
const result = await sdk.path.get()
|
const result = await sdk.path.get()
|
||||||
const data = result.data
|
const data = result.data
|
||||||
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${serverUrl}/path`)
|
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${baseUrl}/path`)
|
||||||
return data.worktree
|
return data.worktree
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.3.7",
|
"version": "1.4.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"test": "bun run test:unit",
|
"test": "bun run test:unit",
|
||||||
|
"test:ci": "mkdir -p .artifacts/unit && bun test --preload ./happydom.ts ./src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
|
||||||
"test:unit": "bun test --preload ./happydom.ts ./src",
|
"test:unit": "bun test --preload ./happydom.ts ./src",
|
||||||
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
@@ -46,9 +47,10 @@
|
|||||||
"@solid-primitives/active-element": "2.1.3",
|
"@solid-primitives/active-element": "2.1.3",
|
||||||
"@solid-primitives/audio": "1.4.2",
|
"@solid-primitives/audio": "1.4.2",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
|
"@solid-primitives/event-listener": "2.4.5",
|
||||||
"@solid-primitives/i18n": "2.2.1",
|
"@solid-primitives/i18n": "2.2.1",
|
||||||
"@solid-primitives/media": "2.3.3",
|
"@solid-primitives/media": "2.3.3",
|
||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.5",
|
||||||
"@solid-primitives/scroll": "2.1.3",
|
"@solid-primitives/scroll": "2.1.3",
|
||||||
"@solid-primitives/storage": "catalog:",
|
"@solid-primitives/storage": "catalog:",
|
||||||
"@solid-primitives/timer": "1.4.4",
|
"@solid-primitives/timer": "1.4.4",
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
|||||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||||
const reuse = !process.env.CI
|
const reuse = !process.env.CI
|
||||||
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
|
||||||
|
const reporter = [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]] as const
|
||||||
|
|
||||||
|
if (process.env.PLAYWRIGHT_JUNIT_OUTPUT) {
|
||||||
|
reporter.push(["junit", { outputFile: process.env.PLAYWRIGHT_JUNIT_OUTPUT }])
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: "./e2e",
|
testDir: "./e2e",
|
||||||
@@ -19,7 +24,7 @@ export default defineConfig({
|
|||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers,
|
workers,
|
||||||
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
reporter,
|
||||||
webServer: {
|
webServer: {
|
||||||
command,
|
command,
|
||||||
url: baseURL,
|
url: baseURL,
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const serverEnv = {
|
|||||||
OPENCODE_E2E_PROJECT_DIR: repoDir,
|
OPENCODE_E2E_PROJECT_DIR: repoDir,
|
||||||
OPENCODE_E2E_SESSION_TITLE: "E2E Session",
|
OPENCODE_E2E_SESSION_TITLE: "E2E Session",
|
||||||
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
|
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
|
||||||
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
|
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano",
|
||||||
OPENCODE_CLIENT: "app",
|
OPENCODE_CLIENT: "app",
|
||||||
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
||||||
} satisfies Record<string, string>
|
} satisfies Record<string, string>
|
||||||
@@ -87,7 +87,7 @@ const runnerEnv = {
|
|||||||
|
|
||||||
let seed: ReturnType<typeof Bun.spawn> | undefined
|
let seed: ReturnType<typeof Bun.spawn> | undefined
|
||||||
let runner: ReturnType<typeof Bun.spawn> | undefined
|
let runner: ReturnType<typeof Bun.spawn> | undefined
|
||||||
let server: { stop: () => Promise<void> | void } | undefined
|
let server: { stop: (close?: boolean) => Promise<void> | void } | undefined
|
||||||
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
|
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
|
||||||
let cleaned = false
|
let cleaned = false
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ const cleanup = async () => {
|
|||||||
|
|
||||||
const jobs = [
|
const jobs = [
|
||||||
inst?.Instance.disposeAll(),
|
inst?.Instance.disposeAll(),
|
||||||
server?.stop(),
|
typeof server?.stop === "function" ? server.stop() : undefined,
|
||||||
keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }),
|
keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }),
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
await Promise.allSettled(jobs)
|
await Promise.allSettled(jobs)
|
||||||
@@ -158,7 +158,7 @@ try {
|
|||||||
|
|
||||||
const servermod = await import("../../opencode/src/server/server")
|
const servermod = await import("../../opencode/src/server/server")
|
||||||
inst = await import("../../opencode/src/project/instance")
|
inst = await import("../../opencode/src/project/instance")
|
||||||
server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
|
server = await servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" })
|
||||||
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
|
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
|
||||||
|
|
||||||
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)
|
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)
|
||||||
|
|||||||
@@ -182,7 +182,6 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
|||||||
if (checkMode() === "background" || type === "http") return false
|
if (checkMode() === "background" || type === "http") return false
|
||||||
}
|
}
|
||||||
}).pipe(
|
}).pipe(
|
||||||
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
|
|
||||||
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
|
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
|
||||||
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
|
||||||
Effect.runPromise,
|
Effect.runPromise,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useIsRouting, useLocation } from "@solidjs/router"
|
import { useIsRouting, useLocation } from "@solidjs/router"
|
||||||
import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
import { batch, createEffect, onCleanup, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
|
||||||
@@ -349,13 +350,12 @@ export function DebugBar() {
|
|||||||
|
|
||||||
syncHeap()
|
syncHeap()
|
||||||
start()
|
start()
|
||||||
document.addEventListener("visibilitychange", vis)
|
makeEventListener(document, "visibilitychange", vis)
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (one !== 0) cancelAnimationFrame(one)
|
if (one !== 0) cancelAnimationFrame(one)
|
||||||
if (two !== 0) cancelAnimationFrame(two)
|
if (two !== 0) cancelAnimationFrame(two)
|
||||||
stop()
|
stop()
|
||||||
document.removeEventListener("visibilitychange", vis)
|
|
||||||
for (const ob of obs) ob.disconnect()
|
for (const ob of obs) ob.disconnect()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ const ModelList: Component<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
|
||||||
|
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
|
||||||
|
|
||||||
export function ModelSelectorPopover(props: {
|
export function ModelSelectorPopover(props: {
|
||||||
provider?: string
|
provider?: string
|
||||||
@@ -93,25 +94,31 @@ export function ModelSelectorPopover(props: {
|
|||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
triggerAs?: ValidComponent
|
triggerAs?: ValidComponent
|
||||||
triggerProps?: ModelSelectorTriggerProps
|
triggerProps?: ModelSelectorTriggerProps
|
||||||
|
onClose?: (cause: "escape" | "select") => void
|
||||||
}) {
|
}) {
|
||||||
const [store, setStore] = createStore<{
|
const [store, setStore] = createStore<{
|
||||||
open: boolean
|
open: boolean
|
||||||
dismiss: "escape" | "outside" | null
|
dismiss: Dismiss | null
|
||||||
}>({
|
}>({
|
||||||
open: false,
|
open: false,
|
||||||
dismiss: null,
|
dismiss: null,
|
||||||
})
|
})
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
||||||
const handleManage = () => {
|
const close = (dismiss: Dismiss) => {
|
||||||
|
setStore("dismiss", dismiss)
|
||||||
setStore("open", false)
|
setStore("open", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleManage = () => {
|
||||||
|
close("manage")
|
||||||
void import("./dialog-manage-models").then((x) => {
|
void import("./dialog-manage-models").then((x) => {
|
||||||
dialog.show(() => <x.DialogManageModels />)
|
dialog.show(() => <x.DialogManageModels />)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConnectProvider = () => {
|
const handleConnectProvider = () => {
|
||||||
setStore("open", false)
|
close("provider")
|
||||||
void import("./dialog-select-provider").then((x) => {
|
void import("./dialog-select-provider").then((x) => {
|
||||||
dialog.show(() => <x.DialogSelectProvider />)
|
dialog.show(() => <x.DialogSelectProvider />)
|
||||||
})
|
})
|
||||||
@@ -136,21 +143,19 @@ export function ModelSelectorPopover(props: {
|
|||||||
<Kobalte.Content
|
<Kobalte.Content
|
||||||
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
|
||||||
onEscapeKeyDown={(event) => {
|
onEscapeKeyDown={(event) => {
|
||||||
setStore("dismiss", "escape")
|
close("escape")
|
||||||
setStore("open", false)
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}}
|
}}
|
||||||
onPointerDownOutside={() => {
|
onPointerDownOutside={() => close("outside")}
|
||||||
setStore("dismiss", "outside")
|
onFocusOutside={() => close("outside")}
|
||||||
setStore("open", false)
|
|
||||||
}}
|
|
||||||
onFocusOutside={() => {
|
|
||||||
setStore("dismiss", "outside")
|
|
||||||
setStore("open", false)
|
|
||||||
}}
|
|
||||||
onCloseAutoFocus={(event) => {
|
onCloseAutoFocus={(event) => {
|
||||||
if (store.dismiss === "outside") event.preventDefault()
|
const dismiss = store.dismiss
|
||||||
|
if (dismiss === "outside") event.preventDefault()
|
||||||
|
if (dismiss === "escape" || dismiss === "select") {
|
||||||
|
event.preventDefault()
|
||||||
|
props.onClose?.(dismiss)
|
||||||
|
}
|
||||||
setStore("dismiss", null)
|
setStore("dismiss", null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -158,7 +163,7 @@ export function ModelSelectorPopover(props: {
|
|||||||
<ModelList
|
<ModelList
|
||||||
provider={props.provider}
|
provider={props.provider}
|
||||||
model={props.model}
|
model={props.model}
|
||||||
onSelect={() => setStore("open", false)}
|
onSelect={() => close("select")}
|
||||||
class="p-1"
|
class="p-1"
|
||||||
action={
|
action={
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
|||||||
@@ -243,23 +243,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
const working = createMemo(() => status()?.type !== "idle")
|
const working = createMemo(() => status()?.type !== "idle")
|
||||||
const tip = () => {
|
|
||||||
if (working()) {
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{language.t("prompt.action.stop")}</span>
|
|
||||||
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span>{language.t("prompt.action.send")}</span>
|
|
||||||
<Icon name="enter" size="small" class="text-icon-base" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const imageAttachments = createMemo(() =>
|
const imageAttachments = createMemo(() =>
|
||||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||||
)
|
)
|
||||||
@@ -297,6 +280,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (store.mode === "shell") return 0
|
if (store.mode === "shell") return 0
|
||||||
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
|
||||||
})
|
})
|
||||||
|
const blank = createMemo(() => {
|
||||||
|
const text = prompt
|
||||||
|
.current()
|
||||||
|
.map((part) => ("content" in part ? part.content : ""))
|
||||||
|
.join("")
|
||||||
|
return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0
|
||||||
|
})
|
||||||
|
const stopping = createMemo(() => working() && blank())
|
||||||
|
const tip = () => {
|
||||||
|
if (stopping()) {
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{language.t("prompt.action.stop")}</span>
|
||||||
|
<span class="text-icon-base text-12-medium text-[10px]!">{language.t("common.key.esc")}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{language.t("prompt.action.send")}</span>
|
||||||
|
<Icon name="enter" size="small" class="text-icon-base" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const contextItems = createMemo(() => {
|
const contextItems = createMemo(() => {
|
||||||
const items = prompt.context.items()
|
const items = prompt.context.items()
|
||||||
@@ -502,6 +510,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
return getCursorPosition(editorRef)
|
return getCursorPosition(editorRef)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const restoreFocus = () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const cursor = prompt.cursor() ?? promptLength(prompt.current())
|
||||||
|
editorRef.focus()
|
||||||
|
setCursorPosition(editorRef, cursor)
|
||||||
|
queueScroll()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const renderEditorWithCursor = (parts: Prompt) => {
|
const renderEditorWithCursor = (parts: Prompt) => {
|
||||||
const cursor = currentCursor()
|
const cursor = currentCursor()
|
||||||
renderEditor(parts)
|
renderEditor(parts)
|
||||||
@@ -624,17 +641,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (!cmd) return
|
if (!cmd) return
|
||||||
promptProbe.select(cmd.id)
|
promptProbe.select(cmd.id)
|
||||||
closePopover()
|
closePopover()
|
||||||
|
const images = imageAttachments()
|
||||||
|
|
||||||
if (cmd.type === "custom") {
|
if (cmd.type === "custom") {
|
||||||
const text = `/${cmd.trigger} `
|
const text = `/${cmd.trigger} `
|
||||||
setEditorText(text)
|
setEditorText(text)
|
||||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length)
|
||||||
focusEditorEnd()
|
focusEditorEnd()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
clearEditor()
|
clearEditor()
|
||||||
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
|
prompt.set([...DEFAULT_PROMPT, ...images], 0)
|
||||||
command.trigger(cmd.id, "slash")
|
command.trigger(cmd.id, "slash")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1061,17 +1079,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
if (!id) return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||||
return permission.isAutoAccepting(id, sdk.directory)
|
return permission.isAutoAccepting(id, sdk.directory)
|
||||||
})
|
})
|
||||||
const acceptLabel = createMemo(() =>
|
|
||||||
language.t(accepting() ? "command.permissions.autoaccept.disable" : "command.permissions.autoaccept.enable"),
|
|
||||||
)
|
|
||||||
const toggleAccept = () => {
|
|
||||||
if (!params.id) {
|
|
||||||
permission.toggleAutoAcceptDirectory(sdk.directory)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
permission.toggleAutoAccept(params.id, sdk.directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
const { abort, handleSubmit } = createPromptSubmit({
|
const { abort, handleSubmit } = createPromptSubmit({
|
||||||
info,
|
info,
|
||||||
@@ -1315,11 +1322,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
const target = e.target
|
const target = e.target
|
||||||
if (!(target instanceof HTMLElement)) return
|
if (!(target instanceof HTMLElement)) return
|
||||||
if (
|
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
|
||||||
target.closest(
|
|
||||||
'[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]',
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
editorRef?.focus()
|
editorRef?.focus()
|
||||||
@@ -1343,6 +1346,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||||
spellcheck={store.mode === "normal"}
|
spellcheck={store.mode === "normal"}
|
||||||
|
inputMode="text"
|
||||||
|
// @ts-expect-error
|
||||||
|
autocomplete="off"
|
||||||
onInput={handleInput}
|
onInput={handleInput}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onCompositionStart={handleCompositionStart}
|
onCompositionStart={handleCompositionStart}
|
||||||
@@ -1394,17 +1400,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="flex items-center gap-1 pointer-events-auto">
|
<div class="flex items-center gap-1 pointer-events-auto">
|
||||||
<Tooltip placement="top" inactive={!prompt.dirty() && !working()} value={tip()}>
|
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
|
||||||
<IconButton
|
<IconButton
|
||||||
data-action="prompt-submit"
|
data-action="prompt-submit"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={store.mode !== "normal" || (!prompt.dirty() && !working() && commentCount() === 0)}
|
disabled={store.mode !== "normal" || (!working() && blank())}
|
||||||
tabIndex={store.mode === "normal" ? undefined : -1}
|
tabIndex={store.mode === "normal" ? undefined : -1}
|
||||||
icon={working() ? "stop" : "arrow-up"}
|
icon={stopping() ? "stop" : "arrow-up"}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
class="size-8"
|
class="size-8"
|
||||||
style={buttons()}
|
style={buttons()}
|
||||||
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -1467,7 +1473,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
size="normal"
|
size="normal"
|
||||||
options={agentNames()}
|
options={agentNames()}
|
||||||
current={local.agent.current()?.name ?? ""}
|
current={local.agent.current()?.name ?? ""}
|
||||||
onSelect={local.agent.set}
|
onSelect={(value) => {
|
||||||
|
local.agent.set(value)
|
||||||
|
restoreFocus()
|
||||||
|
}}
|
||||||
class="capitalize max-w-[160px] text-text-base"
|
class="capitalize max-w-[160px] text-text-base"
|
||||||
valueClass="truncate text-13-regular text-text-base"
|
valueClass="truncate text-13-regular text-text-base"
|
||||||
triggerStyle={control()}
|
triggerStyle={control()}
|
||||||
@@ -1476,28 +1485,62 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</div>
|
</div>
|
||||||
<div data-component="prompt-model-control">
|
<Show when={store.mode !== "shell"}>
|
||||||
<Show
|
<div data-component="prompt-model-control">
|
||||||
when={providers.paid().length > 0}
|
<Show
|
||||||
fallback={
|
when={providers.paid().length > 0}
|
||||||
|
fallback={
|
||||||
|
<TooltipKeybind
|
||||||
|
placement="top"
|
||||||
|
gutter={4}
|
||||||
|
title={language.t("command.model.choose")}
|
||||||
|
keybind={command.keybind("model.choose")}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
data-action="prompt-model"
|
||||||
|
as="div"
|
||||||
|
variant="ghost"
|
||||||
|
size="normal"
|
||||||
|
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||||
|
style={control()}
|
||||||
|
onClick={() => {
|
||||||
|
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
|
<ProviderIcon
|
||||||
|
id={local.model.current()?.provider?.id ?? ""}
|
||||||
|
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
||||||
|
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
<span class="truncate">
|
||||||
|
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||||
|
</span>
|
||||||
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
|
</Button>
|
||||||
|
</TooltipKeybind>
|
||||||
|
}
|
||||||
|
>
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
title={language.t("command.model.choose")}
|
title={language.t("command.model.choose")}
|
||||||
keybind={command.keybind("model.choose")}
|
keybind={command.keybind("model.choose")}
|
||||||
>
|
>
|
||||||
<Button
|
<ModelSelectorPopover
|
||||||
data-action="prompt-model"
|
model={local.model}
|
||||||
as="div"
|
triggerAs={Button}
|
||||||
variant="ghost"
|
triggerProps={{
|
||||||
size="normal"
|
variant: "ghost",
|
||||||
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
size: "normal",
|
||||||
style={control()}
|
style: control(),
|
||||||
onClick={() => {
|
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
||||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
"data-action": "prompt-model",
|
||||||
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
|
onClose={restoreFocus}
|
||||||
>
|
>
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
<Show when={local.model.current()?.provider?.id}>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
@@ -1510,85 +1553,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||||
</span>
|
</span>
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||||
</Button>
|
</ModelSelectorPopover>
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
}
|
</Show>
|
||||||
>
|
</div>
|
||||||
|
<div data-component="prompt-variant-control">
|
||||||
<TooltipKeybind
|
<TooltipKeybind
|
||||||
placement="top"
|
placement="top"
|
||||||
gutter={4}
|
gutter={4}
|
||||||
title={language.t("command.model.choose")}
|
title={language.t("command.model.variant.cycle")}
|
||||||
keybind={command.keybind("model.choose")}
|
keybind={command.keybind("model.variant.cycle")}
|
||||||
>
|
>
|
||||||
<ModelSelectorPopover
|
<Select
|
||||||
model={local.model}
|
size="normal"
|
||||||
triggerAs={Button}
|
options={variants()}
|
||||||
triggerProps={{
|
current={local.model.variant.current() ?? "default"}
|
||||||
variant: "ghost",
|
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
||||||
size: "normal",
|
onSelect={(value) => {
|
||||||
style: control(),
|
local.model.variant.set(value === "default" ? undefined : value)
|
||||||
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
|
restoreFocus()
|
||||||
"data-action": "prompt-model",
|
|
||||||
}}
|
}}
|
||||||
>
|
class="capitalize max-w-[160px] text-text-base"
|
||||||
<Show when={local.model.current()?.provider?.id}>
|
valueClass="truncate text-13-regular text-text-base"
|
||||||
<ProviderIcon
|
triggerStyle={control()}
|
||||||
id={local.model.current()?.provider?.id ?? ""}
|
triggerProps={{ "data-action": "prompt-model-variant" }}
|
||||||
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
|
variant="ghost"
|
||||||
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
|
/>
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<span class="truncate">
|
|
||||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
|
||||||
</span>
|
|
||||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
|
||||||
</ModelSelectorPopover>
|
|
||||||
</TooltipKeybind>
|
</TooltipKeybind>
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
<div data-component="prompt-variant-control">
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={4}
|
|
||||||
title={language.t("command.model.variant.cycle")}
|
|
||||||
keybind={command.keybind("model.variant.cycle")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
size="normal"
|
|
||||||
options={variants()}
|
|
||||||
current={local.model.variant.current() ?? "default"}
|
|
||||||
label={(x) => (x === "default" ? language.t("common.default") : x)}
|
|
||||||
onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
|
|
||||||
class="capitalize max-w-[160px] text-text-base"
|
|
||||||
valueClass="truncate text-13-regular text-text-base"
|
|
||||||
triggerStyle={control()}
|
|
||||||
triggerProps={{ "data-action": "prompt-model-variant" }}
|
|
||||||
variant="ghost"
|
|
||||||
/>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</div>
|
|
||||||
<TooltipKeybind
|
|
||||||
placement="top"
|
|
||||||
gutter={8}
|
|
||||||
title={acceptLabel()}
|
|
||||||
keybind={command.keybind("permissions.autoaccept")}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
data-action="prompt-permissions"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={toggleAccept}
|
|
||||||
classList={{
|
|
||||||
"h-7 w-7 p-0 shrink-0 flex items-center justify-center": true,
|
|
||||||
"text-text-base": !accepting(),
|
|
||||||
"hover:bg-surface-success-base": accepting(),
|
|
||||||
}}
|
|
||||||
style={control()}
|
|
||||||
aria-label={acceptLabel()}
|
|
||||||
aria-pressed={accepting()}
|
|
||||||
>
|
|
||||||
<Icon name="shield" size="small" classList={{ "text-icon-success-base": accepting() }} />
|
|
||||||
</Button>
|
|
||||||
</TooltipKeybind>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { onCleanup, onMount } from "solid-js"
|
import { onMount } from "solid-js"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
|
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -181,15 +182,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
document.addEventListener("dragover", handleGlobalDragOver)
|
makeEventListener(document, "dragover", handleGlobalDragOver)
|
||||||
document.addEventListener("dragleave", handleGlobalDragLeave)
|
makeEventListener(document, "dragleave", handleGlobalDragLeave)
|
||||||
document.addEventListener("drop", handleGlobalDrop)
|
makeEventListener(document, "drop", handleGlobalDrop)
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
document.removeEventListener("dragover", handleGlobalDragOver)
|
|
||||||
document.removeEventListener("dragleave", handleGlobalDragLeave)
|
|
||||||
document.removeEventListener("drop", handleGlobalDrop)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -100,6 +100,30 @@ describe("buildRequestParts", () => {
|
|||||||
expect(synthetic).toHaveLength(1)
|
expect(synthetic).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("adds file parts for @mentions inside comment text", () => {
|
||||||
|
const result = buildRequestParts({
|
||||||
|
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
|
||||||
|
context: [
|
||||||
|
{
|
||||||
|
key: "ctx:comment-mention",
|
||||||
|
type: "file",
|
||||||
|
path: "src/review.ts",
|
||||||
|
comment: "Compare with @src/shared.ts and @src/review.ts.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
images: [],
|
||||||
|
text: "look",
|
||||||
|
messageID: "msg_comment_mentions",
|
||||||
|
sessionID: "ses_comment_mentions",
|
||||||
|
sessionDirectory: "/repo",
|
||||||
|
})
|
||||||
|
|
||||||
|
const files = result.requestParts.filter((part) => part.type === "file")
|
||||||
|
expect(files).toHaveLength(2)
|
||||||
|
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
|
||||||
|
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
test("handles Windows paths correctly (simulated on macOS)", () => {
|
test("handles Windows paths correctly (simulated on macOS)", () => {
|
||||||
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,16 @@ const absolute = (directory: string, path: string) => {
|
|||||||
const fileQuery = (selection: FileSelection | undefined) =>
|
const fileQuery = (selection: FileSelection | undefined) =>
|
||||||
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
||||||
|
|
||||||
|
const mention = /(^|[\s([{"'])@(\S+)/g
|
||||||
|
|
||||||
|
const parseCommentMentions = (comment: string) => {
|
||||||
|
return Array.from(comment.matchAll(mention)).flatMap((match) => {
|
||||||
|
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
|
||||||
|
if (!path) return []
|
||||||
|
return [path]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||||
|
|
||||||
@@ -138,6 +148,21 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
|||||||
|
|
||||||
if (!comment) return [filePart]
|
if (!comment) return [filePart]
|
||||||
|
|
||||||
|
const mentions = parseCommentMentions(comment).flatMap((path) => {
|
||||||
|
const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
|
||||||
|
if (used.has(url)) return []
|
||||||
|
used.add(url)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
type: "file",
|
||||||
|
mime: "text/plain",
|
||||||
|
url,
|
||||||
|
filename: getFilename(path),
|
||||||
|
} satisfies PromptRequestPart,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: Identifier.ascending("part"),
|
id: Identifier.ascending("part"),
|
||||||
@@ -153,6 +178,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
|||||||
}),
|
}),
|
||||||
} satisfies PromptRequestPart,
|
} satisfies PromptRequestPart,
|
||||||
filePart,
|
filePart,
|
||||||
|
...mentions,
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Component, For, Show } from "solid-js"
|
import { Component, For, Show } from "solid-js"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import type { ImageAttachmentPart } from "@/context/prompt"
|
import type { ImageAttachmentPart } from "@/context/prompt"
|
||||||
|
|
||||||
type PromptImageAttachmentsProps = {
|
type PromptImageAttachmentsProps = {
|
||||||
@@ -22,34 +23,36 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
|||||||
<div class="flex flex-wrap gap-2 px-3 pt-3">
|
<div class="flex flex-wrap gap-2 px-3 pt-3">
|
||||||
<For each={props.attachments}>
|
<For each={props.attachments}>
|
||||||
{(attachment) => (
|
{(attachment) => (
|
||||||
<div class="relative group">
|
<Tooltip value={attachment.filename} placement="top" contentClass="break-all">
|
||||||
<Show
|
<div class="relative group">
|
||||||
when={attachment.mime.startsWith("image/")}
|
<Show
|
||||||
fallback={
|
when={attachment.mime.startsWith("image/")}
|
||||||
<div class={fallbackClass}>
|
fallback={
|
||||||
<Icon name="folder" class="size-6 text-text-weak" />
|
<div class={fallbackClass}>
|
||||||
</div>
|
<Icon name="folder" class="size-6 text-text-weak" />
|
||||||
}
|
</div>
|
||||||
>
|
}
|
||||||
<img
|
>
|
||||||
src={attachment.dataUrl}
|
<img
|
||||||
alt={attachment.filename}
|
src={attachment.dataUrl}
|
||||||
class={imageClass}
|
alt={attachment.filename}
|
||||||
onClick={() => props.onOpen(attachment)}
|
class={imageClass}
|
||||||
/>
|
onClick={() => props.onOpen(attachment)}
|
||||||
</Show>
|
/>
|
||||||
<button
|
</Show>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => props.onRemove(attachment.id)}
|
type="button"
|
||||||
class={removeClass}
|
onClick={() => props.onRemove(attachment.id)}
|
||||||
aria-label={props.removeLabel}
|
class={removeClass}
|
||||||
>
|
aria-label={props.removeLabel}
|
||||||
<Icon name="close" class="size-3 text-text-weak" />
|
>
|
||||||
</button>
|
<Icon name="close" class="size-3 text-text-weak" />
|
||||||
<div class={nameClass}>
|
</button>
|
||||||
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
<div class={nameClass}>
|
||||||
|
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ beforeAll(async () => {
|
|||||||
add: (value: {
|
add: (value: {
|
||||||
directory?: string
|
directory?: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
message: { agent: string; model: { providerID: string; modelID: string }; variant?: string }
|
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
||||||
}) => {
|
}) => {
|
||||||
optimistic.push(value)
|
optimistic.push(value)
|
||||||
optimisticSeeded.push(
|
optimisticSeeded.push(
|
||||||
@@ -310,8 +310,7 @@ describe("prompt submit worktree selection", () => {
|
|||||||
expect(optimistic[0]).toMatchObject({
|
expect(optimistic[0]).toMatchObject({
|
||||||
message: {
|
message: {
|
||||||
agent: "agent",
|
agent: "agent",
|
||||||
model: { providerID: "provider", modelID: "model" },
|
model: { providerID: "provider", modelID: "model", variant: "high" },
|
||||||
variant: "high",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { usePermission } from "@/context/permission"
|
|||||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
import { type ContextItem, type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import { promptProbe } from "@/testing/prompt"
|
||||||
import { Identifier } from "@/utils/id"
|
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"
|
||||||
@@ -120,8 +121,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
role: "user",
|
role: "user",
|
||||||
time: { created: Date.now() },
|
time: { created: Date.now() },
|
||||||
agent: input.draft.agent,
|
agent: input.draft.agent,
|
||||||
model: input.draft.model,
|
model: { ...input.draft.model, variant: input.draft.variant },
|
||||||
variant: input.draft.variant,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const add = () =>
|
const add = () =>
|
||||||
@@ -307,6 +307,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
|
|
||||||
input.addToHistory(currentPrompt, mode)
|
input.addToHistory(currentPrompt, mode)
|
||||||
input.resetHistoryNavigation()
|
input.resetHistoryNavigation()
|
||||||
|
promptProbe.start()
|
||||||
|
|
||||||
const projectDirectory = sdk.directory
|
const projectDirectory = sdk.directory
|
||||||
const isNewSession = !params.id
|
const isNewSession = !params.id
|
||||||
@@ -426,6 +427,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
promptProbe.submit({ sessionID: session.id, directory: sessionDirectory })
|
||||||
input.onSubmit?.()
|
input.onSubmit?.()
|
||||||
|
|
||||||
if (mode === "shell") {
|
if (mode === "shell") {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
import {
|
import {
|
||||||
children,
|
children,
|
||||||
createEffect,
|
createEffect,
|
||||||
createMemo,
|
createMemo,
|
||||||
createSignal,
|
createSignal,
|
||||||
type JSXElement,
|
type JSXElement,
|
||||||
onCleanup,
|
|
||||||
onMount,
|
onMount,
|
||||||
type ParentProps,
|
type ParentProps,
|
||||||
Show,
|
Show,
|
||||||
@@ -46,12 +46,9 @@ export function ServerRow(props: ServerRowProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
check()
|
|
||||||
if (typeof ResizeObserver !== "function") return
|
if (typeof ResizeObserver !== "function") return
|
||||||
const observer = new ResizeObserver(check)
|
createResizeObserver([nameRef, versionRef], check)
|
||||||
if (nameRef) observer.observe(nameRef)
|
check()
|
||||||
if (versionRef) observer.observe(versionRef)
|
|
||||||
onCleanup(() => observer.disconnect())
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const tooltipValue = () => (
|
const tooltipValue = () => (
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
|||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
import { useParams } from "@solidjs/router"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { usePermission } from "@/context/permission"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import {
|
import {
|
||||||
monoDefault,
|
monoDefault,
|
||||||
@@ -19,6 +21,7 @@ import {
|
|||||||
sansInput,
|
sansInput,
|
||||||
useSettings,
|
useSettings,
|
||||||
} from "@/context/settings"
|
} from "@/context/settings"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||||
import { Link } from "./link"
|
import { Link } from "./link"
|
||||||
import { SettingsList } from "./settings-list"
|
import { SettingsList } from "./settings-list"
|
||||||
@@ -64,7 +67,9 @@ const playDemoSound = (id: string | undefined) => {
|
|||||||
export const SettingsGeneral: Component = () => {
|
export const SettingsGeneral: Component = () => {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const permission = usePermission()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
const params = useParams()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -76,6 +81,31 @@ export const SettingsGeneral: Component = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
|
||||||
|
const dir = createMemo(() => decode64(params.dir))
|
||||||
|
const accepting = createMemo(() => {
|
||||||
|
const value = dir()
|
||||||
|
if (!value) return false
|
||||||
|
if (!params.id) return permission.isAutoAcceptingDirectory(value)
|
||||||
|
return permission.isAutoAccepting(params.id, value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleAccept = (checked: boolean) => {
|
||||||
|
const value = dir()
|
||||||
|
if (!value) return
|
||||||
|
|
||||||
|
if (!params.id) {
|
||||||
|
if (permission.isAutoAcceptingDirectory(value) === checked) return
|
||||||
|
permission.toggleAutoAcceptDirectory(value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checked) {
|
||||||
|
permission.enableAutoAccept(params.id, value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
permission.disableAutoAccept(params.id, value)
|
||||||
|
}
|
||||||
|
|
||||||
const check = () => {
|
const check = () => {
|
||||||
if (!platform.checkUpdate) return
|
if (!platform.checkUpdate) return
|
||||||
@@ -139,11 +169,6 @@ export const SettingsGeneral: Component = () => {
|
|||||||
{ value: "dark", label: language.t("theme.scheme.dark") },
|
{ value: "dark", label: language.t("theme.scheme.dark") },
|
||||||
])
|
])
|
||||||
|
|
||||||
const followupOptions = createMemo((): { value: "queue" | "steer"; label: string }[] => [
|
|
||||||
{ value: "queue", label: language.t("settings.general.row.followup.option.queue") },
|
|
||||||
{ value: "steer", label: language.t("settings.general.row.followup.option.steer") },
|
|
||||||
])
|
|
||||||
|
|
||||||
const languageOptions = createMemo(() =>
|
const languageOptions = createMemo(() =>
|
||||||
language.locales.map((locale) => ({
|
language.locales.map((locale) => ({
|
||||||
value: locale,
|
value: locale,
|
||||||
@@ -206,6 +231,15 @@ export const SettingsGeneral: Component = () => {
|
|||||||
/>
|
/>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title={language.t("command.permissions.autoaccept.enable")}
|
||||||
|
description={language.t("toast.permissions.autoaccept.on.description")}
|
||||||
|
>
|
||||||
|
<div data-action="settings-auto-accept-permissions">
|
||||||
|
<Switch checked={accepting()} disabled={!dir()} onChange={toggleAccept} />
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
title={language.t("settings.general.row.reasoningSummaries.title")}
|
title={language.t("settings.general.row.reasoningSummaries.title")}
|
||||||
description={language.t("settings.general.row.reasoningSummaries.description")}
|
description={language.t("settings.general.row.reasoningSummaries.description")}
|
||||||
@@ -241,24 +275,6 @@ export const SettingsGeneral: Component = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title={language.t("settings.general.row.followup.title")}
|
|
||||||
description={language.t("settings.general.row.followup.description")}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
data-action="settings-followup"
|
|
||||||
options={followupOptions()}
|
|
||||||
current={followupOptions().find((o) => o.value === settings.general.followup())}
|
|
||||||
value={(o) => o.value}
|
|
||||||
label={(o) => o.label}
|
|
||||||
onSelect={(option) => option && settings.general.setFollowup(option.value)}
|
|
||||||
variant="secondary"
|
|
||||||
size="small"
|
|
||||||
triggerVariant="settings"
|
|
||||||
triggerStyle={{ "min-width": "180px" }}
|
|
||||||
/>
|
|
||||||
</SettingsRow>
|
|
||||||
</SettingsList>
|
</SettingsList>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
|
import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
@@ -250,8 +251,7 @@ function useKeyCapture(input: {
|
|||||||
input.stop()
|
input.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("keydown", handle, true)
|
makeEventListener(document, "keydown", handle, { capture: true })
|
||||||
onCleanup(() => document.removeEventListener("keydown", handle, true))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
const auth = server.current?.http
|
const auth = server.current?.http
|
||||||
const username = auth?.username ?? "opencode"
|
const username = auth?.username ?? "opencode"
|
||||||
const password = auth?.password ?? ""
|
const password = auth?.password ?? ""
|
||||||
|
const sameOrigin = new URL(url, location.href).origin === location.origin
|
||||||
let container!: HTMLDivElement
|
let container!: HTMLDivElement
|
||||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
||||||
const id = local.pty.id
|
const id = local.pty.id
|
||||||
@@ -519,8 +520,12 @@ export const Terminal = (props: TerminalProps) => {
|
|||||||
next.searchParams.set("directory", directory)
|
next.searchParams.set("directory", directory)
|
||||||
next.searchParams.set("cursor", String(seek))
|
next.searchParams.set("cursor", String(seek))
|
||||||
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
||||||
next.username = username
|
if (!sameOrigin && password) {
|
||||||
next.password = password
|
next.searchParams.set("auth_token", btoa(`${username}:${password}`))
|
||||||
|
// For same-origin requests, let the browser reuse the page's existing auth.
|
||||||
|
next.username = username
|
||||||
|
next.password = password
|
||||||
|
}
|
||||||
|
|
||||||
const socket = new WebSocket(next)
|
const socket = new WebSocket(next)
|
||||||
socket.binaryType = "arraybuffer"
|
socket.binaryType = "arraybuffer"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
|||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { dict as en } from "@/i18n/en"
|
import { dict as en } from "@/i18n/en"
|
||||||
@@ -378,11 +379,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
document.addEventListener("keydown", handleKeyDown)
|
makeEventListener(document, "keydown", handleKeyDown)
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
document.removeEventListener("keydown", handleKeyDown)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function register(cb: () => CommandOption[]): void
|
function register(cb: () => CommandOption[]): void
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { batch, onCleanup } from "solid-js"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
|
import { batch, onCleanup, onMount } from "solid-js"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { createSdkForServer } from "@/utils/server"
|
import { createSdkForServer } from "@/utils/server"
|
||||||
import { useLanguage } from "./language"
|
import { useLanguage } from "./language"
|
||||||
@@ -154,7 +155,12 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
resetHeartbeat()
|
resetHeartbeat()
|
||||||
streamErrorLogged = false
|
streamErrorLogged = false
|
||||||
const directory = event.directory ?? "global"
|
const directory = event.directory ?? "global"
|
||||||
const payload = event.payload
|
if (event.payload.type === "sync") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = event.payload as Event
|
||||||
|
|
||||||
const k = key(directory, payload)
|
const k = key(directory, payload)
|
||||||
if (k) {
|
if (k) {
|
||||||
const i = coalesced.get(k)
|
const i = coalesced.get(k)
|
||||||
@@ -206,21 +212,16 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
clearHeartbeat()
|
clearHeartbeat()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onVisibility = () => {
|
onMount(() => {
|
||||||
if (typeof document === "undefined") return
|
makeEventListener(document, "visibilitychange", () => {
|
||||||
if (document.visibilityState !== "visible") return
|
if (document.visibilityState !== "visible") return
|
||||||
if (!started) return
|
if (!started) return
|
||||||
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
|
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
|
||||||
attempt?.abort()
|
attempt?.abort()
|
||||||
}
|
})
|
||||||
if (typeof document !== "undefined") {
|
})
|
||||||
document.addEventListener("visibilitychange", onVisibility)
|
|
||||||
}
|
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (typeof document !== "undefined") {
|
|
||||||
document.removeEventListener("visibilitychange", onVisibility)
|
|
||||||
}
|
|
||||||
stop()
|
stop()
|
||||||
abort.abort()
|
abort.abort()
|
||||||
flush()
|
flush()
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
input.sdk.vcs.get().then((x) => {
|
input.sdk.vcs.get().then((x) => {
|
||||||
const next = x.data ?? input.store.vcs
|
const next = x.data ?? input.store.vcs
|
||||||
input.setStore("vcs", next)
|
input.setStore("vcs", next)
|
||||||
if (next?.branch) input.vcsCache.setStore("value", next)
|
if (next) input.vcsCache.setStore("value", next)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
||||||
|
|||||||
@@ -494,8 +494,10 @@ describe("applyDirectoryEvent", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("updates vcs branch in store and cache", () => {
|
test("updates vcs branch in store and cache", () => {
|
||||||
const [store, setStore] = createStore(baseState())
|
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
|
||||||
const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
|
const [cacheStore, setCacheStore] = createStore({
|
||||||
|
value: { branch: "main", default_branch: "main" } as State["vcs"],
|
||||||
|
})
|
||||||
|
|
||||||
applyDirectoryEvent({
|
applyDirectoryEvent({
|
||||||
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
|
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
|
||||||
@@ -511,8 +513,8 @@ describe("applyDirectoryEvent", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(store.vcs).toEqual({ branch: "feature/test" })
|
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||||
expect(cacheStore.value).toEqual({ branch: "feature/test" })
|
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("routes disposal and lsp events to side-effect handlers", () => {
|
test("routes disposal and lsp events to side-effect handlers", () => {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Binary } from "@opencode-ai/util/binary"
|
import { Binary } from "@opencode-ai/util/binary"
|
||||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import type {
|
import type {
|
||||||
FileDiff,
|
|
||||||
Message,
|
Message,
|
||||||
Part,
|
Part,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
@@ -9,11 +8,13 @@ import type {
|
|||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
Session,
|
Session,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
|
SnapshotFileDiff,
|
||||||
Todo,
|
Todo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import { trimSessions } from "./session-trim"
|
import { trimSessions } from "./session-trim"
|
||||||
import { dropSessionCaches } from "./session-cache"
|
import { dropSessionCaches } from "./session-cache"
|
||||||
|
import { diffs as list, message as clean } from "@/utils/diffs"
|
||||||
|
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
|
|
||||||
@@ -161,8 +162,8 @@ export function applyDirectoryEvent(input: {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "session.diff": {
|
case "session.diff": {
|
||||||
const props = event.properties as { sessionID: string; diff: FileDiff[] }
|
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
|
||||||
input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
|
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "todo.updated": {
|
case "todo.updated": {
|
||||||
@@ -177,7 +178,7 @@ export function applyDirectoryEvent(input: {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "message.updated": {
|
case "message.updated": {
|
||||||
const info = (event.properties as { info: Message }).info
|
const info = clean((event.properties as { info: Message }).info)
|
||||||
const messages = input.store.message[info.sessionID]
|
const messages = input.store.message[info.sessionID]
|
||||||
if (!messages) {
|
if (!messages) {
|
||||||
input.setStore("message", info.sessionID, [info])
|
input.setStore("message", info.sessionID, [info])
|
||||||
@@ -271,9 +272,9 @@ export function applyDirectoryEvent(input: {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "vcs.branch.updated": {
|
case "vcs.branch.updated": {
|
||||||
const props = event.properties as { branch: string }
|
const props = event.properties as { branch?: string }
|
||||||
if (input.store.vcs?.branch === props.branch) break
|
if (input.store.vcs?.branch === props.branch) break
|
||||||
const next = { branch: props.branch }
|
const next = { ...input.store.vcs, branch: props.branch }
|
||||||
input.setStore("vcs", next)
|
input.setStore("vcs", next)
|
||||||
if (input.vcsCache) input.vcsCache.setStore("value", next)
|
if (input.vcsCache) input.vcsCache.setStore("value", next)
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type {
|
import type {
|
||||||
FileDiff,
|
|
||||||
Message,
|
Message,
|
||||||
Part,
|
Part,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
|
SnapshotFileDiff,
|
||||||
Todo,
|
Todo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||||
@@ -33,7 +33,7 @@ describe("app session cache", () => {
|
|||||||
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
||||||
const store: {
|
const store: {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, FileDiff[] | undefined>
|
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||||
todo: Record<string, Todo[] | undefined>
|
todo: Record<string, Todo[] | undefined>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
@@ -64,7 +64,7 @@ describe("app session cache", () => {
|
|||||||
const m = msg("msg_1", "ses_1")
|
const m = msg("msg_1", "ses_1")
|
||||||
const store: {
|
const store: {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, FileDiff[] | undefined>
|
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||||
todo: Record<string, Todo[] | undefined>
|
todo: Record<string, Todo[] | undefined>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
FileDiff,
|
|
||||||
Message,
|
Message,
|
||||||
Part,
|
Part,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
|
SnapshotFileDiff,
|
||||||
Todo,
|
Todo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ export const SESSION_CACHE_LIMIT = 40
|
|||||||
|
|
||||||
type SessionCache = {
|
type SessionCache = {
|
||||||
session_status: Record<string, SessionStatus | undefined>
|
session_status: Record<string, SessionStatus | undefined>
|
||||||
session_diff: Record<string, FileDiff[] | undefined>
|
session_diff: Record<string, SnapshotFileDiff[] | undefined>
|
||||||
todo: Record<string, Todo[] | undefined>
|
todo: Record<string, Todo[] | undefined>
|
||||||
message: Record<string, Message[] | undefined>
|
message: Record<string, Message[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type {
|
|||||||
Agent,
|
Agent,
|
||||||
Command,
|
Command,
|
||||||
Config,
|
Config,
|
||||||
FileDiff,
|
|
||||||
LspStatus,
|
LspStatus,
|
||||||
McpStatus,
|
McpStatus,
|
||||||
Message,
|
Message,
|
||||||
@@ -14,6 +13,7 @@ import type {
|
|||||||
QuestionRequest,
|
QuestionRequest,
|
||||||
Session,
|
Session,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
|
SnapshotFileDiff,
|
||||||
Todo,
|
Todo,
|
||||||
VcsInfo,
|
VcsInfo,
|
||||||
} from "@opencode-ai/sdk/v2/client"
|
} from "@opencode-ai/sdk/v2/client"
|
||||||
@@ -48,7 +48,7 @@ export type State = {
|
|||||||
[sessionID: string]: SessionStatus
|
[sessionID: string]: SessionStatus
|
||||||
}
|
}
|
||||||
session_diff: {
|
session_diff: {
|
||||||
[sessionID: string]: FileDiff[]
|
[sessionID: string]: SnapshotFileDiff[]
|
||||||
}
|
}
|
||||||
todo: {
|
todo: {
|
||||||
[sessionID: string]: Todo[]
|
[sessionID: string]: Todo[]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { useGlobalSync } from "./global-sync"
|
import { useGlobalSync } from "./global-sync"
|
||||||
import { useGlobalSDK } from "./global-sdk"
|
import { useGlobalSDK } from "./global-sdk"
|
||||||
import { useServer } from "./server"
|
import { useServer } from "./server"
|
||||||
@@ -366,12 +367,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
|||||||
flush()
|
flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("pagehide", flush)
|
makeEventListener(window, "pagehide", flush)
|
||||||
document.addEventListener("visibilitychange", handleVisibility)
|
makeEventListener(document, "visibilitychange", handleVisibility)
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
window.removeEventListener("pagehide", flush)
|
|
||||||
document.removeEventListener("visibilitychange", handleVisibility)
|
|
||||||
scroll.dispose()
|
scroll.dispose()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } fro
|
|||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useSync } from "./sync"
|
import { useSync } from "./sync"
|
||||||
|
|
||||||
export type ModelKey = { providerID: string; modelID: string }
|
export type ModelKey = { providerID: string; modelID: string; variant?: string }
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
agent?: string
|
agent?: string
|
||||||
@@ -373,7 +373,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
handoff.set(handoffKey(dir, session), next)
|
handoff.set(handoffKey(dir, session), next)
|
||||||
setStore("draft", undefined)
|
setStore("draft", undefined)
|
||||||
},
|
},
|
||||||
restore(msg: { sessionID: string; agent: string; model: ModelKey; variant?: string }) {
|
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
|
||||||
const session = id()
|
const session = id()
|
||||||
if (!session) return
|
if (!session) return
|
||||||
if (msg.sessionID !== session) return
|
if (msg.sessionID !== session) return
|
||||||
@@ -383,7 +383,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
setSaved("session", session, {
|
setSaved("session", session, {
|
||||||
agent: msg.agent,
|
agent: msg.agent,
|
||||||
model: msg.model,
|
model: msg.model,
|
||||||
variant: msg.variant ?? null,
|
variant: msg.model.variant ?? null,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -136,6 +136,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
|
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (store.general?.followup !== "queue") return
|
||||||
|
setStore("general", "followup", "steer")
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready,
|
ready,
|
||||||
get current() {
|
get current() {
|
||||||
@@ -150,9 +155,12 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
|||||||
setReleaseNotes(value: boolean) {
|
setReleaseNotes(value: boolean) {
|
||||||
setStore("general", "releaseNotes", value)
|
setStore("general", "releaseNotes", value)
|
||||||
},
|
},
|
||||||
followup: withFallback(() => store.general?.followup, defaultSettings.general.followup),
|
followup: withFallback(
|
||||||
|
() => (store.general?.followup === "queue" ? "steer" : store.general?.followup),
|
||||||
|
defaultSettings.general.followup,
|
||||||
|
),
|
||||||
setFollowup(value: "queue" | "steer") {
|
setFollowup(value: "queue" | "steer") {
|
||||||
setStore("general", "followup", value)
|
setStore("general", "followup", value === "queue" ? "steer" : value)
|
||||||
},
|
},
|
||||||
showReasoningSummaries: withFallback(
|
showReasoningSummaries: withFallback(
|
||||||
() => store.general?.showReasoningSummaries,
|
() => store.general?.showReasoningSummaries,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useGlobalSync } from "./global-sync"
|
|||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||||
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||||
|
import { diffs as list, message as clean } from "@/utils/diffs"
|
||||||
|
|
||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
|
|
||||||
@@ -300,7 +301,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
|
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
|
||||||
)
|
)
|
||||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||||
const session = items.map((x) => x.info).sort((a, b) => cmp(a.id, b.id))
|
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
|
||||||
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
|
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
|
||||||
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
|
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
|
||||||
return {
|
return {
|
||||||
@@ -416,8 +417,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
role: "user",
|
role: "user",
|
||||||
time: { created: Date.now() },
|
time: { created: Date.now() },
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
model: input.model,
|
model: { ...input.model, variant: input.variant },
|
||||||
variant: input.variant,
|
|
||||||
}
|
}
|
||||||
const [, setStore] = target()
|
const [, setStore] = target()
|
||||||
setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts })
|
setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts })
|
||||||
@@ -510,7 +510,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
return runInflight(inflightDiff, key, () =>
|
return runInflight(inflightDiff, key, () =>
|
||||||
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
||||||
if (!tracked(directory, sessionID)) return
|
if (!tracked(directory, sessionID)) return
|
||||||
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -238,6 +238,8 @@ export const dict = {
|
|||||||
"prompt.mode.shell": "Shell",
|
"prompt.mode.shell": "Shell",
|
||||||
"prompt.mode.normal": "Prompt",
|
"prompt.mode.normal": "Prompt",
|
||||||
"prompt.mode.shell.exit": "esc to exit",
|
"prompt.mode.shell.exit": "esc to exit",
|
||||||
|
"session.child.promptDisabled": "Subagent sessions cannot be prompted.",
|
||||||
|
"session.child.backToParent": "Back to main session.",
|
||||||
|
|
||||||
"prompt.example.1": "Fix a TODO in the codebase",
|
"prompt.example.1": "Fix a TODO in the codebase",
|
||||||
"prompt.example.2": "What is the tech stack of this project?",
|
"prompt.example.2": "What is the tech stack of this project?",
|
||||||
@@ -535,6 +537,8 @@ export const dict = {
|
|||||||
"session.review.noVcs.createGit.action": "Create Git repository",
|
"session.review.noVcs.createGit.action": "Create Git repository",
|
||||||
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
|
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
|
||||||
"session.review.noChanges": "No changes",
|
"session.review.noChanges": "No changes",
|
||||||
|
"session.review.noUncommittedChanges": "No uncommitted changes yet",
|
||||||
|
"session.review.noBranchChanges": "No branch changes yet",
|
||||||
|
|
||||||
"session.files.selectToOpen": "Select a file to open",
|
"session.files.selectToOpen": "Select a file to open",
|
||||||
"session.files.all": "All files",
|
"session.files.all": "All files",
|
||||||
|
|||||||
@@ -1,6 +1,46 @@
|
|||||||
@import "@opencode-ai/ui/styles/tailwind";
|
@import "@opencode-ai/ui/styles/tailwind";
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
|
@keyframes session-progress-whip {
|
||||||
|
0% {
|
||||||
|
clip-path: inset(0 100% 0 0 round 999px);
|
||||||
|
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
48% {
|
||||||
|
clip-path: inset(0 0 0 0 round 999px);
|
||||||
|
animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
clip-path: inset(0 0 0 100% round 999px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="session-progress"] {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 auto;
|
||||||
|
height: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 220ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="session-progress"][data-state="hiding"] {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="session-progress-bar"] {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--session-progress-color);
|
||||||
|
clip-path: inset(0 100% 0 0 round 999px);
|
||||||
|
animation: session-progress-whip var(--session-progress-ms, 1800ms) infinite;
|
||||||
|
will-change: clip-path;
|
||||||
|
}
|
||||||
|
|
||||||
[data-component="getting-started"] {
|
[data-component="getting-started"] {
|
||||||
container-type: inline-size;
|
container-type: inline-size;
|
||||||
container-name: getting-started;
|
container-name: getting-started;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
untrack,
|
untrack,
|
||||||
type Accessor,
|
type Accessor,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
import { useLayout, LocalProject } from "@/context/layout"
|
import { useLayout, LocalProject } from "@/context/layout"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
@@ -149,7 +150,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
autoselect: !initialDirectory,
|
autoselect: !initialDirectory,
|
||||||
busyWorkspaces: {} as Record<string, boolean>,
|
busyWorkspaces: {} as Record<string, boolean>,
|
||||||
hoverSession: undefined as string | undefined,
|
|
||||||
hoverProject: undefined as string | undefined,
|
hoverProject: undefined as string | undefined,
|
||||||
scrollSessionKey: undefined as string | undefined,
|
scrollSessionKey: undefined as string | undefined,
|
||||||
nav: undefined as HTMLElement | undefined,
|
nav: undefined as HTMLElement | undefined,
|
||||||
@@ -193,7 +193,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
onActivate: (directory) => {
|
onActivate: (directory) => {
|
||||||
globalSync.child(directory)
|
globalSync.child(directory)
|
||||||
setState("hoverProject", directory)
|
setState("hoverProject", directory)
|
||||||
setState("hoverSession", undefined)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -215,18 +214,11 @@ export default function Layout(props: ParentProps) {
|
|||||||
if (document.visibilityState !== "hidden") return
|
if (document.visibilityState !== "hidden") return
|
||||||
reset()
|
reset()
|
||||||
}
|
}
|
||||||
window.addEventListener("pointerup", stop)
|
makeEventListener(window, "pointerup", stop)
|
||||||
window.addEventListener("pointercancel", stop)
|
makeEventListener(window, "pointercancel", stop)
|
||||||
window.addEventListener("blur", stop)
|
makeEventListener(window, "blur", stop)
|
||||||
window.addEventListener("blur", blur)
|
makeEventListener(window, "blur", blur)
|
||||||
document.addEventListener("visibilitychange", hide)
|
makeEventListener(document, "visibilitychange", hide)
|
||||||
onCleanup(() => {
|
|
||||||
window.removeEventListener("pointerup", stop)
|
|
||||||
window.removeEventListener("pointercancel", stop)
|
|
||||||
window.removeEventListener("blur", stop)
|
|
||||||
window.removeEventListener("blur", blur)
|
|
||||||
document.removeEventListener("visibilitychange", hide)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
|
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
|
||||||
@@ -237,7 +229,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
aim.reset()
|
aim.reset()
|
||||||
}
|
}
|
||||||
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
|
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
|
||||||
const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
|
|
||||||
|
|
||||||
const disarm = () => {
|
const disarm = () => {
|
||||||
if (navLeave.current === undefined) return
|
if (navLeave.current === undefined) return
|
||||||
@@ -247,7 +238,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
|
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
disarm()
|
disarm()
|
||||||
setState("hoverSession", undefined)
|
|
||||||
setHoverProject(undefined)
|
setHoverProject(undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +248,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
navLeave.current = window.setTimeout(() => {
|
navLeave.current = window.setTimeout(() => {
|
||||||
navLeave.current = undefined
|
navLeave.current = undefined
|
||||||
setHoverProject(undefined)
|
setHoverProject(undefined)
|
||||||
setState("hoverSession", undefined)
|
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1394,8 +1383,7 @@ export default function Layout(props: ParentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleDeepLinks(drainPendingDeepLinks(window))
|
handleDeepLinks(drainPendingDeepLinks(window))
|
||||||
window.addEventListener(deepLinkEvent, handler as EventListener)
|
makeEventListener(window, deepLinkEvent, handler as EventListener)
|
||||||
onCleanup(() => window.removeEventListener(deepLinkEvent, handler as EventListener))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function renameProject(project: LocalProject, next: string) {
|
async function renameProject(project: LocalProject, next: string) {
|
||||||
@@ -1979,9 +1967,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
navList: currentSessions,
|
navList: currentSessions,
|
||||||
sidebarExpanded,
|
sidebarExpanded,
|
||||||
sidebarHovering,
|
sidebarHovering,
|
||||||
nav: () => state.nav,
|
|
||||||
hoverSession: () => state.hoverSession,
|
|
||||||
setHoverSession,
|
|
||||||
clearHoverProjectSoon,
|
clearHoverProjectSoon,
|
||||||
prefetchSession,
|
prefetchSession,
|
||||||
archiveSession,
|
archiveSession,
|
||||||
@@ -2010,7 +1995,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
sidebarOpened: () => layout.sidebar.opened(),
|
sidebarOpened: () => layout.sidebar.opened(),
|
||||||
sidebarHovering,
|
sidebarHovering,
|
||||||
hoverProject: () => state.hoverProject,
|
hoverProject: () => state.hoverProject,
|
||||||
nav: () => state.nav,
|
|
||||||
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
|
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
|
||||||
onProjectMouseLeave: (worktree) => aim.leave(worktree),
|
onProjectMouseLeave: (worktree) => aim.leave(worktree),
|
||||||
onProjectFocus: (worktree) => aim.activate(worktree),
|
onProjectFocus: (worktree) => aim.activate(worktree),
|
||||||
@@ -2029,15 +2013,10 @@ export default function Layout(props: ParentProps) {
|
|||||||
sessionProps: {
|
sessionProps: {
|
||||||
navList: currentSessions,
|
navList: currentSessions,
|
||||||
sidebarExpanded,
|
sidebarExpanded,
|
||||||
sidebarHovering,
|
|
||||||
nav: () => state.nav,
|
|
||||||
hoverSession: () => state.hoverSession,
|
|
||||||
setHoverSession,
|
|
||||||
clearHoverProjectSoon,
|
clearHoverProjectSoon,
|
||||||
prefetchSession,
|
prefetchSession,
|
||||||
archiveSession,
|
archiveSession,
|
||||||
},
|
},
|
||||||
setHoverSession,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarPanel = (panelProps: {
|
const SidebarPanel = (panelProps: {
|
||||||
@@ -2048,7 +2027,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
const project = panelProps.project
|
const project = panelProps.project
|
||||||
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
|
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
|
||||||
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
|
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
|
||||||
const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
|
|
||||||
const empty = createMemo(() => !params.dir && layout.projects.list().length === 0)
|
const empty = createMemo(() => !params.dir && layout.projects.list().length === 0)
|
||||||
const projectName = createMemo(() => {
|
const projectName = createMemo(() => {
|
||||||
const item = project()
|
const item = project()
|
||||||
@@ -2250,7 +2228,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
project={project()!}
|
project={project()!}
|
||||||
sortNow={sortNow}
|
sortNow={sortNow}
|
||||||
mobile={panelProps.mobile}
|
mobile={panelProps.mobile}
|
||||||
popover={popover()}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -2295,7 +2272,6 @@ export default function Layout(props: ParentProps) {
|
|||||||
project={project()!}
|
project={project()!}
|
||||||
sortNow={sortNow}
|
sortNow={sortNow}
|
||||||
mobile={panelProps.mobile}
|
mobile={panelProps.mobile}
|
||||||
popover={popover()}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "./deep-links"
|
} from "./deep-links"
|
||||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import {
|
import {
|
||||||
|
childSessionOnPath,
|
||||||
displayName,
|
displayName,
|
||||||
effectiveWorkspaceOrder,
|
effectiveWorkspaceOrder,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
@@ -198,6 +199,19 @@ describe("layout workspace helpers", () => {
|
|||||||
expect(result?.id).toBe("root")
|
expect(result?.id).toBe("root")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("finds the direct child on the active session path", () => {
|
||||||
|
const list = [
|
||||||
|
session({ id: "root", directory: "/workspace" }),
|
||||||
|
session({ id: "child", directory: "/workspace", parentID: "root" }),
|
||||||
|
session({ id: "leaf", directory: "/workspace", parentID: "child" }),
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(childSessionOnPath(list, "root", "leaf")?.id).toBe("child")
|
||||||
|
expect(childSessionOnPath(list, "child", "leaf")?.id).toBe("leaf")
|
||||||
|
expect(childSessionOnPath(list, "root", "root")).toBeUndefined()
|
||||||
|
expect(childSessionOnPath(list, "root", "other")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
test("formats fallback project display name", () => {
|
test("formats fallback project display name", () => {
|
||||||
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
|
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
|
||||||
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
|
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
|
||||||
|
|||||||
@@ -46,18 +46,17 @@ export function hasProjectPermissions<T>(
|
|||||||
return Object.values(request ?? {}).some((list) => list?.some(include))
|
return Object.values(request ?? {}).some((list) => list?.some(include))
|
||||||
}
|
}
|
||||||
|
|
||||||
export const childMapByParent = (sessions: Session[] | undefined) => {
|
export const childSessionOnPath = (sessions: Session[] | undefined, rootID: string, activeID?: string) => {
|
||||||
const map = new Map<string, string[]>()
|
if (!activeID || activeID === rootID) return
|
||||||
for (const session of sessions ?? []) {
|
const map = new Map((sessions ?? []).map((session) => [session.id, session]))
|
||||||
if (!session.parentID) continue
|
let id = activeID
|
||||||
const existing = map.get(session.parentID)
|
|
||||||
if (existing) {
|
while (id) {
|
||||||
existing.push(session.id)
|
const session = map.get(id)
|
||||||
continue
|
if (!session?.parentID) return
|
||||||
}
|
if (session.parentID === rootID) return session
|
||||||
map.set(session.parentID, [session.id])
|
id = session.parentID
|
||||||
}
|
}
|
||||||
return map
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const displayName = (project: { name?: string; worktree: string }) =>
|
export const displayName = (project: { name?: string; worktree: string }) =>
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
import type { Message, Session, TextPart, UserMessage } from "@opencode-ai/sdk/v2/client"
|
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { Avatar } from "@opencode-ai/ui/avatar"
|
import { Avatar } from "@opencode-ai/ui/avatar"
|
||||||
import { HoverCard } from "@opencode-ai/ui/hover-card"
|
|
||||||
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 { MessageNav } from "@opencode-ai/ui/message-nav"
|
|
||||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
import { A, useNavigate, useParams } from "@solidjs/router"
|
import { A, useParams } from "@solidjs/router"
|
||||||
import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
|
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
|
||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
|
||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { messageAgentColor } from "@/utils/agent"
|
import { messageAgentColor } from "@/utils/agent"
|
||||||
|
import { sessionTitle } from "@/utils/session-title"
|
||||||
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
|
||||||
import { hasProjectPermissions } from "./helpers"
|
import { childSessionOnPath, hasProjectPermissions } from "./helpers"
|
||||||
|
|
||||||
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
|
||||||
|
|
||||||
@@ -38,6 +36,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
|
|||||||
)
|
)
|
||||||
const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0))
|
const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0))
|
||||||
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
|
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
|
||||||
<div class="size-full rounded overflow-clip">
|
<div class="size-full rounded overflow-clip">
|
||||||
@@ -72,13 +71,10 @@ export type SessionItemProps = {
|
|||||||
slug: string
|
slug: string
|
||||||
mobile?: boolean
|
mobile?: boolean
|
||||||
dense?: boolean
|
dense?: boolean
|
||||||
popover?: boolean
|
showTooltip?: boolean
|
||||||
children: Map<string, string[]>
|
showChild?: boolean
|
||||||
|
level?: number
|
||||||
sidebarExpanded: Accessor<boolean>
|
sidebarExpanded: Accessor<boolean>
|
||||||
sidebarHovering: Accessor<boolean>
|
|
||||||
nav: Accessor<HTMLElement | undefined>
|
|
||||||
hoverSession: Accessor<string | undefined>
|
|
||||||
setHoverSession: (id: string | undefined) => void
|
|
||||||
clearHoverProjectSoon: () => void
|
clearHoverProjectSoon: () => void
|
||||||
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
||||||
archiveSession: (session: Session) => Promise<void>
|
archiveSession: (session: Session) => Promise<void>
|
||||||
@@ -94,112 +90,52 @@ const SessionRow = (props: {
|
|||||||
hasPermissions: Accessor<boolean>
|
hasPermissions: Accessor<boolean>
|
||||||
hasError: Accessor<boolean>
|
hasError: Accessor<boolean>
|
||||||
unseenCount: Accessor<number>
|
unseenCount: Accessor<number>
|
||||||
setHoverSession: (id: string | undefined) => void
|
|
||||||
clearHoverProjectSoon: () => void
|
clearHoverProjectSoon: () => void
|
||||||
sidebarOpened: Accessor<boolean>
|
sidebarOpened: Accessor<boolean>
|
||||||
warmHover: () => void
|
|
||||||
warmPress: () => void
|
warmPress: () => void
|
||||||
warmFocus: () => void
|
warmFocus: () => void
|
||||||
cancelHoverPrefetch: () => void
|
|
||||||
}): JSX.Element => (
|
|
||||||
<A
|
|
||||||
href={`/${props.slug}/session/${props.session.id}`}
|
|
||||||
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
|
||||||
onPointerDown={props.warmPress}
|
|
||||||
onPointerEnter={props.warmHover}
|
|
||||||
onPointerLeave={props.cancelHoverPrefetch}
|
|
||||||
onFocus={props.warmFocus}
|
|
||||||
onClick={() => {
|
|
||||||
props.setHoverSession(undefined)
|
|
||||||
if (props.sidebarOpened()) return
|
|
||||||
props.clearHoverProjectSoon()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="shrink-0 size-6 flex items-center justify-center"
|
|
||||||
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
|
|
||||||
>
|
|
||||||
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
|
|
||||||
<Match when={props.isWorking()}>
|
|
||||||
<Spinner class="size-[15px]" />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.hasPermissions()}>
|
|
||||||
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.hasError()}>
|
|
||||||
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
|
||||||
</Match>
|
|
||||||
<Match when={props.unseenCount() > 0}>
|
|
||||||
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
|
|
||||||
</A>
|
|
||||||
)
|
|
||||||
|
|
||||||
const SessionHoverPreview = (props: {
|
|
||||||
mobile?: boolean
|
|
||||||
nav: Accessor<HTMLElement | undefined>
|
|
||||||
hoverSession: Accessor<string | undefined>
|
|
||||||
session: Session
|
|
||||||
sidebarHovering: Accessor<boolean>
|
|
||||||
hoverReady: Accessor<boolean>
|
|
||||||
hoverMessages: Accessor<UserMessage[] | undefined>
|
|
||||||
language: ReturnType<typeof useLanguage>
|
|
||||||
isActive: Accessor<boolean>
|
|
||||||
slug: string
|
|
||||||
setHoverSession: (id: string | undefined) => void
|
|
||||||
messageLabel: (message: Message) => string | undefined
|
|
||||||
onMessageSelect: (message: Message) => void
|
|
||||||
trigger: JSX.Element
|
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
let ref: HTMLDivElement | undefined
|
const title = () => sessionTitle(props.session.title)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HoverCard
|
<A
|
||||||
openDelay={1000}
|
href={`/${props.slug}/session/${props.session.id}`}
|
||||||
closeDelay={props.sidebarHovering() ? 600 : 0}
|
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||||
placement="right-start"
|
onPointerDown={props.warmPress}
|
||||||
gutter={16}
|
onFocus={props.warmFocus}
|
||||||
shift={-2}
|
onClick={() => {
|
||||||
trigger={
|
if (props.sidebarOpened()) return
|
||||||
<div ref={ref} class="min-w-0 w-full">
|
props.clearHoverProjectSoon()
|
||||||
{props.trigger}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
open={props.hoverSession() === props.session.id}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) {
|
|
||||||
props.setHoverSession(undefined)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!ref?.matches(":hover")) return
|
|
||||||
props.setHoverSession(props.session.id)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show
|
<Show when={props.isWorking() || props.hasPermissions() || props.hasError() || props.unseenCount() > 0}>
|
||||||
when={props.hoverReady()}
|
<div
|
||||||
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
|
class="shrink-0 size-6 flex items-center justify-center"
|
||||||
>
|
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
|
||||||
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
|
>
|
||||||
<MessageNav
|
<Switch>
|
||||||
messages={props.hoverMessages() ?? []}
|
<Match when={props.isWorking()}>
|
||||||
current={undefined}
|
<Spinner class="size-[15px]" />
|
||||||
getLabel={props.messageLabel}
|
</Match>
|
||||||
onMessageSelect={props.onMessageSelect}
|
<Match when={props.hasPermissions()}>
|
||||||
size="normal"
|
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
|
||||||
class="w-60"
|
</Match>
|
||||||
/>
|
<Match when={props.hasError()}>
|
||||||
|
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
|
||||||
|
</Match>
|
||||||
|
<Match when={props.unseenCount() > 0}>
|
||||||
|
<div class="size-1.5 rounded-full bg-text-interactive-base" />
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</HoverCard>
|
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{title()}</span>
|
||||||
|
</A>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const navigate = useNavigate()
|
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const notification = useNotification()
|
const notification = useNotification()
|
||||||
@@ -229,18 +165,13 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const tint = createMemo(() => {
|
const tint = createMemo(() => messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent))
|
||||||
return messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)
|
const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded()))
|
||||||
|
const currentChild = createMemo(() => {
|
||||||
|
if (!props.showChild) return
|
||||||
|
return childSessionOnPath(sessionStore.session, props.session.id, params.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
const hoverMessages = createMemo(() =>
|
|
||||||
sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"),
|
|
||||||
)
|
|
||||||
const hoverReady = createMemo(() => hoverMessages() !== undefined)
|
|
||||||
const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
|
|
||||||
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
|
|
||||||
const isActive = createMemo(() => props.session.id === params.id)
|
|
||||||
|
|
||||||
const warm = (span: number, priority: "high" | "low") => {
|
const warm = (span: number, priority: "high" | "low") => {
|
||||||
const nav = props.navList?.()
|
const nav = props.navList?.()
|
||||||
const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory)
|
const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory)
|
||||||
@@ -261,30 +192,6 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hoverPrefetch = {
|
|
||||||
current: undefined as ReturnType<typeof setTimeout> | undefined,
|
|
||||||
}
|
|
||||||
const cancelHoverPrefetch = () => {
|
|
||||||
if (hoverPrefetch.current === undefined) return
|
|
||||||
clearTimeout(hoverPrefetch.current)
|
|
||||||
hoverPrefetch.current = undefined
|
|
||||||
}
|
|
||||||
const scheduleHoverPrefetch = () => {
|
|
||||||
warm(1, "high")
|
|
||||||
if (hoverPrefetch.current !== undefined) return
|
|
||||||
hoverPrefetch.current = setTimeout(() => {
|
|
||||||
hoverPrefetch.current = undefined
|
|
||||||
warm(2, "low")
|
|
||||||
}, 80)
|
|
||||||
}
|
|
||||||
|
|
||||||
onCleanup(cancelHoverPrefetch)
|
|
||||||
|
|
||||||
const messageLabel = (message: Message) => {
|
|
||||||
const parts = sessionStore.part[message.id] ?? []
|
|
||||||
const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
|
|
||||||
return text?.text
|
|
||||||
}
|
|
||||||
const item = (
|
const item = (
|
||||||
<SessionRow
|
<SessionRow
|
||||||
session={props.session}
|
session={props.session}
|
||||||
@@ -296,86 +203,74 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
|||||||
hasPermissions={hasPermissions}
|
hasPermissions={hasPermissions}
|
||||||
hasError={hasError}
|
hasError={hasError}
|
||||||
unseenCount={unseenCount}
|
unseenCount={unseenCount}
|
||||||
setHoverSession={props.setHoverSession}
|
|
||||||
clearHoverProjectSoon={props.clearHoverProjectSoon}
|
clearHoverProjectSoon={props.clearHoverProjectSoon}
|
||||||
sidebarOpened={layout.sidebar.opened}
|
sidebarOpened={layout.sidebar.opened}
|
||||||
warmHover={scheduleHoverPrefetch}
|
|
||||||
warmPress={() => warm(2, "high")}
|
warmPress={() => warm(2, "high")}
|
||||||
warmFocus={() => warm(2, "high")}
|
warmFocus={() => warm(2, "high")}
|
||||||
cancelHoverPrefetch={cancelHoverPrefetch}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
data-session-id={props.session.id}
|
<div
|
||||||
class="group/session relative w-full min-w-0 rounded-md cursor-default pl-2 pr-3 transition-colors
|
data-session-id={props.session.id}
|
||||||
hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
|
class="group/session relative w-full min-w-0 rounded-md cursor-default pr-3 transition-colors hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
|
||||||
>
|
style={{ "padding-left": `${8 + (props.level ?? 0) * 16}px` }}
|
||||||
<div class="flex min-w-0 items-center gap-1">
|
>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="flex min-w-0 items-center gap-1">
|
||||||
<Show
|
<div class="min-w-0 flex-1">
|
||||||
when={hoverEnabled()}
|
<Show
|
||||||
fallback={
|
when={!tooltip()}
|
||||||
<Tooltip
|
fallback={
|
||||||
placement={props.mobile ? "bottom" : "right"}
|
<Tooltip
|
||||||
value={props.session.title}
|
placement={props.mobile ? "bottom" : "right"}
|
||||||
gutter={10}
|
value={sessionTitle(props.session.title)}
|
||||||
class="min-w-0 w-full"
|
gutter={10}
|
||||||
>
|
class="min-w-0 w-full"
|
||||||
{item}
|
>
|
||||||
</Tooltip>
|
{item}
|
||||||
}
|
</Tooltip>
|
||||||
>
|
}
|
||||||
<SessionHoverPreview
|
>
|
||||||
mobile={props.mobile}
|
{item}
|
||||||
nav={props.nav}
|
</Show>
|
||||||
hoverSession={props.hoverSession}
|
</div>
|
||||||
session={props.session}
|
|
||||||
sidebarHovering={props.sidebarHovering}
|
|
||||||
hoverReady={hoverReady}
|
|
||||||
hoverMessages={hoverMessages}
|
|
||||||
language={language}
|
|
||||||
isActive={isActive}
|
|
||||||
slug={props.slug}
|
|
||||||
setHoverSession={props.setHoverSession}
|
|
||||||
messageLabel={messageLabel}
|
|
||||||
onMessageSelect={(message) => {
|
|
||||||
if (!isActive())
|
|
||||||
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
|
|
||||||
|
|
||||||
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
|
<Show when={!props.level}>
|
||||||
|
<div
|
||||||
|
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
||||||
|
classList={{
|
||||||
|
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
|
||||||
|
"w-0 opacity-0 pointer-events-none": !props.mobile,
|
||||||
|
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
|
||||||
|
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
|
||||||
}}
|
}}
|
||||||
trigger={item}
|
>
|
||||||
/>
|
<Tooltip value={language.t("common.archive")} placement="top">
|
||||||
|
<IconButton
|
||||||
|
icon="archive"
|
||||||
|
variant="ghost"
|
||||||
|
class="size-6 rounded-md"
|
||||||
|
aria-label={language.t("common.archive")}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
void props.archiveSession(props.session)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
class="shrink-0 overflow-hidden transition-[width,opacity]"
|
|
||||||
classList={{
|
|
||||||
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
|
|
||||||
"w-0 opacity-0 pointer-events-none": !props.mobile,
|
|
||||||
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
|
|
||||||
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Tooltip value={language.t("common.archive")} placement="top">
|
|
||||||
<IconButton
|
|
||||||
icon="archive"
|
|
||||||
variant="ghost"
|
|
||||||
class="size-6 rounded-md"
|
|
||||||
aria-label={language.t("common.archive")}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
void props.archiveSession(props.session)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<Show when={currentChild()}>
|
||||||
|
{(child) => (
|
||||||
|
<div class="w-full">
|
||||||
|
<SessionItem {...props} session={child()} level={(props.level ?? 0) + 1} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,7 +280,6 @@ export const NewSessionItem = (props: {
|
|||||||
dense?: boolean
|
dense?: boolean
|
||||||
sidebarExpanded: Accessor<boolean>
|
sidebarExpanded: Accessor<boolean>
|
||||||
clearHoverProjectSoon: () => void
|
clearHoverProjectSoon: () => void
|
||||||
setHoverSession: (id: string | undefined) => void
|
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -395,9 +289,8 @@ export const NewSessionItem = (props: {
|
|||||||
<A
|
<A
|
||||||
href={`/${props.slug}/session`}
|
href={`/${props.slug}/session`}
|
||||||
end
|
end
|
||||||
class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
props.setHoverSession(undefined)
|
|
||||||
if (layout.sidebar.opened()) return
|
if (layout.sidebar.opened()) return
|
||||||
props.clearHoverProjectSoon()
|
props.clearHoverProjectSoon()
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { base64Encode } from "@opencode-ai/util/encode"
|
import { base64Encode } from "@opencode-ai/util/encode"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
@@ -11,7 +11,7 @@ import { useGlobalSync } from "@/context/global-sync"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useNotification } from "@/context/notification"
|
import { useNotification } from "@/context/notification"
|
||||||
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
|
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
|
||||||
import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
|
import { displayName, sortedRootSessions } from "./helpers"
|
||||||
|
|
||||||
export type ProjectSidebarContext = {
|
export type ProjectSidebarContext = {
|
||||||
currentDir: Accessor<string>
|
currentDir: Accessor<string>
|
||||||
@@ -19,7 +19,6 @@ export type ProjectSidebarContext = {
|
|||||||
sidebarOpened: Accessor<boolean>
|
sidebarOpened: Accessor<boolean>
|
||||||
sidebarHovering: Accessor<boolean>
|
sidebarHovering: Accessor<boolean>
|
||||||
hoverProject: Accessor<string | undefined>
|
hoverProject: Accessor<string | undefined>
|
||||||
nav: Accessor<HTMLElement | undefined>
|
|
||||||
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
|
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
|
||||||
onProjectMouseLeave: (worktree: string) => void
|
onProjectMouseLeave: (worktree: string) => void
|
||||||
onProjectFocus: (worktree: string) => void
|
onProjectFocus: (worktree: string) => void
|
||||||
@@ -32,8 +31,7 @@ export type ProjectSidebarContext = {
|
|||||||
workspacesEnabled: (project: LocalProject) => boolean
|
workspacesEnabled: (project: LocalProject) => boolean
|
||||||
workspaceIds: (project: LocalProject) => string[]
|
workspaceIds: (project: LocalProject) => string[]
|
||||||
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
|
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
|
||||||
sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "children" | "mobile" | "dense" | "popover">
|
sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "mobile" | "dense">
|
||||||
setHoverSession: (id: string | undefined) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProjectDragOverlay = (props: {
|
export const ProjectDragOverlay = (props: {
|
||||||
@@ -55,7 +53,6 @@ export const ProjectDragOverlay = (props: {
|
|||||||
const ProjectTile = (props: {
|
const ProjectTile = (props: {
|
||||||
project: LocalProject
|
project: LocalProject
|
||||||
mobile?: boolean
|
mobile?: boolean
|
||||||
nav: Accessor<HTMLElement | undefined>
|
|
||||||
sidebarHovering: Accessor<boolean>
|
sidebarHovering: Accessor<boolean>
|
||||||
selected: Accessor<boolean>
|
selected: Accessor<boolean>
|
||||||
active: Accessor<boolean>
|
active: Accessor<boolean>
|
||||||
@@ -195,9 +192,7 @@ const ProjectPreviewPanel = (props: {
|
|||||||
workspaces: Accessor<string[]>
|
workspaces: Accessor<string[]>
|
||||||
label: (directory: string) => string
|
label: (directory: string) => string
|
||||||
projectSessions: Accessor<ReturnType<typeof sortedRootSessions>>
|
projectSessions: Accessor<ReturnType<typeof sortedRootSessions>>
|
||||||
projectChildren: Accessor<Map<string, string[]>>
|
|
||||||
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
|
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
|
||||||
workspaceChildren: (directory: string) => Map<string, string[]>
|
|
||||||
ctx: ProjectSidebarContext
|
ctx: ProjectSidebarContext
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
}): JSX.Element => (
|
}): JSX.Element => (
|
||||||
@@ -218,9 +213,8 @@ const ProjectPreviewPanel = (props: {
|
|||||||
list={props.projectSessions()}
|
list={props.projectSessions()}
|
||||||
slug={base64Encode(props.project.worktree)}
|
slug={base64Encode(props.project.worktree)}
|
||||||
dense
|
dense
|
||||||
|
showTooltip
|
||||||
mobile={props.mobile}
|
mobile={props.mobile}
|
||||||
popover={false}
|
|
||||||
children={props.projectChildren()}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -229,7 +223,6 @@ const ProjectPreviewPanel = (props: {
|
|||||||
<For each={props.workspaces()}>
|
<For each={props.workspaces()}>
|
||||||
{(directory) => {
|
{(directory) => {
|
||||||
const sessions = createMemo(() => props.workspaceSessions(directory))
|
const sessions = createMemo(() => props.workspaceSessions(directory))
|
||||||
const children = createMemo(() => props.workspaceChildren(directory))
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
|
<div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
|
||||||
@@ -246,9 +239,8 @@ const ProjectPreviewPanel = (props: {
|
|||||||
list={sessions()}
|
list={sessions()}
|
||||||
slug={base64Encode(directory)}
|
slug={base64Encode(directory)}
|
||||||
dense
|
dense
|
||||||
|
showTooltip
|
||||||
mobile={props.mobile}
|
mobile={props.mobile}
|
||||||
popover={false}
|
|
||||||
children={children()}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
@@ -310,20 +302,14 @@ export const SortableProject = (props: {
|
|||||||
|
|
||||||
const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
|
const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
|
||||||
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
|
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
|
||||||
const projectChildren = createMemo(() => childMapByParent(projectStore().session))
|
|
||||||
const workspaceSessions = (directory: string) => {
|
const workspaceSessions = (directory: string) => {
|
||||||
const [data] = globalSync.child(directory, { bootstrap: false })
|
const [data] = globalSync.child(directory, { bootstrap: false })
|
||||||
return sortedRootSessions(data, props.sortNow())
|
return sortedRootSessions(data, props.sortNow())
|
||||||
}
|
}
|
||||||
const workspaceChildren = (directory: string) => {
|
|
||||||
const [data] = globalSync.child(directory, { bootstrap: false })
|
|
||||||
return childMapByParent(data.session)
|
|
||||||
}
|
|
||||||
const tile = () => (
|
const tile = () => (
|
||||||
<ProjectTile
|
<ProjectTile
|
||||||
project={props.project}
|
project={props.project}
|
||||||
mobile={props.mobile}
|
mobile={props.mobile}
|
||||||
nav={props.ctx.nav}
|
|
||||||
sidebarHovering={props.ctx.sidebarHovering}
|
sidebarHovering={props.ctx.sidebarHovering}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
active={active}
|
active={active}
|
||||||
@@ -360,7 +346,6 @@ export const SortableProject = (props: {
|
|||||||
if (state.menu) return
|
if (state.menu) return
|
||||||
if (value && state.suppressHover) return
|
if (value && state.suppressHover) return
|
||||||
props.ctx.onHoverOpenChanged(props.project.worktree, value)
|
props.ctx.onHoverOpenChanged(props.project.worktree, value)
|
||||||
if (value) props.ctx.setHoverSession(undefined)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ProjectPreviewPanel
|
<ProjectPreviewPanel
|
||||||
@@ -371,9 +356,7 @@ export const SortableProject = (props: {
|
|||||||
workspaces={workspaces}
|
workspaces={workspaces}
|
||||||
label={label}
|
label={label}
|
||||||
projectSessions={projectSessions}
|
projectSessions={projectSessions}
|
||||||
projectChildren={projectChildren}
|
|
||||||
workspaceSessions={workspaceSessions}
|
workspaceSessions={workspaceSessions}
|
||||||
workspaceChildren={workspaceChildren}
|
|
||||||
ctx={props.ctx}
|
ctx={props.ctx}
|
||||||
language={language}
|
language={language}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout"
|
|||||||
import { useGlobalSync } from "@/context/global-sync"
|
import { useGlobalSync } from "@/context/global-sync"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
|
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
|
||||||
import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers"
|
import { sortedRootSessions, workspaceKey } from "./helpers"
|
||||||
|
|
||||||
type InlineEditorComponent = (props: {
|
type InlineEditorComponent = (props: {
|
||||||
id: string
|
id: string
|
||||||
@@ -35,9 +35,6 @@ export type WorkspaceSidebarContext = {
|
|||||||
navList: Accessor<Session[]>
|
navList: Accessor<Session[]>
|
||||||
sidebarExpanded: Accessor<boolean>
|
sidebarExpanded: Accessor<boolean>
|
||||||
sidebarHovering: Accessor<boolean>
|
sidebarHovering: Accessor<boolean>
|
||||||
nav: Accessor<HTMLElement | undefined>
|
|
||||||
hoverSession: Accessor<string | undefined>
|
|
||||||
setHoverSession: (id: string | undefined) => void
|
|
||||||
clearHoverProjectSoon: () => void
|
clearHoverProjectSoon: () => void
|
||||||
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
prefetchSession: (session: Session, priority?: "high" | "low") => void
|
||||||
archiveSession: (session: Session) => Promise<void>
|
archiveSession: (session: Session) => Promise<void>
|
||||||
@@ -152,7 +149,6 @@ const WorkspaceActions = (props: {
|
|||||||
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
|
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
|
||||||
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
|
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
|
||||||
root: string
|
root: string
|
||||||
setHoverSession: WorkspaceSidebarContext["setHoverSession"]
|
|
||||||
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
|
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
|
||||||
navigateToNewSession: () => void
|
navigateToNewSession: () => void
|
||||||
}): JSX.Element => (
|
}): JSX.Element => (
|
||||||
@@ -226,7 +222,6 @@ const WorkspaceActions = (props: {
|
|||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
props.setHoverSession(undefined)
|
|
||||||
props.clearHoverProjectSoon()
|
props.clearHoverProjectSoon()
|
||||||
props.navigateToNewSession()
|
props.navigateToNewSession()
|
||||||
}}
|
}}
|
||||||
@@ -239,12 +234,10 @@ const WorkspaceActions = (props: {
|
|||||||
const WorkspaceSessionList = (props: {
|
const WorkspaceSessionList = (props: {
|
||||||
slug: Accessor<string>
|
slug: Accessor<string>
|
||||||
mobile?: boolean
|
mobile?: boolean
|
||||||
popover?: boolean
|
|
||||||
ctx: WorkspaceSidebarContext
|
ctx: WorkspaceSidebarContext
|
||||||
showNew: Accessor<boolean>
|
showNew: Accessor<boolean>
|
||||||
loading: Accessor<boolean>
|
loading: Accessor<boolean>
|
||||||
sessions: Accessor<Session[]>
|
sessions: Accessor<Session[]>
|
||||||
children: Accessor<Map<string, string[]>>
|
|
||||||
hasMore: Accessor<boolean>
|
hasMore: Accessor<boolean>
|
||||||
loadMore: () => Promise<void>
|
loadMore: () => Promise<void>
|
||||||
language: ReturnType<typeof useLanguage>
|
language: ReturnType<typeof useLanguage>
|
||||||
@@ -256,7 +249,6 @@ const WorkspaceSessionList = (props: {
|
|||||||
mobile={props.mobile}
|
mobile={props.mobile}
|
||||||
sidebarExpanded={props.ctx.sidebarExpanded}
|
sidebarExpanded={props.ctx.sidebarExpanded}
|
||||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||||
setHoverSession={props.ctx.setHoverSession}
|
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={props.loading()}>
|
<Show when={props.loading()}>
|
||||||
@@ -270,13 +262,8 @@ const WorkspaceSessionList = (props: {
|
|||||||
navList={props.ctx.navList}
|
navList={props.ctx.navList}
|
||||||
slug={props.slug()}
|
slug={props.slug()}
|
||||||
mobile={props.mobile}
|
mobile={props.mobile}
|
||||||
popover={props.popover}
|
showChild
|
||||||
children={props.children()}
|
|
||||||
sidebarExpanded={props.ctx.sidebarExpanded}
|
sidebarExpanded={props.ctx.sidebarExpanded}
|
||||||
sidebarHovering={props.ctx.sidebarHovering}
|
|
||||||
nav={props.ctx.nav}
|
|
||||||
hoverSession={props.ctx.hoverSession}
|
|
||||||
setHoverSession={props.ctx.setHoverSession}
|
|
||||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||||
prefetchSession={props.ctx.prefetchSession}
|
prefetchSession={props.ctx.prefetchSession}
|
||||||
archiveSession={props.ctx.archiveSession}
|
archiveSession={props.ctx.archiveSession}
|
||||||
@@ -287,7 +274,7 @@ const WorkspaceSessionList = (props: {
|
|||||||
<div class="relative w-full py-1">
|
<div class="relative w-full py-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
|
class="flex w-full text-left justify-start text-14-regular text-text-weak pl-2 pr-10"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={(e: MouseEvent) => {
|
onClick={(e: MouseEvent) => {
|
||||||
props.loadMore()
|
props.loadMore()
|
||||||
@@ -307,7 +294,6 @@ export const SortableWorkspace = (props: {
|
|||||||
project: LocalProject
|
project: LocalProject
|
||||||
sortNow: Accessor<number>
|
sortNow: Accessor<number>
|
||||||
mobile?: boolean
|
mobile?: boolean
|
||||||
popover?: boolean
|
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
@@ -321,7 +307,6 @@ export const SortableWorkspace = (props: {
|
|||||||
})
|
})
|
||||||
const slug = createMemo(() => base64Encode(props.directory))
|
const slug = createMemo(() => base64Encode(props.directory))
|
||||||
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
|
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
|
||||||
const children = createMemo(() => childMapByParent(workspaceStore.session))
|
|
||||||
const local = createMemo(() => props.directory === props.project.worktree)
|
const local = createMemo(() => props.directory === props.project.worktree)
|
||||||
const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
|
const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
|
||||||
const workspaceValue = createMemo(() => {
|
const workspaceValue = createMemo(() => {
|
||||||
@@ -428,7 +413,6 @@ export const SortableWorkspace = (props: {
|
|||||||
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
|
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
|
||||||
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
|
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
|
||||||
root={props.project.worktree}
|
root={props.project.worktree}
|
||||||
setHoverSession={props.ctx.setHoverSession}
|
|
||||||
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
|
||||||
navigateToNewSession={() => navigate(`/${slug()}/session`)}
|
navigateToNewSession={() => navigate(`/${slug()}/session`)}
|
||||||
/>
|
/>
|
||||||
@@ -440,12 +424,10 @@ export const SortableWorkspace = (props: {
|
|||||||
<WorkspaceSessionList
|
<WorkspaceSessionList
|
||||||
slug={slug}
|
slug={slug}
|
||||||
mobile={props.mobile}
|
mobile={props.mobile}
|
||||||
popover={props.popover}
|
|
||||||
ctx={props.ctx}
|
ctx={props.ctx}
|
||||||
showNew={showNew}
|
showNew={showNew}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
children={children}
|
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
loadMore={loadMore}
|
loadMore={loadMore}
|
||||||
language={language}
|
language={language}
|
||||||
@@ -461,7 +443,6 @@ export const LocalWorkspace = (props: {
|
|||||||
project: LocalProject
|
project: LocalProject
|
||||||
sortNow: Accessor<number>
|
sortNow: Accessor<number>
|
||||||
mobile?: boolean
|
mobile?: boolean
|
||||||
popover?: boolean
|
|
||||||
}): JSX.Element => {
|
}): JSX.Element => {
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -471,7 +452,6 @@ export const LocalWorkspace = (props: {
|
|||||||
})
|
})
|
||||||
const slug = createMemo(() => base64Encode(props.project.worktree))
|
const slug = createMemo(() => base64Encode(props.project.worktree))
|
||||||
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
|
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
|
||||||
const children = createMemo(() => childMapByParent(workspace().store.session))
|
|
||||||
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
|
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
|
||||||
const count = createMemo(() => sessions()?.length ?? 0)
|
const count = createMemo(() => sessions()?.length ?? 0)
|
||||||
const loading = createMemo(() => !booted() && count() === 0)
|
const loading = createMemo(() => !booted() && count() === 0)
|
||||||
@@ -489,12 +469,10 @@ export const LocalWorkspace = (props: {
|
|||||||
<WorkspaceSessionList
|
<WorkspaceSessionList
|
||||||
slug={slug}
|
slug={slug}
|
||||||
mobile={props.mobile}
|
mobile={props.mobile}
|
||||||
popover={props.popover}
|
|
||||||
ctx={props.ctx}
|
ctx={props.ctx}
|
||||||
showNew={() => false}
|
showNew={() => false}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
children={children}
|
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
loadMore={loadMore}
|
loadMore={loadMore}
|
||||||
language={language}
|
language={language}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
import { useMutation } from "@tanstack/solid-query"
|
import { useMutation } from "@tanstack/solid-query"
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
onMount,
|
onMount,
|
||||||
untrack,
|
untrack,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { createMediaQuery } from "@solid-primitives/media"
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
@@ -57,6 +58,7 @@ import { TerminalPanel } from "@/pages/session/terminal-panel"
|
|||||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||||
import { Identifier } from "@/utils/id"
|
import { Identifier } from "@/utils/id"
|
||||||
|
import { diffs as list } from "@/utils/diffs"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
@@ -67,6 +69,9 @@ type FollowupItem = FollowupDraft & { id: string }
|
|||||||
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
|
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
|
||||||
const emptyFollowups: FollowupItem[] = []
|
const emptyFollowups: FollowupItem[] = []
|
||||||
|
|
||||||
|
type ChangeMode = "git" | "branch" | "turn"
|
||||||
|
type VcsMode = "git" | "branch"
|
||||||
|
|
||||||
type SessionHistoryWindowInput = {
|
type SessionHistoryWindowInput = {
|
||||||
sessionID: () => string | undefined
|
sessionID: () => string | undefined
|
||||||
messagesReady: () => boolean
|
messagesReady: () => boolean
|
||||||
@@ -329,10 +334,9 @@ export default function Page() {
|
|||||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!untrack(() => prompt.ready())) return
|
if (!prompt.ready()) return
|
||||||
prompt.ready()
|
|
||||||
untrack(() => {
|
untrack(() => {
|
||||||
if (params.id || !prompt.ready()) return
|
if (params.id) return
|
||||||
const text = searchParams.prompt
|
const text = searchParams.prompt
|
||||||
if (!text) return
|
if (!text) return
|
||||||
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
|
||||||
@@ -347,6 +351,7 @@ export default function Page() {
|
|||||||
scroll: {
|
scroll: {
|
||||||
overflow: false,
|
overflow: false,
|
||||||
bottom: true,
|
bottom: true,
|
||||||
|
jump: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -425,16 +430,18 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
const isChildSession = createMemo(() => !!info()?.parentID)
|
||||||
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : []))
|
||||||
const hasReview = createMemo(() => reviewCount() > 0)
|
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||||
|
const hasSessionReview = createMemo(() => sessionCount() > 0)
|
||||||
|
const canReview = createMemo(() => !!sync.project)
|
||||||
const reviewTab = createMemo(() => isDesktop())
|
const reviewTab = createMemo(() => isDesktop())
|
||||||
const tabState = createSessionTabs({
|
const tabState = createSessionTabs({
|
||||||
tabs,
|
tabs,
|
||||||
pathFromTab: file.pathFromTab,
|
pathFromTab: file.pathFromTab,
|
||||||
normalizeTab,
|
normalizeTab,
|
||||||
review: reviewTab,
|
review: reviewTab,
|
||||||
hasReview,
|
hasReview: canReview,
|
||||||
})
|
})
|
||||||
const contextOpen = tabState.contextOpen
|
const contextOpen = tabState.contextOpen
|
||||||
const openedTabs = tabState.openedTabs
|
const openedTabs = tabState.openedTabs
|
||||||
@@ -457,7 +464,6 @@ export default function Page() {
|
|||||||
if (!id) return false
|
if (!id) return false
|
||||||
return sync.session.history.loading(id)
|
return sync.session.history.loading(id)
|
||||||
})
|
})
|
||||||
|
|
||||||
const userMessages = createMemo(
|
const userMessages = createMemo(
|
||||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||||
emptyUserMessages,
|
emptyUserMessages,
|
||||||
@@ -510,11 +516,31 @@ export default function Page() {
|
|||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
messageId: undefined as string | undefined,
|
messageId: undefined as string | undefined,
|
||||||
mobileTab: "session" as "session" | "changes",
|
mobileTab: "session" as "session" | "changes",
|
||||||
changes: "session" as "session" | "turn",
|
changes: "git" as ChangeMode,
|
||||||
newSessionWorktree: "main",
|
newSessionWorktree: "main",
|
||||||
deferRender: false,
|
deferRender: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const [vcs, setVcs] = createStore<{
|
||||||
|
diff: {
|
||||||
|
git: VcsFileDiff[]
|
||||||
|
branch: VcsFileDiff[]
|
||||||
|
}
|
||||||
|
ready: {
|
||||||
|
git: boolean
|
||||||
|
branch: boolean
|
||||||
|
}
|
||||||
|
}>({
|
||||||
|
diff: {
|
||||||
|
git: [] as VcsFileDiff[],
|
||||||
|
branch: [] as VcsFileDiff[],
|
||||||
|
},
|
||||||
|
ready: {
|
||||||
|
git: false,
|
||||||
|
branch: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const [followup, setFollowup] = persisted(
|
const [followup, setFollowup] = persisted(
|
||||||
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
|
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
|
||||||
createStore<{
|
createStore<{
|
||||||
@@ -548,6 +574,68 @@ export default function Page() {
|
|||||||
let todoTimer: number | undefined
|
let todoTimer: number | undefined
|
||||||
let diffFrame: number | undefined
|
let diffFrame: number | undefined
|
||||||
let diffTimer: number | undefined
|
let diffTimer: number | undefined
|
||||||
|
const vcsTask = new Map<VcsMode, Promise<void>>()
|
||||||
|
const vcsRun = new Map<VcsMode, number>()
|
||||||
|
|
||||||
|
const bumpVcs = (mode: VcsMode) => {
|
||||||
|
const next = (vcsRun.get(mode) ?? 0) + 1
|
||||||
|
vcsRun.set(mode, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetVcs = (mode?: VcsMode) => {
|
||||||
|
const list = mode ? [mode] : (["git", "branch"] as const)
|
||||||
|
list.forEach((item) => {
|
||||||
|
bumpVcs(item)
|
||||||
|
vcsTask.delete(item)
|
||||||
|
setVcs("diff", item, [])
|
||||||
|
setVcs("ready", item, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadVcs = (mode: VcsMode, force = false) => {
|
||||||
|
if (sync.project?.vcs !== "git") return Promise.resolve()
|
||||||
|
if (!force && vcs.ready[mode]) return Promise.resolve()
|
||||||
|
|
||||||
|
if (force) {
|
||||||
|
if (vcsTask.has(mode)) bumpVcs(mode)
|
||||||
|
vcsTask.delete(mode)
|
||||||
|
setVcs("ready", mode, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = vcsTask.get(mode)
|
||||||
|
if (current) return current
|
||||||
|
|
||||||
|
const run = bumpVcs(mode)
|
||||||
|
|
||||||
|
const task = sdk.client.vcs
|
||||||
|
.diff({ mode })
|
||||||
|
.then((result) => {
|
||||||
|
if (vcsRun.get(mode) !== run) return
|
||||||
|
setVcs("diff", mode, list(result.data))
|
||||||
|
setVcs("ready", mode, true)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (vcsRun.get(mode) !== run) return
|
||||||
|
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
||||||
|
setVcs("diff", mode, [])
|
||||||
|
setVcs("ready", mode, true)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (vcsTask.get(mode) === task) vcsTask.delete(mode)
|
||||||
|
})
|
||||||
|
|
||||||
|
vcsTask.set(mode, task)
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshVcs = () => {
|
||||||
|
resetVcs()
|
||||||
|
const mode = untrack(vcsMode)
|
||||||
|
if (!mode) return
|
||||||
|
if (!untrack(wantsReview)) return
|
||||||
|
void loadVcs(mode, true)
|
||||||
|
}
|
||||||
|
|
||||||
createComputed((prev) => {
|
createComputed((prev) => {
|
||||||
const open = desktopReviewOpen()
|
const open = desktopReviewOpen()
|
||||||
@@ -562,8 +650,37 @@ export default function Page() {
|
|||||||
return open
|
return open
|
||||||
}, desktopReviewOpen())
|
}, desktopReviewOpen())
|
||||||
|
|
||||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs))
|
||||||
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git")
|
||||||
|
const changesOptions = createMemo<ChangeMode[]>(() => {
|
||||||
|
const list: ChangeMode[] = []
|
||||||
|
if (sync.project?.vcs === "git") list.push("git")
|
||||||
|
if (
|
||||||
|
sync.project?.vcs === "git" &&
|
||||||
|
sync.data.vcs?.branch &&
|
||||||
|
sync.data.vcs?.default_branch &&
|
||||||
|
sync.data.vcs.branch !== sync.data.vcs.default_branch
|
||||||
|
) {
|
||||||
|
list.push("branch")
|
||||||
|
}
|
||||||
|
list.push("turn")
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
const vcsMode = createMemo<VcsMode | undefined>(() => {
|
||||||
|
if (store.changes === "git" || store.changes === "branch") return store.changes
|
||||||
|
})
|
||||||
|
const reviewDiffs = createMemo(() => {
|
||||||
|
if (store.changes === "git") return list(vcs.diff.git)
|
||||||
|
if (store.changes === "branch") return list(vcs.diff.branch)
|
||||||
|
return turnDiffs()
|
||||||
|
})
|
||||||
|
const reviewCount = createMemo(() => reviewDiffs().length)
|
||||||
|
const hasReview = createMemo(() => reviewCount() > 0)
|
||||||
|
const reviewReady = createMemo(() => {
|
||||||
|
if (store.changes === "git") return vcs.ready.git
|
||||||
|
if (store.changes === "branch") return vcs.ready.branch
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
const newSessionWorktree = createMemo(() => {
|
const newSessionWorktree = createMemo(() => {
|
||||||
if (store.newSessionWorktree === "create") return "create"
|
if (store.newSessionWorktree === "create") return "create"
|
||||||
@@ -629,19 +746,6 @@ export default function Page() {
|
|||||||
scrollToMessage(msgs[targetIndex], "auto")
|
scrollToMessage(msgs[targetIndex], "auto")
|
||||||
}
|
}
|
||||||
|
|
||||||
const diffsReady = createMemo(() => {
|
|
||||||
const id = params.id
|
|
||||||
if (!id) return true
|
|
||||||
if (!hasReview()) return true
|
|
||||||
return sync.data.session_diff[id] !== undefined
|
|
||||||
})
|
|
||||||
const reviewEmptyKey = createMemo(() => {
|
|
||||||
const project = sync.project
|
|
||||||
if (project && !project.vcs) return "session.review.noVcs"
|
|
||||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
|
||||||
return "session.review.empty"
|
|
||||||
})
|
|
||||||
|
|
||||||
function upsert(next: Project) {
|
function upsert(next: Project) {
|
||||||
const list = globalSync.data.project
|
const list = globalSync.data.project
|
||||||
sync.set("project", next.id)
|
sync.set("project", next.id)
|
||||||
@@ -789,13 +893,46 @@ export default function Page() {
|
|||||||
sessionKey,
|
sessionKey,
|
||||||
() => {
|
() => {
|
||||||
setStore("messageId", undefined)
|
setStore("messageId", undefined)
|
||||||
setStore("changes", "session")
|
setStore("changes", "git")
|
||||||
setUi("pendingMessage", undefined)
|
setUi("pendingMessage", undefined)
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => sdk.directory,
|
||||||
|
() => {
|
||||||
|
resetVcs()
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const,
|
||||||
|
(next, prev) => {
|
||||||
|
if (prev === undefined || same(next, prev)) return
|
||||||
|
refreshVcs()
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const stopVcs = sdk.event.listen((evt) => {
|
||||||
|
if (evt.details.type !== "file.watcher.updated") return
|
||||||
|
const props =
|
||||||
|
typeof evt.details.properties === "object" && evt.details.properties
|
||||||
|
? (evt.details.properties as Record<string, unknown>)
|
||||||
|
: undefined
|
||||||
|
const file = typeof props?.file === "string" ? props.file : undefined
|
||||||
|
if (!file || file.startsWith(".git/")) return
|
||||||
|
refreshVcs()
|
||||||
|
})
|
||||||
|
onCleanup(stopVcs)
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => params.dir,
|
() => params.dir,
|
||||||
@@ -912,12 +1049,46 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
|
||||||
if (composer.blocked()) return
|
if (composer.blocked() || isChildSession()) return
|
||||||
inputRef?.focus()
|
inputRef?.focus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
|
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
|
||||||
|
const wantsReview = createMemo(() =>
|
||||||
|
isDesktop()
|
||||||
|
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||||
|
: store.mobileTab === "changes",
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const list = changesOptions()
|
||||||
|
if (list.includes(store.changes)) return
|
||||||
|
const next = list[0]
|
||||||
|
if (!next) return
|
||||||
|
setStore("changes", next)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const mode = vcsMode()
|
||||||
|
if (!mode) return
|
||||||
|
if (!wantsReview()) return
|
||||||
|
void loadVcs(mode)
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => sync.data.session_status[params.id ?? ""]?.type,
|
||||||
|
(next, prev) => {
|
||||||
|
const mode = vcsMode()
|
||||||
|
if (!mode) return
|
||||||
|
if (!wantsReview()) return
|
||||||
|
if (next !== "idle" || prev === undefined || prev === "idle") return
|
||||||
|
void loadVcs(mode, true)
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const fileTreeTab = () => layout.fileTree.tab()
|
const fileTreeTab = () => layout.fileTree.tab()
|
||||||
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
|
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
|
||||||
@@ -947,7 +1118,10 @@ export default function Page() {
|
|||||||
setFileTreeTab("all")
|
setFileTreeTab("all")
|
||||||
}
|
}
|
||||||
|
|
||||||
const focusInput = () => inputRef?.focus()
|
const focusInput = () => {
|
||||||
|
if (isChildSession()) return
|
||||||
|
inputRef?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
useSessionCommands({
|
useSessionCommands({
|
||||||
navigateMessageByOffset,
|
navigateMessageByOffset,
|
||||||
@@ -964,21 +1138,22 @@ export default function Page() {
|
|||||||
loadFile: file.load,
|
loadFile: file.load,
|
||||||
})
|
})
|
||||||
|
|
||||||
const changesOptions = ["session", "turn"] as const
|
|
||||||
const changesOptionsList = [...changesOptions]
|
|
||||||
|
|
||||||
const changesTitle = () => {
|
const changesTitle = () => {
|
||||||
if (!hasReview()) {
|
if (!canReview()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const label = (option: ChangeMode) => {
|
||||||
|
if (option === "git") return language.t("ui.sessionReview.title.git")
|
||||||
|
if (option === "branch") return language.t("ui.sessionReview.title.branch")
|
||||||
|
return language.t("ui.sessionReview.title.lastTurn")
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
options={changesOptionsList}
|
options={changesOptions()}
|
||||||
current={store.changes}
|
current={store.changes}
|
||||||
label={(option) =>
|
label={label}
|
||||||
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
|
|
||||||
}
|
|
||||||
onSelect={(option) => option && setStore("changes", option)}
|
onSelect={(option) => option && setStore("changes", option)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -987,40 +1162,48 @@ export default function Page() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyTurn = () => (
|
const empty = (text: string) => (
|
||||||
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||||
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
|
<div class="text-14-regular text-text-weak max-w-56">{text}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
const createGit = (input: { emptyClass: string }) => (
|
||||||
if (store.changes === "turn") return emptyTurn()
|
<div class={input.emptyClass}>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
|
||||||
|
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
|
||||||
|
{language.t("session.review.noVcs.createGit.description")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
|
||||||
|
{gitMutation.isPending
|
||||||
|
? language.t("session.review.noVcs.createGit.actionLoading")
|
||||||
|
: language.t("session.review.noVcs.createGit.action")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
if (hasReview() && !diffsReady()) {
|
const reviewEmptyText = createMemo(() => {
|
||||||
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
|
||||||
|
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
|
||||||
|
return language.t("session.review.noChanges")
|
||||||
|
})
|
||||||
|
|
||||||
|
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
||||||
|
if (store.changes === "git" || store.changes === "branch") {
|
||||||
|
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||||
|
return empty(reviewEmptyText())
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reviewEmptyKey() === "session.review.noVcs") {
|
if (store.changes === "turn") {
|
||||||
return (
|
if (nogit()) return createGit(input)
|
||||||
<div class={input.emptyClass}>
|
return empty(reviewEmptyText())
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
|
|
||||||
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
|
|
||||||
{language.t("session.review.noVcs.createGit.description")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
|
|
||||||
{gitMutation.isPending
|
|
||||||
? language.t("session.review.noVcs.createGit.actionLoading")
|
|
||||||
: language.t("session.review.noVcs.createGit.action")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class={input.emptyClass}>
|
<div class={input.emptyClass}>
|
||||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1046,6 +1229,9 @@ export default function Page() {
|
|||||||
onLineCommentUpdate={updateCommentInContext}
|
onLineCommentUpdate={updateCommentInContext}
|
||||||
onLineCommentDelete={removeCommentFromContext}
|
onLineCommentDelete={removeCommentFromContext}
|
||||||
lineCommentActions={reviewCommentActions()}
|
lineCommentActions={reviewCommentActions()}
|
||||||
|
commentMentions={{
|
||||||
|
items: file.searchFilesAndDirectories,
|
||||||
|
}}
|
||||||
comments={comments.all()}
|
comments={comments.all()}
|
||||||
focusedComment={comments.focus()}
|
focusedComment={comments.focus()}
|
||||||
onFocusedCommentChange={comments.setFocus}
|
onFocusedCommentChange={comments.setFocus}
|
||||||
@@ -1124,7 +1310,7 @@ export default function Page() {
|
|||||||
const pending = tree.pendingDiff
|
const pending = tree.pendingDiff
|
||||||
if (!pending) return
|
if (!pending) return
|
||||||
if (!tree.reviewScroll) return
|
if (!tree.reviewScroll) return
|
||||||
if (!diffsReady()) return
|
if (!reviewReady()) return
|
||||||
|
|
||||||
const attempt = (count: number) => {
|
const attempt = (count: number) => {
|
||||||
if (tree.pendingDiff !== pending) return
|
if (tree.pendingDiff !== pending) return
|
||||||
@@ -1165,10 +1351,7 @@ export default function Page() {
|
|||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return
|
if (!id) return
|
||||||
|
|
||||||
const wants = isDesktop()
|
if (!wantsReview()) return
|
||||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
|
||||||
: store.mobileTab === "changes"
|
|
||||||
if (!wants) return
|
|
||||||
if (sync.data.session_diff[id] !== undefined) return
|
if (sync.data.session_diff[id] !== undefined) return
|
||||||
if (sync.status === "loading") return
|
if (sync.status === "loading") return
|
||||||
|
|
||||||
@@ -1177,13 +1360,7 @@ export default function Page() {
|
|||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() =>
|
() => [sessionKey(), wantsReview()] as const,
|
||||||
[
|
|
||||||
sessionKey(),
|
|
||||||
isDesktop()
|
|
||||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
|
||||||
: store.mobileTab === "changes",
|
|
||||||
] as const,
|
|
||||||
([key, wants]) => {
|
([key, wants]) => {
|
||||||
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
||||||
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
||||||
@@ -1244,13 +1421,17 @@ export default function Page() {
|
|||||||
let scrollStateTarget: HTMLDivElement | undefined
|
let scrollStateTarget: HTMLDivElement | undefined
|
||||||
let fillFrame: number | undefined
|
let fillFrame: number | undefined
|
||||||
|
|
||||||
|
const jumpThreshold = (el: HTMLDivElement) => Math.max(400, el.clientHeight)
|
||||||
|
|
||||||
const updateScrollState = (el: HTMLDivElement) => {
|
const updateScrollState = (el: HTMLDivElement) => {
|
||||||
const max = el.scrollHeight - el.clientHeight
|
const max = el.scrollHeight - el.clientHeight
|
||||||
|
const distance = max - el.scrollTop
|
||||||
const overflow = max > 1
|
const overflow = max > 1
|
||||||
const bottom = !overflow || el.scrollTop >= max - 2
|
const bottom = !overflow || distance <= 2
|
||||||
|
const jump = overflow && distance > jumpThreshold(el)
|
||||||
|
|
||||||
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
|
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom && ui.scroll.jump === jump) return
|
||||||
setUi("scroll", { overflow, bottom })
|
setUi("scroll", { overflow, bottom, jump })
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleScrollState = (el: HTMLDivElement) => {
|
const scheduleScrollState = (el: HTMLDivElement) => {
|
||||||
@@ -1464,7 +1645,7 @@ export default function Page() {
|
|||||||
const queueEnabled = createMemo(() => {
|
const queueEnabled = createMemo(() => {
|
||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return false
|
if (!id) return false
|
||||||
return settings.general.followup() === "queue" && busy(id) && !composer.blocked()
|
return settings.general.followup() === "queue" && busy(id) && !composer.blocked() && !isChildSession()
|
||||||
})
|
})
|
||||||
|
|
||||||
const followupText = (item: FollowupDraft) => {
|
const followupText = (item: FollowupDraft) => {
|
||||||
@@ -1496,6 +1677,7 @@ export default function Page() {
|
|||||||
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
|
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
|
||||||
|
|
||||||
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
||||||
|
if (sync.session.get(sessionID)?.parentID) return Promise.resolve()
|
||||||
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
||||||
if (!item) return Promise.resolve()
|
if (!item) return Promise.resolve()
|
||||||
if (followupBusy(sessionID)) return Promise.resolve()
|
if (followupBusy(sessionID)) return Promise.resolve()
|
||||||
@@ -1626,6 +1808,7 @@ export default function Page() {
|
|||||||
if (followupBusy(sessionID)) return
|
if (followupBusy(sessionID)) return
|
||||||
if (followup.failed[sessionID] === item.id) return
|
if (followup.failed[sessionID] === item.id) return
|
||||||
if (followup.paused[sessionID]) return
|
if (followup.paused[sessionID]) return
|
||||||
|
if (isChildSession()) return
|
||||||
if (composer.blocked()) return
|
if (composer.blocked()) return
|
||||||
if (busy(sessionID)) return
|
if (busy(sessionID)) return
|
||||||
|
|
||||||
@@ -1685,11 +1868,10 @@ export default function Page() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
document.addEventListener("keydown", handleKeyDown)
|
makeEventListener(document, "keydown", handleKeyDown)
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
document.removeEventListener("keydown", handleKeyDown)
|
|
||||||
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||||
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
|
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
|
||||||
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
|
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
|
||||||
@@ -1808,7 +1990,7 @@ export default function Page() {
|
|||||||
}}
|
}}
|
||||||
onResponseSubmit={resumeScroll}
|
onResponseSubmit={resumeScroll}
|
||||||
followup={
|
followup={
|
||||||
params.id
|
params.id && !isChildSession()
|
||||||
? {
|
? {
|
||||||
queue: queueEnabled,
|
queue: queueEnabled,
|
||||||
items: followupDock(),
|
items: followupDock(),
|
||||||
@@ -1860,6 +2042,12 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SessionSidePanel
|
<SessionSidePanel
|
||||||
|
canReview={canReview}
|
||||||
|
diffs={reviewDiffs}
|
||||||
|
diffsReady={reviewReady}
|
||||||
|
empty={reviewEmptyText}
|
||||||
|
hasReview={hasReview}
|
||||||
|
reviewCount={reviewCount}
|
||||||
reviewPanel={reviewPanel}
|
reviewPanel={reviewPanel}
|
||||||
activeDiff={tree.activeDiff}
|
activeDiff={tree.activeDiff}
|
||||||
focusReviewDiff={focusReviewDiff}
|
focusReviewDiff={focusReviewDiff}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Show, createEffect, createMemo, onCleanup } from "solid-js"
|
import { Show, createEffect, createMemo, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { useNavigate } from "@solidjs/router"
|
||||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||||
import { PromptInput } from "@/components/prompt-input"
|
import { PromptInput } from "@/components/prompt-input"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||||
import { useSessionKey } from "@/pages/session/session-layout"
|
import { useSessionKey } from "@/pages/session/session-layout"
|
||||||
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
|
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
|
||||||
@@ -13,6 +15,7 @@ import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
|
|||||||
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
|
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
|
||||||
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
|
||||||
import type { FollowupDraft } from "@/components/prompt-input/submit"
|
import type { FollowupDraft } from "@/components/prompt-input/submit"
|
||||||
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
|
|
||||||
export function SessionComposerRegion(props: {
|
export function SessionComposerRegion(props: {
|
||||||
state: SessionComposerState
|
state: SessionComposerState
|
||||||
@@ -42,11 +45,17 @@ export function SessionComposerRegion(props: {
|
|||||||
}
|
}
|
||||||
setPromptDockRef: (el: HTMLDivElement) => void
|
setPromptDockRef: (el: HTMLDivElement) => void
|
||||||
}) {
|
}) {
|
||||||
|
const navigate = useNavigate()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const route = useSessionKey()
|
const route = useSessionKey()
|
||||||
|
const sync = useSync()
|
||||||
|
|
||||||
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
|
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
|
||||||
|
const info = createMemo(() => (route.params.id ? sync.session.get(route.params.id) : undefined))
|
||||||
|
const parentID = createMemo(() => info()?.parentID)
|
||||||
|
const child = createMemo(() => !!parentID())
|
||||||
|
const showComposer = createMemo(() => !props.state.blocked() || child())
|
||||||
|
|
||||||
const previewPrompt = () =>
|
const previewPrompt = () =>
|
||||||
prompt
|
prompt
|
||||||
@@ -112,16 +121,18 @@ export function SessionComposerRegion(props: {
|
|||||||
const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
|
const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
|
||||||
const full = createMemo(() => Math.max(78, store.height))
|
const full = createMemo(() => Math.max(78, store.height))
|
||||||
|
|
||||||
|
const openParent = () => {
|
||||||
|
const id = parentID()
|
||||||
|
if (!id) return
|
||||||
|
navigate(`/${route.params.dir}/session/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const el = store.body
|
const el = store.body
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const update = () => {
|
const update = () => setStore("height", el.getBoundingClientRect().height)
|
||||||
setStore("height", el.getBoundingClientRect().height)
|
createResizeObserver(store.body, update)
|
||||||
}
|
|
||||||
update()
|
update()
|
||||||
const observer = new ResizeObserver(update)
|
|
||||||
observer.observe(el)
|
|
||||||
onCleanup(() => observer.disconnect())
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -159,7 +170,7 @@ export function SessionComposerRegion(props: {
|
|||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={!props.state.blocked()}>
|
<Show when={showComposer()}>
|
||||||
<Show
|
<Show
|
||||||
when={prompt.ready()}
|
when={prompt.ready()}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -235,17 +246,40 @@ export function SessionComposerRegion(props: {
|
|||||||
onEdit={props.followup!.onEdit}
|
onEdit={props.followup!.onEdit}
|
||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
<PromptInput
|
<Show
|
||||||
ref={props.inputRef}
|
when={child()}
|
||||||
newSessionWorktree={props.newSessionWorktree}
|
fallback={
|
||||||
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
|
<Show when={!props.state.blocked()}>
|
||||||
edit={props.followup?.edit}
|
<PromptInput
|
||||||
onEditLoaded={props.followup?.onEditLoaded}
|
ref={props.inputRef}
|
||||||
shouldQueue={props.followup?.queue}
|
newSessionWorktree={props.newSessionWorktree}
|
||||||
onQueue={props.followup?.onQueue}
|
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
|
||||||
onAbort={props.followup?.onAbort}
|
edit={props.followup?.edit}
|
||||||
onSubmit={props.onSubmit}
|
onEditLoaded={props.followup?.onEditLoaded}
|
||||||
/>
|
shouldQueue={props.followup?.queue}
|
||||||
|
onQueue={props.followup?.onQueue}
|
||||||
|
onAbort={props.followup?.onAbort}
|
||||||
|
onSubmit={props.onSubmit}
|
||||||
|
/>
|
||||||
|
</Show>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={props.inputRef}
|
||||||
|
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
|
||||||
|
>
|
||||||
|
<span>{language.t("session.child.promptDisabled")} </span>
|
||||||
|
<Show when={parentID()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-text-base transition-colors hover:text-text-strong"
|
||||||
|
onClick={openParent}
|
||||||
|
>
|
||||||
|
{language.t("session.child.backToParent")}
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
|
import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
|
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
@@ -86,8 +87,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
|
|||||||
pull()
|
pull()
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener(composerEvent, onEvent)
|
makeEventListener(window, composerEvent, onEvent)
|
||||||
onCleanup(() => window.removeEventListener(composerEvent, onEvent))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const todos = createMemo((): Todo[] => {
|
const todos = createMemo((): Todo[] => {
|
||||||
|
|||||||
@@ -8,9 +8,56 @@ import { showToast } from "@opencode-ai/ui/toast"
|
|||||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
|
|
||||||
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
|
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
|
||||||
|
|
||||||
|
function Mark(props: { multi: boolean; picked: boolean; onClick?: (event: MouseEvent) => void }) {
|
||||||
|
return (
|
||||||
|
<span data-slot="question-option-check" aria-hidden="true" onClick={props.onClick}>
|
||||||
|
<span data-slot="question-option-box" data-type={props.multi ? "checkbox" : "radio"} data-picked={props.picked}>
|
||||||
|
<Show when={props.multi} fallback={<span data-slot="question-option-radio-dot" />}>
|
||||||
|
<Icon name="check-small" size="small" />
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Option(props: {
|
||||||
|
multi: boolean
|
||||||
|
picked: boolean
|
||||||
|
label: string
|
||||||
|
description?: string
|
||||||
|
disabled: boolean
|
||||||
|
ref?: (el: HTMLButtonElement) => void
|
||||||
|
onFocus?: VoidFunction
|
||||||
|
onClick: VoidFunction
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={props.ref}
|
||||||
|
data-slot="question-option"
|
||||||
|
data-picked={props.picked}
|
||||||
|
role={props.multi ? "checkbox" : "radio"}
|
||||||
|
aria-checked={props.picked}
|
||||||
|
disabled={props.disabled}
|
||||||
|
onFocus={props.onFocus}
|
||||||
|
onClick={props.onClick}
|
||||||
|
>
|
||||||
|
<Mark multi={props.multi} picked={props.picked} />
|
||||||
|
<span data-slot="question-option-main">
|
||||||
|
<span data-slot="option-label">{props.label}</span>
|
||||||
|
<Show when={props.description}>
|
||||||
|
<span data-slot="option-description">{props.description}</span>
|
||||||
|
</Show>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
|
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -25,22 +72,30 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
custom: cached?.custom ?? ([] as string[]),
|
custom: cached?.custom ?? ([] as string[]),
|
||||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||||
editing: false,
|
editing: false,
|
||||||
|
focus: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
let root: HTMLDivElement | undefined
|
let root: HTMLDivElement | undefined
|
||||||
|
let customRef: HTMLButtonElement | undefined
|
||||||
|
let optsRef: HTMLButtonElement[] = []
|
||||||
let replied = false
|
let replied = false
|
||||||
|
let focusFrame: number | undefined
|
||||||
|
|
||||||
const question = createMemo(() => questions()[store.tab])
|
const question = createMemo(() => questions()[store.tab])
|
||||||
const options = createMemo(() => question()?.options ?? [])
|
const options = createMemo(() => question()?.options ?? [])
|
||||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||||
const on = createMemo(() => store.customOn[store.tab] === true)
|
const on = createMemo(() => store.customOn[store.tab] === true)
|
||||||
const multi = createMemo(() => question()?.multiple === true)
|
const multi = createMemo(() => question()?.multiple === true)
|
||||||
|
const count = createMemo(() => options().length + 1)
|
||||||
|
|
||||||
const summary = createMemo(() => {
|
const summary = createMemo(() => {
|
||||||
const n = Math.min(store.tab + 1, total())
|
const n = Math.min(store.tab + 1, total())
|
||||||
return language.t("session.question.progress", { current: n, total: total() })
|
return language.t("session.question.progress", { current: n, total: total() })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
|
||||||
|
const customPlaceholder = () => language.t("ui.question.custom.placeholder")
|
||||||
|
|
||||||
const last = createMemo(() => store.tab >= total() - 1)
|
const last = createMemo(() => store.tab >= total() - 1)
|
||||||
|
|
||||||
const customUpdate = (value: string, selected: boolean = on()) => {
|
const customUpdate = (value: string, selected: boolean = on()) => {
|
||||||
@@ -85,6 +140,29 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
root.style.setProperty("--question-prompt-max-height", `${max}px`)
|
root.style.setProperty("--question-prompt-max-height", `${max}px`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i))
|
||||||
|
|
||||||
|
const pickFocus = (tab: number = store.tab) => {
|
||||||
|
const list = questions()[tab]?.options ?? []
|
||||||
|
if (store.customOn[tab] === true) return list.length
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const focus = (i: number) => {
|
||||||
|
const next = clamp(i)
|
||||||
|
setStore("focus", next)
|
||||||
|
if (store.editing) return
|
||||||
|
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
||||||
|
focusFrame = requestAnimationFrame(() => {
|
||||||
|
focusFrame = undefined
|
||||||
|
const el = next === options().length ? customRef : optsRef[next]
|
||||||
|
el?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
let raf: number | undefined
|
let raf: number | undefined
|
||||||
const update = () => {
|
const update = () => {
|
||||||
@@ -96,22 +174,22 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
}
|
}
|
||||||
|
|
||||||
update()
|
update()
|
||||||
window.addEventListener("resize", update)
|
|
||||||
|
makeEventListener(window, "resize", update)
|
||||||
|
|
||||||
const dock = root?.closest('[data-component="session-prompt-dock"]')
|
const dock = root?.closest('[data-component="session-prompt-dock"]')
|
||||||
const scroller = document.querySelector(".scroll-view__viewport")
|
const scroller = document.querySelector(".scroll-view__viewport")
|
||||||
const observer = new ResizeObserver(update)
|
createResizeObserver([dock, scroller], update)
|
||||||
if (dock instanceof HTMLElement) observer.observe(dock)
|
|
||||||
if (scroller instanceof HTMLElement) observer.observe(scroller)
|
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
window.removeEventListener("resize", update)
|
|
||||||
observer.disconnect()
|
|
||||||
if (raf !== undefined) cancelAnimationFrame(raf)
|
if (raf !== undefined) cancelAnimationFrame(raf)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
focus(pickFocus())
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
||||||
if (replied) return
|
if (replied) return
|
||||||
cache.set(props.request.id, {
|
cache.set(props.request.id, {
|
||||||
tab: store.tab,
|
tab: store.tab,
|
||||||
@@ -164,6 +242,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
|
|
||||||
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
|
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
|
||||||
|
|
||||||
|
const answered = (i: number) => {
|
||||||
|
if ((store.answers[i]?.length ?? 0) > 0) return true
|
||||||
|
return store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false
|
||||||
|
|
||||||
const pick = (answer: string, custom: boolean = false) => {
|
const pick = (answer: string, custom: boolean = false) => {
|
||||||
setStore("answers", store.tab, [answer])
|
setStore("answers", store.tab, [answer])
|
||||||
if (custom) setStore("custom", store.tab, answer)
|
if (custom) setStore("custom", store.tab, answer)
|
||||||
@@ -180,6 +265,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
|
|
||||||
const customToggle = () => {
|
const customToggle = () => {
|
||||||
if (sending()) return
|
if (sending()) return
|
||||||
|
setStore("focus", options().length)
|
||||||
|
|
||||||
if (!multi()) {
|
if (!multi()) {
|
||||||
setStore("customOn", store.tab, true)
|
setStore("customOn", store.tab, true)
|
||||||
@@ -199,15 +285,68 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
const value = input().trim()
|
const value = input().trim()
|
||||||
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
|
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
|
focus(options().length)
|
||||||
}
|
}
|
||||||
|
|
||||||
const customOpen = () => {
|
const customOpen = () => {
|
||||||
if (sending()) return
|
if (sending()) return
|
||||||
|
setStore("focus", options().length)
|
||||||
if (!on()) setStore("customOn", store.tab, true)
|
if (!on()) setStore("customOn", store.tab, true)
|
||||||
setStore("editing", true)
|
setStore("editing", true)
|
||||||
customUpdate(input(), true)
|
customUpdate(input(), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const move = (step: number) => {
|
||||||
|
if (store.editing || sending()) return
|
||||||
|
focus(store.focus + step)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nav = (event: KeyboardEvent) => {
|
||||||
|
if (event.defaultPrevented) return
|
||||||
|
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault()
|
||||||
|
void reject()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const mod = (event.metaKey || event.ctrlKey) && !event.altKey
|
||||||
|
if (mod && event.key === "Enter") {
|
||||||
|
if (event.repeat) return
|
||||||
|
event.preventDefault()
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target =
|
||||||
|
event.target instanceof HTMLElement ? event.target.closest('[data-slot="question-options"]') : undefined
|
||||||
|
if (store.editing) return
|
||||||
|
if (!(target instanceof HTMLElement)) return
|
||||||
|
if (event.altKey || event.ctrlKey || event.metaKey) return
|
||||||
|
|
||||||
|
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
|
||||||
|
event.preventDefault()
|
||||||
|
move(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
|
||||||
|
event.preventDefault()
|
||||||
|
move(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Home") {
|
||||||
|
event.preventDefault()
|
||||||
|
focus(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key !== "End") return
|
||||||
|
event.preventDefault()
|
||||||
|
focus(count() - 1)
|
||||||
|
}
|
||||||
|
|
||||||
const selectOption = (optIndex: number) => {
|
const selectOption = (optIndex: number) => {
|
||||||
if (sending()) return
|
if (sending()) return
|
||||||
|
|
||||||
@@ -219,6 +358,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
const opt = options()[optIndex]
|
const opt = options()[optIndex]
|
||||||
if (!opt) return
|
if (!opt) return
|
||||||
if (multi()) {
|
if (multi()) {
|
||||||
|
setStore("editing", false)
|
||||||
toggle(opt.label)
|
toggle(opt.label)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -228,6 +368,25 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
const commitCustom = () => {
|
const commitCustom = () => {
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
customUpdate(input())
|
customUpdate(input())
|
||||||
|
focus(options().length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resizeInput = (el: HTMLTextAreaElement) => {
|
||||||
|
el.style.height = "0px"
|
||||||
|
el.style.height = `${el.scrollHeight}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusCustom = (el: HTMLTextAreaElement) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
el.focus()
|
||||||
|
resizeInput(el)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCustomMark = (event: MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
customToggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
@@ -239,27 +398,33 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setStore("tab", store.tab + 1)
|
const tab = store.tab + 1
|
||||||
|
setStore("tab", tab)
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
|
focus(pickFocus(tab))
|
||||||
}
|
}
|
||||||
|
|
||||||
const back = () => {
|
const back = () => {
|
||||||
if (sending()) return
|
if (sending()) return
|
||||||
if (store.tab <= 0) return
|
if (store.tab <= 0) return
|
||||||
setStore("tab", store.tab - 1)
|
const tab = store.tab - 1
|
||||||
|
setStore("tab", tab)
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
|
focus(pickFocus(tab))
|
||||||
}
|
}
|
||||||
|
|
||||||
const jump = (tab: number) => {
|
const jump = (tab: number) => {
|
||||||
if (sending()) return
|
if (sending()) return
|
||||||
setStore("tab", tab)
|
setStore("tab", tab)
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
|
focus(pickFocus(tab))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DockPrompt
|
<DockPrompt
|
||||||
kind="question"
|
kind="question"
|
||||||
ref={(el) => (root = el)}
|
ref={(el) => (root = el)}
|
||||||
|
onKeyDown={nav}
|
||||||
header={
|
header={
|
||||||
<>
|
<>
|
||||||
<div data-slot="question-header-title">{summary()}</div>
|
<div data-slot="question-header-title">{summary()}</div>
|
||||||
@@ -270,10 +435,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
type="button"
|
type="button"
|
||||||
data-slot="question-progress-segment"
|
data-slot="question-progress-segment"
|
||||||
data-active={i() === store.tab}
|
data-active={i() === store.tab}
|
||||||
data-answered={
|
data-answered={answered(i())}
|
||||||
(store.answers[i()]?.length ?? 0) > 0 ||
|
|
||||||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
|
|
||||||
}
|
|
||||||
disabled={sending()}
|
disabled={sending()}
|
||||||
onClick={() => jump(i())}
|
onClick={() => jump(i())}
|
||||||
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
|
||||||
@@ -285,7 +447,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
|
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
|
||||||
{language.t("ui.common.dismiss")}
|
{language.t("ui.common.dismiss")}
|
||||||
</Button>
|
</Button>
|
||||||
<div data-slot="question-footer-actions">
|
<div data-slot="question-footer-actions">
|
||||||
@@ -294,7 +456,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
{language.t("ui.common.back")}
|
{language.t("ui.common.back")}
|
||||||
</Button>
|
</Button>
|
||||||
</Show>
|
</Show>
|
||||||
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
|
<Button
|
||||||
|
variant={last() ? "primary" : "secondary"}
|
||||||
|
size="large"
|
||||||
|
disabled={sending()}
|
||||||
|
onClick={next}
|
||||||
|
aria-keyshortcuts="Meta+Enter Control+Enter"
|
||||||
|
>
|
||||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -307,69 +475,39 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
</Show>
|
</Show>
|
||||||
<div data-slot="question-options">
|
<div data-slot="question-options">
|
||||||
<For each={options()}>
|
<For each={options()}>
|
||||||
{(opt, i) => {
|
{(opt, i) => (
|
||||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
<Option
|
||||||
return (
|
multi={multi()}
|
||||||
<button
|
picked={picked(opt.label)}
|
||||||
data-slot="question-option"
|
label={opt.label}
|
||||||
data-picked={picked()}
|
description={opt.description}
|
||||||
role={multi() ? "checkbox" : "radio"}
|
disabled={sending()}
|
||||||
aria-checked={picked()}
|
ref={(el) => (optsRef[i()] = el)}
|
||||||
disabled={sending()}
|
onFocus={() => setStore("focus", i())}
|
||||||
onClick={() => selectOption(i())}
|
onClick={() => selectOption(i())}
|
||||||
>
|
/>
|
||||||
<span data-slot="question-option-check" aria-hidden="true">
|
)}
|
||||||
<span
|
|
||||||
data-slot="question-option-box"
|
|
||||||
data-type={multi() ? "checkbox" : "radio"}
|
|
||||||
data-picked={picked()}
|
|
||||||
>
|
|
||||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
|
||||||
<Icon name="check-small" size="small" />
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span data-slot="question-option-main">
|
|
||||||
<span data-slot="option-label">{opt.label}</span>
|
|
||||||
<Show when={opt.description}>
|
|
||||||
<span data-slot="option-description">{opt.description}</span>
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
</For>
|
||||||
|
|
||||||
<Show
|
<Show
|
||||||
when={store.editing}
|
when={store.editing}
|
||||||
fallback={
|
fallback={
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={customRef}
|
||||||
data-slot="question-option"
|
data-slot="question-option"
|
||||||
data-custom="true"
|
data-custom="true"
|
||||||
data-picked={on()}
|
data-picked={on()}
|
||||||
role={multi() ? "checkbox" : "radio"}
|
role={multi() ? "checkbox" : "radio"}
|
||||||
aria-checked={on()}
|
aria-checked={on()}
|
||||||
disabled={sending()}
|
disabled={sending()}
|
||||||
|
onFocus={() => setStore("focus", options().length)}
|
||||||
onClick={customOpen}
|
onClick={customOpen}
|
||||||
>
|
>
|
||||||
<span
|
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||||
data-slot="question-option-check"
|
|
||||||
aria-hidden="true"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
customToggle()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
|
||||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
|
||||||
<Icon name="check-small" size="small" />
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span data-slot="question-option-main">
|
<span data-slot="question-option-main">
|
||||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
<span data-slot="option-label">{customLabel()}</span>
|
||||||
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
|
<span data-slot="option-description">{input() || customPlaceholder()}</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -394,33 +532,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
commitCustom()
|
commitCustom()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||||
data-slot="question-option-check"
|
|
||||||
aria-hidden="true"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
customToggle()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
|
|
||||||
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
|
|
||||||
<Icon name="check-small" size="small" />
|
|
||||||
</Show>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span data-slot="question-option-main">
|
<span data-slot="question-option-main">
|
||||||
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
|
<span data-slot="option-label">{customLabel()}</span>
|
||||||
<textarea
|
<textarea
|
||||||
ref={(el) =>
|
ref={focusCustom}
|
||||||
setTimeout(() => {
|
|
||||||
el.focus()
|
|
||||||
el.style.height = "0px"
|
|
||||||
el.style.height = `${el.scrollHeight}px`
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
data-slot="question-custom-input"
|
data-slot="question-custom-input"
|
||||||
placeholder={language.t("ui.question.custom.placeholder")}
|
placeholder={customPlaceholder()}
|
||||||
value={input()}
|
value={input()}
|
||||||
rows={1}
|
rows={1}
|
||||||
disabled={sending()}
|
disabled={sending()}
|
||||||
@@ -428,16 +546,17 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
|||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setStore("editing", false)
|
setStore("editing", false)
|
||||||
|
focus(options().length)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||||
if (e.key !== "Enter" || e.shiftKey) return
|
if (e.key !== "Enter" || e.shiftKey) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
commitCustom()
|
commitCustom()
|
||||||
}}
|
}}
|
||||||
onInput={(e) => {
|
onInput={(e) => {
|
||||||
customUpdate(e.currentTarget.value)
|
customUpdate(e.currentTarget.value)
|
||||||
e.currentTarget.style.height = "0px"
|
resizeInput(e.currentTarget)
|
||||||
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
|||||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||||
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
import { TextReveal } from "@opencode-ai/ui/text-reveal"
|
||||||
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
|
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
|
||||||
import { Index, createEffect, createMemo, on, onCleanup } from "solid-js"
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
|
import { Index, createEffect, createMemo, onCleanup } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { composerEnabled, composerProbe } from "@/testing/session-composer"
|
import { composerEnabled, composerProbe } from "@/testing/session-composer"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
@@ -91,9 +92,7 @@ export function SessionTodoDock(props: {
|
|||||||
setStore("height", el.getBoundingClientRect().height)
|
setStore("height", el.getBoundingClientRect().height)
|
||||||
}
|
}
|
||||||
update()
|
update()
|
||||||
const observer = new ResizeObserver(update)
|
createResizeObserver(el, update)
|
||||||
observer.observe(el)
|
|
||||||
onCleanup(() => observer.disconnect())
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -211,76 +210,25 @@ export function SessionTodoDock(props: {
|
|||||||
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
|
opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TodoList todos={props.todos} open={!store.collapsed} />
|
<TodoList todos={props.todos} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DockTray>
|
</DockTray>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TodoList(props: { todos: Todo[]; open: boolean }) {
|
function TodoList(props: { todos: Todo[] }) {
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
stuck: false,
|
stuck: false,
|
||||||
scrolling: false,
|
|
||||||
})
|
|
||||||
let scrollRef!: HTMLDivElement
|
|
||||||
let timer: number | undefined
|
|
||||||
|
|
||||||
const inProgress = createMemo(() => props.todos.findIndex((todo) => todo.status === "in_progress"))
|
|
||||||
|
|
||||||
const ensure = () => {
|
|
||||||
if (!props.open) return
|
|
||||||
if (store.scrolling) return
|
|
||||||
if (!scrollRef || scrollRef.offsetParent === null) return
|
|
||||||
|
|
||||||
const el = scrollRef.querySelector("[data-in-progress]")
|
|
||||||
if (!(el instanceof HTMLElement)) return
|
|
||||||
|
|
||||||
const topFade = 16
|
|
||||||
const bottomFade = 44
|
|
||||||
const container = scrollRef.getBoundingClientRect()
|
|
||||||
const rect = el.getBoundingClientRect()
|
|
||||||
const top = rect.top - container.top + scrollRef.scrollTop
|
|
||||||
const bottom = rect.bottom - container.top + scrollRef.scrollTop
|
|
||||||
const viewTop = scrollRef.scrollTop + topFade
|
|
||||||
const viewBottom = scrollRef.scrollTop + scrollRef.clientHeight - bottomFade
|
|
||||||
|
|
||||||
if (top < viewTop) {
|
|
||||||
scrollRef.scrollTop = Math.max(0, top - topFade)
|
|
||||||
} else if (bottom > viewBottom) {
|
|
||||||
scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade)
|
|
||||||
}
|
|
||||||
|
|
||||||
setStore("stuck", scrollRef.scrollTop > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on([() => props.open, inProgress], () => {
|
|
||||||
if (!props.open || inProgress() < 0) return
|
|
||||||
requestAnimationFrame(ensure)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
if (!timer) return
|
|
||||||
window.clearTimeout(timer)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div
|
<div
|
||||||
class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"
|
class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar"
|
||||||
ref={scrollRef}
|
|
||||||
style={{ "overflow-anchor": "none" }}
|
style={{ "overflow-anchor": "none" }}
|
||||||
onScroll={(e) => {
|
onScroll={(e) => {
|
||||||
setStore("stuck", e.currentTarget.scrollTop > 0)
|
setStore("stuck", e.currentTarget.scrollTop > 0)
|
||||||
setStore("scrolling", true)
|
|
||||||
if (timer) window.clearTimeout(timer)
|
|
||||||
timer = window.setTimeout(() => {
|
|
||||||
setStore("scrolling", false)
|
|
||||||
if (inProgress() < 0) return
|
|
||||||
requestAnimationFrame(ensure)
|
|
||||||
}, 250)
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Index each={props.todos}>
|
<Index each={props.todos}>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createEffect, createMemo, Match, on, onCleanup, Switch } from "solid-js"
|
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { Dynamic } from "solid-js/web"
|
import { Dynamic } from "solid-js/web"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import type { FileSearchHandle } from "@opencode-ai/ui/file"
|
import type { FileSearchHandle } from "@opencode-ai/ui/file"
|
||||||
import { useFileComponent } from "@opencode-ai/ui/context/file"
|
import { useFileComponent } from "@opencode-ai/ui/context/file"
|
||||||
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
|
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
|
||||||
@@ -52,6 +53,124 @@ function FileCommentMenu(props: {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ScrollPos = { x: number; y: number }
|
||||||
|
|
||||||
|
function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) {
|
||||||
|
let scroll: HTMLDivElement | undefined
|
||||||
|
let scrollFrame: number | undefined
|
||||||
|
let restoreFrame: number | undefined
|
||||||
|
let pending: ScrollPos | undefined
|
||||||
|
const [code, setCode] = createSignal<HTMLElement[]>([])
|
||||||
|
|
||||||
|
const getCode = () => {
|
||||||
|
const el = scroll
|
||||||
|
if (!el) return []
|
||||||
|
|
||||||
|
const host = el.querySelector("diffs-container")
|
||||||
|
if (!(host instanceof HTMLElement)) return []
|
||||||
|
|
||||||
|
const root = host.shadowRoot
|
||||||
|
if (!root) return []
|
||||||
|
|
||||||
|
return Array.from(root.querySelectorAll("[data-code]")).filter(
|
||||||
|
(node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = (next: ScrollPos) => {
|
||||||
|
pending = next
|
||||||
|
if (scrollFrame !== undefined) return
|
||||||
|
|
||||||
|
scrollFrame = requestAnimationFrame(() => {
|
||||||
|
scrollFrame = undefined
|
||||||
|
|
||||||
|
const out = pending
|
||||||
|
pending = undefined
|
||||||
|
if (!out) return
|
||||||
|
|
||||||
|
input.view().setScroll(input.tab(), out)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCodeScroll = (event: Event) => {
|
||||||
|
const el = scroll
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const target = event.currentTarget
|
||||||
|
if (!(target instanceof HTMLElement)) return
|
||||||
|
|
||||||
|
save({
|
||||||
|
x: target.scrollLeft,
|
||||||
|
y: el.scrollTop,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
const next = getCode()
|
||||||
|
const current = code()
|
||||||
|
if (next.length === current.length && next.every((el, i) => el === current[i])) return
|
||||||
|
setCode(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const restore = () => {
|
||||||
|
const el = scroll
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const pos = input.view().scroll(input.tab())
|
||||||
|
if (!pos) return
|
||||||
|
|
||||||
|
sync()
|
||||||
|
|
||||||
|
if (code().length > 0) {
|
||||||
|
for (const item of code()) {
|
||||||
|
if (item.scrollLeft !== pos.x) item.scrollLeft = pos.x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.scrollTop !== pos.y) el.scrollTop = pos.y
|
||||||
|
if (code().length > 0) return
|
||||||
|
if (el.scrollLeft !== pos.x) el.scrollLeft = pos.x
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueRestore = () => {
|
||||||
|
if (restoreFrame !== undefined) return
|
||||||
|
|
||||||
|
restoreFrame = requestAnimationFrame(() => {
|
||||||
|
restoreFrame = undefined
|
||||||
|
restore()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
||||||
|
if (code().length === 0) sync()
|
||||||
|
|
||||||
|
save({
|
||||||
|
x: code()[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
|
||||||
|
y: event.currentTarget.scrollTop,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
for (const item of code()) makeEventListener(item, "scroll", onCodeScroll)
|
||||||
|
})
|
||||||
|
|
||||||
|
const setViewport = (el: HTMLDivElement) => {
|
||||||
|
scroll = el
|
||||||
|
restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
|
||||||
|
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleScroll,
|
||||||
|
queueRestore,
|
||||||
|
setViewport,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function FileTabContent(props: { tab: string }) {
|
export function FileTabContent(props: { tab: string }) {
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
@@ -65,11 +184,6 @@ export function FileTabContent(props: { tab: string }) {
|
|||||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||||
}).activeFileTab
|
}).activeFileTab
|
||||||
|
|
||||||
let scroll: HTMLDivElement | undefined
|
|
||||||
let scrollFrame: number | undefined
|
|
||||||
let restoreFrame: number | undefined
|
|
||||||
let pending: { x: number; y: number } | undefined
|
|
||||||
let codeScroll: HTMLElement[] = []
|
|
||||||
let find: FileSearchHandle | null = null
|
let find: FileSearchHandle | null = null
|
||||||
|
|
||||||
const search = {
|
const search = {
|
||||||
@@ -92,6 +206,10 @@ export function FileTabContent(props: { tab: string }) {
|
|||||||
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
|
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
|
||||||
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
|
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
|
||||||
})
|
})
|
||||||
|
const scrollSync = createScrollSync({
|
||||||
|
tab: () => props.tab,
|
||||||
|
view,
|
||||||
|
})
|
||||||
|
|
||||||
const selectionPreview = (source: string, selection: FileSelection) => {
|
const selectionPreview = (source: string, selection: FileSelection) => {
|
||||||
return previewSelectedLines(source, {
|
return previewSelectedLines(source, {
|
||||||
@@ -100,6 +218,12 @@ export function FileTabContent(props: { tab: string }) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildPreview = (filePath: string, selection: FileSelection) => {
|
||||||
|
const source = filePath === path() ? contents() : file.get(filePath)?.content?.content
|
||||||
|
if (!source) return undefined
|
||||||
|
return selectionPreview(source, selection)
|
||||||
|
}
|
||||||
|
|
||||||
const addCommentToContext = (input: {
|
const addCommentToContext = (input: {
|
||||||
file: string
|
file: string
|
||||||
selection: SelectedLineRange
|
selection: SelectedLineRange
|
||||||
@@ -108,14 +232,7 @@ export function FileTabContent(props: { tab: string }) {
|
|||||||
origin?: "review" | "file"
|
origin?: "review" | "file"
|
||||||
}) => {
|
}) => {
|
||||||
const selection = selectionFromLines(input.selection)
|
const selection = selectionFromLines(input.selection)
|
||||||
const preview =
|
const preview = input.preview ?? buildPreview(input.file, selection)
|
||||||
input.preview ??
|
|
||||||
(() => {
|
|
||||||
if (input.file === path()) return selectionPreview(contents(), selection)
|
|
||||||
const source = file.get(input.file)?.content?.content
|
|
||||||
if (!source) return undefined
|
|
||||||
return selectionPreview(source, selection)
|
|
||||||
})()
|
|
||||||
|
|
||||||
const saved = comments.add({
|
const saved = comments.add({
|
||||||
file: input.file,
|
file: input.file,
|
||||||
@@ -140,8 +257,7 @@ export function FileTabContent(props: { tab: string }) {
|
|||||||
comment: string
|
comment: string
|
||||||
}) => {
|
}) => {
|
||||||
comments.update(input.file, input.id, input.comment)
|
comments.update(input.file, input.id, input.comment)
|
||||||
const preview =
|
const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined
|
||||||
input.file === path() ? selectionPreview(contents(), selectionFromLines(input.selection)) : undefined
|
|
||||||
prompt.context.updateComment(input.file, input.id, {
|
prompt.context.updateComment(input.file, input.id, {
|
||||||
comment: input.comment,
|
comment: input.comment,
|
||||||
...(preview ? { preview } : {}),
|
...(preview ? { preview } : {}),
|
||||||
@@ -179,6 +295,9 @@ export function FileTabContent(props: { tab: string }) {
|
|||||||
comments: fileComments,
|
comments: fileComments,
|
||||||
label: language.t("ui.lineComment.submit"),
|
label: language.t("ui.lineComment.submit"),
|
||||||
draftKey: () => path() ?? props.tab,
|
draftKey: () => path() ?? props.tab,
|
||||||
|
mention: {
|
||||||
|
items: file.searchFilesAndDirectories,
|
||||||
|
},
|
||||||
state: {
|
state: {
|
||||||
opened: () => note.openedComment,
|
opened: () => note.openedComment,
|
||||||
setOpened: (id) => setNote("openedComment", id),
|
setOpened: (id) => setNote("openedComment", id),
|
||||||
@@ -232,8 +351,7 @@ export function FileTabContent(props: { tab: string }) {
|
|||||||
find?.focus()
|
find?.focus()
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown, { capture: true })
|
makeEventListener(window, "keydown", onKeyDown, { capture: true })
|
||||||
onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true }))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
@@ -260,102 +378,6 @@ export function FileTabContent(props: { tab: string }) {
|
|||||||
requestAnimationFrame(() => comments.clearFocus())
|
requestAnimationFrame(() => comments.clearFocus())
|
||||||
})
|
})
|
||||||
|
|
||||||
const getCodeScroll = () => {
|
|
||||||
const el = scroll
|
|
||||||
if (!el) return []
|
|
||||||
|
|
||||||
const host = el.querySelector("diffs-container")
|
|
||||||
if (!(host instanceof HTMLElement)) return []
|
|
||||||
|
|
||||||
const root = host.shadowRoot
|
|
||||||
if (!root) return []
|
|
||||||
|
|
||||||
return Array.from(root.querySelectorAll("[data-code]")).filter(
|
|
||||||
(node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const queueScrollUpdate = (next: { x: number; y: number }) => {
|
|
||||||
pending = next
|
|
||||||
if (scrollFrame !== undefined) return
|
|
||||||
|
|
||||||
scrollFrame = requestAnimationFrame(() => {
|
|
||||||
scrollFrame = undefined
|
|
||||||
|
|
||||||
const out = pending
|
|
||||||
pending = undefined
|
|
||||||
if (!out) return
|
|
||||||
|
|
||||||
view().setScroll(props.tab, out)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCodeScroll = (event: Event) => {
|
|
||||||
const el = scroll
|
|
||||||
if (!el) return
|
|
||||||
|
|
||||||
const target = event.currentTarget
|
|
||||||
if (!(target instanceof HTMLElement)) return
|
|
||||||
|
|
||||||
queueScrollUpdate({
|
|
||||||
x: target.scrollLeft,
|
|
||||||
y: el.scrollTop,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const syncCodeScroll = () => {
|
|
||||||
const next = getCodeScroll()
|
|
||||||
if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
|
|
||||||
|
|
||||||
for (const item of codeScroll) {
|
|
||||||
item.removeEventListener("scroll", handleCodeScroll)
|
|
||||||
}
|
|
||||||
|
|
||||||
codeScroll = next
|
|
||||||
|
|
||||||
for (const item of codeScroll) {
|
|
||||||
item.addEventListener("scroll", handleCodeScroll)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const restoreScroll = () => {
|
|
||||||
const el = scroll
|
|
||||||
if (!el) return
|
|
||||||
|
|
||||||
const s = view().scroll(props.tab)
|
|
||||||
if (!s) return
|
|
||||||
|
|
||||||
syncCodeScroll()
|
|
||||||
|
|
||||||
if (codeScroll.length > 0) {
|
|
||||||
for (const item of codeScroll) {
|
|
||||||
if (item.scrollLeft !== s.x) item.scrollLeft = s.x
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (el.scrollTop !== s.y) el.scrollTop = s.y
|
|
||||||
if (codeScroll.length > 0) return
|
|
||||||
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
|
|
||||||
}
|
|
||||||
|
|
||||||
const queueRestore = () => {
|
|
||||||
if (restoreFrame !== undefined) return
|
|
||||||
|
|
||||||
restoreFrame = requestAnimationFrame(() => {
|
|
||||||
restoreFrame = undefined
|
|
||||||
restoreScroll()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
|
|
||||||
if (codeScroll.length === 0) syncCodeScroll()
|
|
||||||
|
|
||||||
queueScrollUpdate({
|
|
||||||
x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
|
|
||||||
y: event.currentTarget.scrollTop,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelCommenting = () => {
|
const cancelCommenting = () => {
|
||||||
const p = path()
|
const p = path()
|
||||||
if (p) file.setSelectedLines(p, null)
|
if (p) file.setSelectedLines(p, null)
|
||||||
@@ -375,16 +397,7 @@ export function FileTabContent(props: { tab: string }) {
|
|||||||
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
|
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
|
||||||
prev = { loaded, ready, active }
|
prev = { loaded, ready, active }
|
||||||
if (!restore) return
|
if (!restore) return
|
||||||
queueRestore()
|
scrollSync.queueRestore()
|
||||||
})
|
|
||||||
|
|
||||||
onCleanup(() => {
|
|
||||||
for (const item of codeScroll) {
|
|
||||||
item.removeEventListener("scroll", handleCodeScroll)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
|
|
||||||
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const renderFile = (source: string) => (
|
const renderFile = (source: string) => (
|
||||||
@@ -402,7 +415,7 @@ export function FileTabContent(props: { tab: string }) {
|
|||||||
selectedLines={activeSelection()}
|
selectedLines={activeSelection()}
|
||||||
commentedLines={commentedLines()}
|
commentedLines={commentedLines()}
|
||||||
onRendered={() => {
|
onRendered={() => {
|
||||||
queueRestore()
|
scrollSync.queueRestore()
|
||||||
}}
|
}}
|
||||||
annotations={commentsUi.annotations()}
|
annotations={commentsUi.annotations()}
|
||||||
renderAnnotation={commentsUi.renderAnnotation}
|
renderAnnotation={commentsUi.renderAnnotation}
|
||||||
@@ -420,7 +433,7 @@ export function FileTabContent(props: { tab: string }) {
|
|||||||
mode: "auto",
|
mode: "auto",
|
||||||
path: path(),
|
path: path(),
|
||||||
current: state()?.content,
|
current: state()?.content,
|
||||||
onLoad: queueRestore,
|
onLoad: scrollSync.queueRestore,
|
||||||
onError: (args: { kind: "image" | "audio" | "svg" }) => {
|
onError: (args: { kind: "image" | "audio" | "svg" }) => {
|
||||||
if (args.kind !== "svg") return
|
if (args.kind !== "svg") return
|
||||||
showToast({
|
showToast({
|
||||||
@@ -435,14 +448,7 @@ export function FileTabContent(props: { tab: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs.Content value={props.tab} class="mt-3 relative h-full">
|
<Tabs.Content value={props.tab} class="mt-3 relative h-full">
|
||||||
<ScrollView
|
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
|
||||||
class="h-full"
|
|
||||||
viewportRef={(el: HTMLDivElement) => {
|
|
||||||
scroll = el
|
|
||||||
restoreScroll()
|
|
||||||
}}
|
|
||||||
onScroll={handleScroll as any}
|
|
||||||
>
|
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
|
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
|
||||||
<Match when={state()?.loading}>
|
<Match when={state()?.loading}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
|
|
||||||
const emptyTabs: string[] = []
|
const emptyTabs: string[] = []
|
||||||
@@ -171,14 +172,9 @@ export const createSizing = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
window.addEventListener("pointerup", stop)
|
makeEventListener(window, "pointerup", stop)
|
||||||
window.addEventListener("pointercancel", stop)
|
makeEventListener(window, "pointercancel", stop)
|
||||||
window.addEventListener("blur", stop)
|
makeEventListener(window, "blur", stop)
|
||||||
onCleanup(() => {
|
|
||||||
window.removeEventListener("pointerup", stop)
|
|
||||||
window.removeEventListener("pointercancel", stop)
|
|
||||||
window.removeEventListener("blur", stop)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { Popover as KobaltePopover } from "@kobalte/core/popover"
|
|||||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSessionKey } from "@/pages/session/session-layout"
|
import { useSessionKey } from "@/pages/session/session-layout"
|
||||||
import { useGlobalSDK } from "@/context/global-sdk"
|
import { useGlobalSDK } from "@/context/global-sdk"
|
||||||
@@ -29,6 +30,7 @@ import { useSettings } from "@/context/settings"
|
|||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { messageAgentColor } from "@/utils/agent"
|
import { messageAgentColor } from "@/utils/agent"
|
||||||
|
import { sessionTitle } from "@/utils/session-title"
|
||||||
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
|
||||||
import { makeTimer } from "@solid-primitives/timer"
|
import { makeTimer } from "@solid-primitives/timer"
|
||||||
|
|
||||||
@@ -43,7 +45,6 @@ type MessageComment = {
|
|||||||
|
|
||||||
const emptyMessages: MessageType[] = []
|
const emptyMessages: MessageType[] = []
|
||||||
const idle = { type: "idle" as const }
|
const idle = { type: "idle" as const }
|
||||||
|
|
||||||
type UserActions = {
|
type UserActions = {
|
||||||
fork?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
|
fork?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
|
||||||
revert?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
|
revert?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
|
||||||
@@ -68,6 +69,16 @@ const messageComments = (parts: Part[]): MessageComment[] =>
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const taskDescription = (part: Part, sessionID: string) => {
|
||||||
|
if (part.type !== "tool" || part.tool !== "task") return
|
||||||
|
const metadata = "metadata" in part.state ? part.state.metadata : undefined
|
||||||
|
if (metadata?.sessionId !== sessionID) return
|
||||||
|
const value = part.state.input?.description
|
||||||
|
if (typeof value === "string" && value) return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const pace = (width: number) => Math.round(Math.max(1200, Math.min(3200, (Math.max(width, 360) * 2000) / 900)))
|
||||||
|
|
||||||
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
|
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
|
||||||
const current = target instanceof Element ? target : undefined
|
const current = target instanceof Element ? target : undefined
|
||||||
const nested = current?.closest("[data-scrollable]")
|
const nested = current?.closest("[data-scrollable]")
|
||||||
@@ -200,7 +211,7 @@ export function MessageTimeline(props: {
|
|||||||
mobileChanges: boolean
|
mobileChanges: boolean
|
||||||
mobileFallback: JSX.Element
|
mobileFallback: JSX.Element
|
||||||
actions?: UserActions
|
actions?: UserActions
|
||||||
scroll: { overflow: boolean; bottom: boolean }
|
scroll: { overflow: boolean; bottom: boolean; jump: boolean }
|
||||||
onResumeScroll: () => void
|
onResumeScroll: () => void
|
||||||
setScrollRef: (el: HTMLDivElement | undefined) => void
|
setScrollRef: (el: HTMLDivElement | undefined) => void
|
||||||
onScheduleScrollState: (el: HTMLDivElement) => void
|
onScheduleScrollState: (el: HTMLDivElement) => void
|
||||||
@@ -291,9 +302,36 @@ export function MessageTimeline(props: {
|
|||||||
return sync.session.get(id)
|
return sync.session.get(id)
|
||||||
})
|
})
|
||||||
const titleValue = createMemo(() => info()?.title)
|
const titleValue = createMemo(() => info()?.title)
|
||||||
|
const titleLabel = createMemo(() => sessionTitle(titleValue()))
|
||||||
const shareUrl = createMemo(() => info()?.share?.url)
|
const shareUrl = createMemo(() => info()?.share?.url)
|
||||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||||
const parentID = createMemo(() => info()?.parentID)
|
const parentID = createMemo(() => info()?.parentID)
|
||||||
|
const parent = createMemo(() => {
|
||||||
|
const id = parentID()
|
||||||
|
if (!id) return
|
||||||
|
return sync.session.get(id)
|
||||||
|
})
|
||||||
|
const parentMessages = createMemo(() => {
|
||||||
|
const id = parentID()
|
||||||
|
if (!id) return emptyMessages
|
||||||
|
return sync.data.message[id] ?? emptyMessages
|
||||||
|
})
|
||||||
|
const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
|
||||||
|
const childTaskDescription = createMemo(() => {
|
||||||
|
const id = sessionID()
|
||||||
|
if (!id) return
|
||||||
|
return parentMessages()
|
||||||
|
.flatMap((message) => sync.data.part[message.id] ?? [])
|
||||||
|
.map((part) => taskDescription(part, id))
|
||||||
|
.findLast((value): value is string => !!value)
|
||||||
|
})
|
||||||
|
const childTitle = createMemo(() => {
|
||||||
|
if (!parentID()) return titleLabel() ?? ""
|
||||||
|
if (childTaskDescription()) return childTaskDescription()
|
||||||
|
const value = titleLabel()?.replace(/\s+\(@[^)]+ subagent\)$/, "")
|
||||||
|
if (value) return value
|
||||||
|
return language.t("command.session.new")
|
||||||
|
})
|
||||||
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
const showHeader = createMemo(() => !!(titleValue() || parentID()))
|
||||||
const stageCfg = { init: 1, batch: 3 }
|
const stageCfg = { init: 1, batch: 3 }
|
||||||
const staging = createTimelineStaging({
|
const staging = createTimelineStaging({
|
||||||
@@ -316,8 +354,20 @@ export function MessageTimeline(props: {
|
|||||||
open: false,
|
open: false,
|
||||||
dismiss: null as "escape" | "outside" | null,
|
dismiss: null as "escape" | "outside" | null,
|
||||||
})
|
})
|
||||||
|
const [bar, setBar] = createStore({
|
||||||
|
ms: pace(640),
|
||||||
|
})
|
||||||
|
|
||||||
let more: HTMLButtonElement | undefined
|
let more: HTMLButtonElement | undefined
|
||||||
|
let head: HTMLDivElement | undefined
|
||||||
|
|
||||||
|
createResizeObserver(
|
||||||
|
() => head,
|
||||||
|
() => {
|
||||||
|
if (!head || head.clientWidth <= 0) return
|
||||||
|
setBar("ms", pace(head.clientWidth))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const viewShare = () => {
|
const viewShare = () => {
|
||||||
const url = shareUrl()
|
const url = shareUrl()
|
||||||
@@ -397,9 +447,21 @@ export function MessageTimeline(props: {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => [parentID(), childTaskDescription()] as const,
|
||||||
|
([id, description]) => {
|
||||||
|
if (!id || description) return
|
||||||
|
if (sync.data.message[id] !== undefined) return
|
||||||
|
void sync.session.sync(id)
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const openTitleEditor = () => {
|
const openTitleEditor = () => {
|
||||||
if (!sessionID()) return
|
if (!sessionID() || parentID()) return
|
||||||
setTitle({ editing: true, draft: titleValue() ?? "" })
|
setTitle({ editing: true, draft: titleLabel() ?? "" })
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
titleRef?.focus()
|
titleRef?.focus()
|
||||||
titleRef?.select()
|
titleRef?.select()
|
||||||
@@ -417,7 +479,7 @@ export function MessageTimeline(props: {
|
|||||||
if (titleMutation.isPending) return
|
if (titleMutation.isPending) return
|
||||||
|
|
||||||
const next = title.draft.trim()
|
const next = title.draft.trim()
|
||||||
if (!next || next === (titleValue() ?? "")) {
|
if (!next || next === (titleLabel() ?? "")) {
|
||||||
setTitle("editing", false)
|
setTitle("editing", false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -532,7 +594,9 @@ export function MessageTimeline(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DialogDeleteSession(props: { sessionID: string }) {
|
function DialogDeleteSession(props: { sessionID: string }) {
|
||||||
const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
|
const name = createMemo(
|
||||||
|
() => sessionTitle(sync.session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
|
||||||
|
)
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
await deleteSession(props.sessionID)
|
await deleteSession(props.sessionID)
|
||||||
dialog.close()
|
dialog.close()
|
||||||
@@ -568,17 +632,24 @@ export function MessageTimeline(props: {
|
|||||||
<div
|
<div
|
||||||
class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
|
||||||
classList={{
|
classList={{
|
||||||
"opacity-100 translate-y-0 scale-100":
|
"opacity-100 translate-y-0 scale-100": props.scroll.overflow && props.scroll.jump && !staging.isStaging(),
|
||||||
props.scroll.overflow && !props.scroll.bottom && !staging.isStaging(),
|
|
||||||
"opacity-0 translate-y-2 scale-95 pointer-events-none":
|
"opacity-0 translate-y-2 scale-95 pointer-events-none":
|
||||||
!props.scroll.overflow || props.scroll.bottom || staging.isStaging(),
|
!props.scroll.overflow || !props.scroll.jump || staging.isStaging(),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="pointer-events-auto size-8 flex items-center justify-center rounded-full bg-background-base border border-border-base shadow-sm text-text-base hover:bg-background-stronger transition-colors"
|
class="pointer-events-auto flex items-center justify-center w-10 h-8 bg-transparent border-none cursor-pointer p-0 group"
|
||||||
onClick={props.onResumeScroll}
|
onClick={props.onResumeScroll}
|
||||||
>
|
>
|
||||||
<Icon name="arrow-down-to-line" />
|
<div
|
||||||
|
class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]"
|
||||||
|
style={{
|
||||||
|
"box-shadow":
|
||||||
|
"0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="arrow-down-to-line" size="small" />
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -636,27 +707,53 @@ export function MessageTimeline(props: {
|
|||||||
<div ref={props.setContentRef} class="min-w-0 w-full">
|
<div ref={props.setContentRef} class="min-w-0 w-full">
|
||||||
<Show when={showHeader()}>
|
<Show when={showHeader()}>
|
||||||
<div
|
<div
|
||||||
|
ref={(el) => {
|
||||||
|
head = el
|
||||||
|
setBar("ms", pace(el.clientWidth))
|
||||||
|
}}
|
||||||
data-session-title
|
data-session-title
|
||||||
classList={{
|
classList={{
|
||||||
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
||||||
|
relative: true,
|
||||||
"w-full": true,
|
"w-full": true,
|
||||||
"pb-4": true,
|
"pb-4": true,
|
||||||
"pl-2 pr-3 md:pl-4 md:pr-3": true,
|
"pl-2 pr-3 md:pl-4 md:pr-3": true,
|
||||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Show when={workingStatus() !== "hidden"}>
|
||||||
|
<div
|
||||||
|
data-component="session-progress"
|
||||||
|
data-state={workingStatus()}
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
"--session-progress-color": tint() ?? "var(--icon-interactive-base)",
|
||||||
|
"--session-progress-ms": `${bar.ms}ms`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div data-component="session-progress-bar" />
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
||||||
<Show when={parentID()}>
|
|
||||||
<IconButton
|
|
||||||
tabIndex={-1}
|
|
||||||
icon="arrow-left"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={navigateParent}
|
|
||||||
aria-label={language.t("common.goBack")}
|
|
||||||
/>
|
|
||||||
</Show>
|
|
||||||
<div class="flex items-center min-w-0 grow-1">
|
<div class="flex items-center min-w-0 grow-1">
|
||||||
|
<Show when={parentID()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-slot="session-title-parent"
|
||||||
|
class="min-w-0 max-w-[40%] truncate text-14-medium text-text-weak transition-colors hover:text-text-base"
|
||||||
|
onClick={navigateParent}
|
||||||
|
>
|
||||||
|
{parentTitle()}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
data-slot="session-title-separator"
|
||||||
|
class="px-2 text-14-medium text-text-weak"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</span>
|
||||||
|
</Show>
|
||||||
<div
|
<div
|
||||||
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||||
style={{
|
style={{
|
||||||
@@ -674,15 +771,16 @@ export function MessageTimeline(props: {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={titleValue() || title.editing}>
|
<Show when={childTitle() || title.editing}>
|
||||||
<Show
|
<Show
|
||||||
when={title.editing}
|
when={title.editing}
|
||||||
fallback={
|
fallback={
|
||||||
<h1
|
<h1
|
||||||
|
data-slot="session-title-child"
|
||||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
|
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
|
||||||
onDblClick={openTitleEditor}
|
onDblClick={openTitleEditor}
|
||||||
>
|
>
|
||||||
{titleValue()}
|
{childTitle()}
|
||||||
</h1>
|
</h1>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -690,6 +788,7 @@ export function MessageTimeline(props: {
|
|||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
titleRef = el
|
titleRef = el
|
||||||
}}
|
}}
|
||||||
|
data-slot="session-title-child"
|
||||||
value={title.draft}
|
value={title.draft}
|
||||||
disabled={titleMutation.isPending}
|
disabled={titleMutation.isPending}
|
||||||
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
|
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
|
||||||
@@ -717,177 +816,179 @@ export function MessageTimeline(props: {
|
|||||||
{(id) => (
|
{(id) => (
|
||||||
<div class="shrink-0 flex items-center gap-3">
|
<div class="shrink-0 flex items-center gap-3">
|
||||||
<SessionContextUsage placement="bottom" />
|
<SessionContextUsage placement="bottom" />
|
||||||
<DropdownMenu
|
<Show when={!parentID()}>
|
||||||
gutter={4}
|
<DropdownMenu
|
||||||
placement="bottom-end"
|
gutter={4}
|
||||||
open={title.menuOpen}
|
placement="bottom-end"
|
||||||
onOpenChange={(open) => {
|
open={title.menuOpen}
|
||||||
setTitle("menuOpen", open)
|
onOpenChange={(open) => {
|
||||||
if (open) return
|
setTitle("menuOpen", open)
|
||||||
}}
|
if (open) return
|
||||||
>
|
|
||||||
<DropdownMenu.Trigger
|
|
||||||
as={IconButton}
|
|
||||||
icon="dot-grid"
|
|
||||||
variant="ghost"
|
|
||||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
|
||||||
classList={{
|
|
||||||
"bg-surface-base-active": share.open || title.pendingShare,
|
|
||||||
}}
|
}}
|
||||||
aria-label={language.t("common.moreOptions")}
|
>
|
||||||
aria-expanded={title.menuOpen || share.open || title.pendingShare}
|
<DropdownMenu.Trigger
|
||||||
ref={(el: HTMLButtonElement) => {
|
as={IconButton}
|
||||||
more = el
|
icon="dot-grid"
|
||||||
}}
|
variant="ghost"
|
||||||
/>
|
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||||
<DropdownMenu.Portal>
|
classList={{
|
||||||
<DropdownMenu.Content
|
"bg-surface-base-active": share.open || title.pendingShare,
|
||||||
style={{ "min-width": "104px" }}
|
|
||||||
onCloseAutoFocus={(event) => {
|
|
||||||
if (title.pendingRename) {
|
|
||||||
event.preventDefault()
|
|
||||||
setTitle("pendingRename", false)
|
|
||||||
openTitleEditor()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (title.pendingShare) {
|
|
||||||
event.preventDefault()
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setShare({ open: true, dismiss: null })
|
|
||||||
setTitle("pendingShare", false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
aria-label={language.t("common.moreOptions")}
|
||||||
<DropdownMenu.Item
|
aria-expanded={title.menuOpen || share.open || title.pendingShare}
|
||||||
onSelect={() => {
|
ref={(el: HTMLButtonElement) => {
|
||||||
setTitle("pendingRename", true)
|
more = el
|
||||||
setTitle("menuOpen", false)
|
}}
|
||||||
|
/>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
style={{ "min-width": "104px" }}
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (title.pendingRename) {
|
||||||
|
event.preventDefault()
|
||||||
|
setTitle("pendingRename", false)
|
||||||
|
openTitleEditor()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (title.pendingShare) {
|
||||||
|
event.preventDefault()
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setShare({ open: true, dismiss: null })
|
||||||
|
setTitle("pendingShare", false)
|
||||||
|
})
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
<Show when={shareEnabled()}>
|
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setTitle({ pendingShare: true, menuOpen: false })
|
setTitle("pendingRename", true)
|
||||||
|
setTitle("menuOpen", false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownMenu.ItemLabel>
|
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||||
{language.t("session.share.action.share")}
|
|
||||||
</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
</Show>
|
<Show when={shareEnabled()}>
|
||||||
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
|
<DropdownMenu.Item
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
onSelect={() => {
|
||||||
</DropdownMenu.Item>
|
setTitle({ pendingShare: true, menuOpen: false })
|
||||||
<DropdownMenu.Separator />
|
}}
|
||||||
<DropdownMenu.Item
|
|
||||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
|
|
||||||
>
|
|
||||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
</DropdownMenu.Content>
|
|
||||||
</DropdownMenu.Portal>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<KobaltePopover
|
|
||||||
open={share.open}
|
|
||||||
anchorRef={() => more}
|
|
||||||
placement="bottom-end"
|
|
||||||
gutter={4}
|
|
||||||
modal={false}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (open) setShare("dismiss", null)
|
|
||||||
setShare("open", open)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<KobaltePopover.Portal>
|
|
||||||
<KobaltePopover.Content
|
|
||||||
data-component="popover-content"
|
|
||||||
style={{ "min-width": "320px" }}
|
|
||||||
onEscapeKeyDown={(event) => {
|
|
||||||
setShare({ dismiss: "escape", open: false })
|
|
||||||
event.preventDefault()
|
|
||||||
event.stopPropagation()
|
|
||||||
}}
|
|
||||||
onPointerDownOutside={() => {
|
|
||||||
setShare({ dismiss: "outside", open: false })
|
|
||||||
}}
|
|
||||||
onFocusOutside={() => {
|
|
||||||
setShare({ dismiss: "outside", open: false })
|
|
||||||
}}
|
|
||||||
onCloseAutoFocus={(event) => {
|
|
||||||
if (share.dismiss === "outside") event.preventDefault()
|
|
||||||
setShare("dismiss", null)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="flex flex-col p-3">
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<div class="text-13-medium text-text-strong">
|
|
||||||
{language.t("session.share.popover.title")}
|
|
||||||
</div>
|
|
||||||
<div class="text-12-regular text-text-weak">
|
|
||||||
{shareUrl()
|
|
||||||
? language.t("session.share.popover.description.shared")
|
|
||||||
: language.t("session.share.popover.description.unshared")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-3 flex flex-col gap-2">
|
|
||||||
<Show
|
|
||||||
when={shareUrl()}
|
|
||||||
fallback={
|
|
||||||
<Button
|
|
||||||
size="large"
|
|
||||||
variant="primary"
|
|
||||||
class="w-full"
|
|
||||||
onClick={shareSession}
|
|
||||||
disabled={shareMutation.isPending}
|
|
||||||
>
|
|
||||||
{shareMutation.isPending
|
|
||||||
? language.t("session.share.action.publishing")
|
|
||||||
: language.t("session.share.action.publish")}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-2">
|
<DropdownMenu.ItemLabel>
|
||||||
<TextField
|
{language.t("session.share.action.share")}
|
||||||
value={shareUrl() ?? ""}
|
</DropdownMenu.ItemLabel>
|
||||||
readOnly
|
</DropdownMenu.Item>
|
||||||
copyable
|
</Show>
|
||||||
copyKind="link"
|
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
|
||||||
tabIndex={-1}
|
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||||
class="w-full"
|
</DropdownMenu.Item>
|
||||||
/>
|
<DropdownMenu.Separator />
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<DropdownMenu.Item
|
||||||
<Button
|
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
|
||||||
size="large"
|
>
|
||||||
variant="secondary"
|
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||||
class="w-full shadow-none border border-border-weak-base"
|
</DropdownMenu.Item>
|
||||||
onClick={unshareSession}
|
</DropdownMenu.Content>
|
||||||
disabled={unshareMutation.isPending}
|
</DropdownMenu.Portal>
|
||||||
>
|
</DropdownMenu>
|
||||||
{unshareMutation.isPending
|
|
||||||
? language.t("session.share.action.unpublishing")
|
<KobaltePopover
|
||||||
: language.t("session.share.action.unpublish")}
|
open={share.open}
|
||||||
</Button>
|
anchorRef={() => more}
|
||||||
|
placement="bottom-end"
|
||||||
|
gutter={4}
|
||||||
|
modal={false}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) setShare("dismiss", null)
|
||||||
|
setShare("open", open)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<KobaltePopover.Portal>
|
||||||
|
<KobaltePopover.Content
|
||||||
|
data-component="popover-content"
|
||||||
|
style={{ "min-width": "320px" }}
|
||||||
|
onEscapeKeyDown={(event) => {
|
||||||
|
setShare({ dismiss: "escape", open: false })
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onPointerDownOutside={() => {
|
||||||
|
setShare({ dismiss: "outside", open: false })
|
||||||
|
}}
|
||||||
|
onFocusOutside={() => {
|
||||||
|
setShare({ dismiss: "outside", open: false })
|
||||||
|
}}
|
||||||
|
onCloseAutoFocus={(event) => {
|
||||||
|
if (share.dismiss === "outside") event.preventDefault()
|
||||||
|
setShare("dismiss", null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col p-3">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<div class="text-13-medium text-text-strong">
|
||||||
|
{language.t("session.share.popover.title")}
|
||||||
|
</div>
|
||||||
|
<div class="text-12-regular text-text-weak">
|
||||||
|
{shareUrl()
|
||||||
|
? language.t("session.share.popover.description.shared")
|
||||||
|
: language.t("session.share.popover.description.unshared")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex flex-col gap-2">
|
||||||
|
<Show
|
||||||
|
when={shareUrl()}
|
||||||
|
fallback={
|
||||||
<Button
|
<Button
|
||||||
size="large"
|
size="large"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
onClick={viewShare}
|
onClick={shareSession}
|
||||||
disabled={unshareMutation.isPending}
|
disabled={shareMutation.isPending}
|
||||||
>
|
>
|
||||||
{language.t("session.share.action.view")}
|
{shareMutation.isPending
|
||||||
|
? language.t("session.share.action.publishing")
|
||||||
|
: language.t("session.share.action.publish")}
|
||||||
</Button>
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<TextField
|
||||||
|
value={shareUrl() ?? ""}
|
||||||
|
readOnly
|
||||||
|
copyable
|
||||||
|
copyKind="link"
|
||||||
|
tabIndex={-1}
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
variant="secondary"
|
||||||
|
class="w-full shadow-none border border-border-weak-base"
|
||||||
|
onClick={unshareSession}
|
||||||
|
disabled={unshareMutation.isPending}
|
||||||
|
>
|
||||||
|
{unshareMutation.isPending
|
||||||
|
? language.t("session.share.action.unpublishing")
|
||||||
|
: language.t("session.share.action.unpublish")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="large"
|
||||||
|
variant="primary"
|
||||||
|
class="w-full"
|
||||||
|
onClick={viewShare}
|
||||||
|
disabled={unshareMutation.isPending}
|
||||||
|
>
|
||||||
|
{language.t("session.share.action.view")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Show>
|
||||||
</Show>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</KobaltePopover.Content>
|
||||||
</KobaltePopover.Content>
|
</KobaltePopover.Portal>
|
||||||
</KobaltePopover.Portal>
|
</KobaltePopover>
|
||||||
</KobaltePopover>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createEffect, onCleanup, type JSX } from "solid-js"
|
import { createEffect, createSignal, onCleanup, type JSX } from "solid-js"
|
||||||
import type { FileDiff } from "@opencode-ai/sdk/v2"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
|
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||||
import { SessionReview } from "@opencode-ai/ui/session-review"
|
import { SessionReview } from "@opencode-ai/ui/session-review"
|
||||||
import type {
|
import type {
|
||||||
SessionReviewCommentActions,
|
SessionReviewCommentActions,
|
||||||
@@ -13,10 +14,12 @@ import type { LineComment } from "@/context/comments"
|
|||||||
|
|
||||||
export type DiffStyle = "unified" | "split"
|
export type DiffStyle = "unified" | "split"
|
||||||
|
|
||||||
|
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
|
||||||
|
|
||||||
export interface SessionReviewTabProps {
|
export interface SessionReviewTabProps {
|
||||||
title?: JSX.Element
|
title?: JSX.Element
|
||||||
empty?: JSX.Element
|
empty?: JSX.Element
|
||||||
diffs: () => FileDiff[]
|
diffs: () => ReviewDiff[]
|
||||||
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
|
||||||
diffStyle: DiffStyle
|
diffStyle: DiffStyle
|
||||||
onDiffStyleChange?: (style: DiffStyle) => void
|
onDiffStyleChange?: (style: DiffStyle) => void
|
||||||
@@ -30,6 +33,9 @@ export interface SessionReviewTabProps {
|
|||||||
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
|
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
|
||||||
focusedFile?: string
|
focusedFile?: string
|
||||||
onScrollRef?: (el: HTMLDivElement) => void
|
onScrollRef?: (el: HTMLDivElement) => void
|
||||||
|
commentMentions?: {
|
||||||
|
items: (query: string) => string[] | Promise<string[]>
|
||||||
|
}
|
||||||
classes?: {
|
classes?: {
|
||||||
root?: string
|
root?: string
|
||||||
header?: string
|
header?: string
|
||||||
@@ -120,13 +126,6 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
|||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
|
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
|
||||||
if (scroll) {
|
|
||||||
scroll.removeEventListener("wheel", handleInteraction, { capture: true })
|
|
||||||
scroll.removeEventListener("mousewheel", handleInteraction, { capture: true })
|
|
||||||
scroll.removeEventListener("pointerdown", handleInteraction, { capture: true })
|
|
||||||
scroll.removeEventListener("touchstart", handleInteraction, { capture: true })
|
|
||||||
scroll.removeEventListener("keydown", handleInteraction, { capture: true })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -135,11 +134,11 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
|||||||
empty={props.empty}
|
empty={props.empty}
|
||||||
scrollRef={(el) => {
|
scrollRef={(el) => {
|
||||||
scroll = el
|
scroll = el
|
||||||
el.addEventListener("wheel", handleInteraction, { passive: true, capture: true })
|
makeEventListener(el, "wheel", handleInteraction, { passive: true, capture: true })
|
||||||
el.addEventListener("mousewheel", handleInteraction, { passive: true, capture: true })
|
makeEventListener(el, "mousewheel", handleInteraction, { passive: true, capture: true })
|
||||||
el.addEventListener("pointerdown", handleInteraction, { passive: true, capture: true })
|
makeEventListener(el, "pointerdown", handleInteraction, { passive: true, capture: true })
|
||||||
el.addEventListener("touchstart", handleInteraction, { passive: true, capture: true })
|
makeEventListener(el, "touchstart", handleInteraction, { passive: true, capture: true })
|
||||||
el.addEventListener("keydown", handleInteraction, { passive: true, capture: true })
|
makeEventListener(el, "keydown", handleInteraction, { capture: true })
|
||||||
props.onScrollRef?.(el)
|
props.onScrollRef?.(el)
|
||||||
queueRestore()
|
queueRestore()
|
||||||
}}
|
}}
|
||||||
@@ -162,6 +161,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
|||||||
onLineCommentUpdate={props.onLineCommentUpdate}
|
onLineCommentUpdate={props.onLineCommentUpdate}
|
||||||
onLineCommentDelete={props.onLineCommentDelete}
|
onLineCommentDelete={props.onLineCommentDelete}
|
||||||
lineCommentActions={props.lineCommentActions}
|
lineCommentActions={props.lineCommentActions}
|
||||||
|
lineCommentMention={props.commentMentions}
|
||||||
comments={props.comments}
|
comments={props.comments}
|
||||||
focusedComment={props.focusedComment}
|
focusedComment={props.focusedComment}
|
||||||
onFocusedCommentChange={props.onFocusedCommentChange}
|
onFocusedCommentChange={props.onFocusedCommentChange}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { resetSessionModel, syncSessionModel } from "./session-model-helpers"
|
import { resetSessionModel, syncSessionModel } from "./session-model-helpers"
|
||||||
|
|
||||||
const message = (input?: Partial<Pick<UserMessage, "agent" | "model" | "variant">>) =>
|
const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
|
||||||
({
|
({
|
||||||
id: "msg",
|
id: "msg",
|
||||||
sessionID: "session",
|
sessionID: "session",
|
||||||
@@ -10,7 +10,6 @@ const message = (input?: Partial<Pick<UserMessage, "agent" | "model" | "variant"
|
|||||||
time: { created: 1 },
|
time: { created: 1 },
|
||||||
agent: input?.agent ?? "build",
|
agent: input?.agent ?? "build",
|
||||||
model: input?.model ?? { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
model: input?.model ?? { providerID: "anthropic", modelID: "claude-sonnet-4" },
|
||||||
variant: input?.variant,
|
|
||||||
}) as UserMessage
|
}) as UserMessage
|
||||||
|
|
||||||
describe("syncSessionModel", () => {
|
describe("syncSessionModel", () => {
|
||||||
@@ -26,10 +25,12 @@ describe("syncSessionModel", () => {
|
|||||||
reset() {},
|
reset() {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
message({ variant: "high" }),
|
message({ model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } }),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(calls).toEqual([message({ variant: "high" })])
|
expect(calls).toEqual([
|
||||||
|
message({ model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } }),
|
||||||
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
|||||||
import { Mark } from "@opencode-ai/ui/logo"
|
import { Mark } from "@opencode-ai/ui/logo"
|
||||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||||
|
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||||
|
|
||||||
@@ -18,7 +19,6 @@ import { useCommand } from "@/context/command"
|
|||||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useSync } from "@/context/sync"
|
|
||||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||||
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
||||||
@@ -26,6 +26,12 @@ import { setSessionHandoff } from "@/pages/session/handoff"
|
|||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
|
||||||
export function SessionSidePanel(props: {
|
export function SessionSidePanel(props: {
|
||||||
|
canReview: () => boolean
|
||||||
|
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
|
||||||
|
diffsReady: () => boolean
|
||||||
|
empty: () => string
|
||||||
|
hasReview: () => boolean
|
||||||
|
reviewCount: () => number
|
||||||
reviewPanel: () => JSX.Element
|
reviewPanel: () => JSX.Element
|
||||||
activeDiff?: string
|
activeDiff?: string
|
||||||
focusReviewDiff: (path: string) => void
|
focusReviewDiff: (path: string) => void
|
||||||
@@ -33,12 +39,11 @@ export function SessionSidePanel(props: {
|
|||||||
size: Sizing
|
size: Sizing
|
||||||
}) {
|
}) {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const sync = useSync()
|
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
const { sessionKey, tabs, view } = useSessionLayout()
|
||||||
|
|
||||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||||
|
|
||||||
@@ -53,24 +58,7 @@ export function SessionSidePanel(props: {
|
|||||||
})
|
})
|
||||||
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
|
||||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
|
||||||
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
|
||||||
const hasReview = createMemo(() => reviewCount() > 0)
|
|
||||||
const diffsReady = createMemo(() => {
|
|
||||||
const id = params.id
|
|
||||||
if (!id) return true
|
|
||||||
if (!hasReview()) return true
|
|
||||||
return sync.data.session_diff[id] !== undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
const reviewEmptyKey = createMemo(() => {
|
|
||||||
if (sync.project && !sync.project.vcs) return "session.review.noVcs"
|
|
||||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
|
||||||
return "session.review.noChanges"
|
|
||||||
})
|
|
||||||
|
|
||||||
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
|
||||||
const kinds = createMemo(() => {
|
const kinds = createMemo(() => {
|
||||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||||
if (!a) return b
|
if (!a) return b
|
||||||
@@ -81,7 +69,7 @@ export function SessionSidePanel(props: {
|
|||||||
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
|
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
|
||||||
|
|
||||||
const out = new Map<string, "add" | "del" | "mix">()
|
const out = new Map<string, "add" | "del" | "mix">()
|
||||||
for (const diff of diffs()) {
|
for (const diff of props.diffs()) {
|
||||||
const file = normalize(diff.file)
|
const file = normalize(diff.file)
|
||||||
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
||||||
|
|
||||||
@@ -135,7 +123,7 @@ export function SessionSidePanel(props: {
|
|||||||
pathFromTab: file.pathFromTab,
|
pathFromTab: file.pathFromTab,
|
||||||
normalizeTab,
|
normalizeTab,
|
||||||
review: reviewTab,
|
review: reviewTab,
|
||||||
hasReview,
|
hasReview: props.canReview,
|
||||||
})
|
})
|
||||||
const contextOpen = tabState.contextOpen
|
const contextOpen = tabState.contextOpen
|
||||||
const openedTabs = tabState.openedTabs
|
const openedTabs = tabState.openedTabs
|
||||||
@@ -240,12 +228,12 @@ export function SessionSidePanel(props: {
|
|||||||
onCleanup(stop)
|
onCleanup(stop)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={reviewTab()}>
|
<Show when={reviewTab() && props.canReview()}>
|
||||||
<Tabs.Trigger value="review">
|
<Tabs.Trigger value="review">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<div>{language.t("session.tab.review")}</div>
|
<div>{language.t("session.tab.review")}</div>
|
||||||
<Show when={hasReview()}>
|
<Show when={props.hasReview()}>
|
||||||
<div>{reviewCount()}</div>
|
<div>{props.reviewCount()}</div>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
@@ -304,7 +292,7 @@ export function SessionSidePanel(props: {
|
|||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={reviewTab()}>
|
<Show when={reviewTab() && props.canReview()}>
|
||||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||||
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
|
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
@@ -378,8 +366,10 @@ export function SessionSidePanel(props: {
|
|||||||
>
|
>
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||||
{reviewCount()}{" "}
|
{props.reviewCount()}{" "}
|
||||||
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
|
{language.t(
|
||||||
|
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||||
|
)}
|
||||||
</Tabs.Trigger>
|
</Tabs.Trigger>
|
||||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||||
{language.t("session.files.all")}
|
{language.t("session.files.all")}
|
||||||
@@ -387,9 +377,9 @@ export function SessionSidePanel(props: {
|
|||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={hasReview()}>
|
<Match when={props.hasReview() || !props.diffsReady()}>
|
||||||
<Show
|
<Show
|
||||||
when={diffsReady()}
|
when={props.diffsReady()}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||||
{language.t("common.loading")}
|
{language.t("common.loading")}
|
||||||
@@ -408,11 +398,7 @@ export function SessionSidePanel(props: {
|
|||||||
/>
|
/>
|
||||||
</Show>
|
</Show>
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={true}>
|
<Match when={true}>{empty(props.empty())}</Match>
|
||||||
{empty(
|
|
||||||
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
|
|
||||||
)}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
</Switch>
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
|
import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
@@ -50,12 +51,8 @@ export function TerminalPanel() {
|
|||||||
const port = window.visualViewport
|
const port = window.visualViewport
|
||||||
|
|
||||||
sync()
|
sync()
|
||||||
window.addEventListener("resize", sync)
|
makeEventListener(window, "resize", sync)
|
||||||
port?.addEventListener("resize", sync)
|
if (port) makeEventListener(port, "resize", sync)
|
||||||
onCleanup(() => {
|
|
||||||
window.removeEventListener("resize", sync)
|
|
||||||
port?.removeEventListener("resize", sync)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -52,11 +52,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
if (!id) return
|
if (!id) return
|
||||||
return sync.session.get(id)
|
return sync.session.get(id)
|
||||||
}
|
}
|
||||||
const hasReview = () => {
|
const hasReview = () => !!params.id
|
||||||
const id = params.id
|
|
||||||
if (!id) return false
|
|
||||||
return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0
|
|
||||||
}
|
|
||||||
const normalizeTab = (tab: string) => {
|
const normalizeTab = (tab: string) => {
|
||||||
if (!tab.startsWith("file://")) return tab
|
if (!tab.startsWith("file://")) return tab
|
||||||
return file.tab(tab)
|
return file.tab(tab)
|
||||||
@@ -128,380 +124,452 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
|
if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
|
||||||
return permission.isAutoAcceptingDirectory(sdk.directory)
|
return permission.isAutoAcceptingDirectory(sdk.directory)
|
||||||
}
|
}
|
||||||
command.register("session", () => {
|
const write = async (value: string) => {
|
||||||
const share =
|
const body = typeof document === "undefined" ? undefined : document.body
|
||||||
sync.data.config.share === "disabled"
|
if (body) {
|
||||||
? []
|
const textarea = document.createElement("textarea")
|
||||||
: [
|
textarea.value = value
|
||||||
sessionCommand({
|
textarea.setAttribute("readonly", "")
|
||||||
id: "session.share",
|
textarea.style.position = "fixed"
|
||||||
title: info()?.share?.url
|
textarea.style.opacity = "0"
|
||||||
? language.t("session.share.copy.copyLink")
|
textarea.style.pointerEvents = "none"
|
||||||
: language.t("command.session.share"),
|
body.appendChild(textarea)
|
||||||
description: info()?.share?.url
|
textarea.select()
|
||||||
? language.t("toast.session.share.success.description")
|
const copied = document.execCommand("copy")
|
||||||
: language.t("command.session.share.description"),
|
body.removeChild(textarea)
|
||||||
slash: "share",
|
if (copied) return true
|
||||||
disabled: !params.id,
|
}
|
||||||
onSelect: async () => {
|
|
||||||
if (!params.id) return
|
|
||||||
|
|
||||||
const write = (value: string) => {
|
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
|
||||||
const body = typeof document === "undefined" ? undefined : document.body
|
if (!clipboard?.writeText) return false
|
||||||
if (body) {
|
return clipboard.writeText(value).then(
|
||||||
const textarea = document.createElement("textarea")
|
() => true,
|
||||||
textarea.value = value
|
() => false,
|
||||||
textarea.setAttribute("readonly", "")
|
)
|
||||||
textarea.style.position = "fixed"
|
}
|
||||||
textarea.style.opacity = "0"
|
|
||||||
textarea.style.pointerEvents = "none"
|
|
||||||
body.appendChild(textarea)
|
|
||||||
textarea.select()
|
|
||||||
const copied = document.execCommand("copy")
|
|
||||||
body.removeChild(textarea)
|
|
||||||
if (copied) return Promise.resolve(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
|
const copyShare = async (url: string, existing: boolean) => {
|
||||||
if (!clipboard?.writeText) return Promise.resolve(false)
|
if (!(await write(url))) {
|
||||||
return clipboard.writeText(value).then(
|
showToast({
|
||||||
() => true,
|
title: language.t("toast.session.share.copyFailed.title"),
|
||||||
() => false,
|
variant: "error",
|
||||||
)
|
})
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const copy = async (url: string, existing: boolean) => {
|
showToast({
|
||||||
const ok = await write(url)
|
title: existing ? language.t("session.share.copy.copied") : language.t("toast.session.share.success.title"),
|
||||||
if (!ok) {
|
description: language.t("toast.session.share.success.description"),
|
||||||
showToast({
|
variant: "success",
|
||||||
title: language.t("toast.session.share.copyFailed.title"),
|
})
|
||||||
variant: "error",
|
}
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast({
|
const share = async () => {
|
||||||
title: existing
|
const sessionID = params.id
|
||||||
? language.t("session.share.copy.copied")
|
if (!sessionID) return
|
||||||
: language.t("toast.session.share.success.title"),
|
|
||||||
description: language.t("toast.session.share.success.description"),
|
|
||||||
variant: "success",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = info()?.share?.url
|
const existing = info()?.share?.url
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await copy(existing, true)
|
await copyShare(existing, true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = await sdk.client.session
|
const url = await sdk.client.session
|
||||||
.share({ sessionID: params.id })
|
.share({ sessionID })
|
||||||
.then((res) => res.data?.share?.url)
|
.then((res) => res.data?.share?.url)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
if (!url) {
|
if (!url) {
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("toast.session.share.failed.title"),
|
title: language.t("toast.session.share.failed.title"),
|
||||||
description: language.t("toast.session.share.failed.description"),
|
description: language.t("toast.session.share.failed.description"),
|
||||||
variant: "error",
|
variant: "error",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await copy(url, false)
|
await copyShare(url, false)
|
||||||
},
|
}
|
||||||
}),
|
|
||||||
sessionCommand({
|
|
||||||
id: "session.unshare",
|
|
||||||
title: language.t("command.session.unshare"),
|
|
||||||
description: language.t("command.session.unshare.description"),
|
|
||||||
slash: "unshare",
|
|
||||||
disabled: !params.id || !info()?.share?.url,
|
|
||||||
onSelect: async () => {
|
|
||||||
if (!params.id) return
|
|
||||||
await sdk.client.session
|
|
||||||
.unshare({ sessionID: params.id })
|
|
||||||
.then(() =>
|
|
||||||
showToast({
|
|
||||||
title: language.t("toast.session.unshare.success.title"),
|
|
||||||
description: language.t("toast.session.unshare.success.description"),
|
|
||||||
variant: "success",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.catch(() =>
|
|
||||||
showToast({
|
|
||||||
title: language.t("toast.session.unshare.failed.title"),
|
|
||||||
description: language.t("toast.session.unshare.failed.description"),
|
|
||||||
variant: "error",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
const unshare = async () => {
|
||||||
|
const sessionID = params.id
|
||||||
|
if (!sessionID) return
|
||||||
|
|
||||||
|
await sdk.client.session
|
||||||
|
.unshare({ sessionID })
|
||||||
|
.then(() =>
|
||||||
|
showToast({
|
||||||
|
title: language.t("toast.session.unshare.success.title"),
|
||||||
|
description: language.t("toast.session.unshare.success.description"),
|
||||||
|
variant: "success",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch(() =>
|
||||||
|
showToast({
|
||||||
|
title: language.t("toast.session.unshare.failed.title"),
|
||||||
|
description: language.t("toast.session.unshare.failed.description"),
|
||||||
|
variant: "error",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openFile = () => {
|
||||||
|
void import("@/components/dialog-select-file").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeTab = () => {
|
||||||
|
const tab = closableTab()
|
||||||
|
if (!tab) return
|
||||||
|
tabs().close(tab)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSelection = () => {
|
||||||
|
const tab = activeFileTab()
|
||||||
|
if (!tab) return
|
||||||
|
|
||||||
|
const path = file.pathFromTab(tab)
|
||||||
|
if (!path) return
|
||||||
|
|
||||||
|
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
|
||||||
|
if (!range) {
|
||||||
|
showToast({
|
||||||
|
title: language.t("toast.context.noLineSelection.title"),
|
||||||
|
description: language.t("toast.context.noLineSelection.description"),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addSelectionToContext(path, selectionFromLines(range))
|
||||||
|
}
|
||||||
|
|
||||||
|
const openTerminal = () => {
|
||||||
|
if (terminal.all().length > 0) terminal.new()
|
||||||
|
view().terminal.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
const chooseModel = () => {
|
||||||
|
void import("@/components/dialog-select-model").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogSelectModel model={local.model} />)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const chooseMcp = () => {
|
||||||
|
void import("@/components/dialog-select-mcp").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogSelectMcp />)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleAutoAccept = () => {
|
||||||
|
const sessionID = params.id
|
||||||
|
if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
|
||||||
|
else permission.toggleAutoAcceptDirectory(sdk.directory)
|
||||||
|
|
||||||
|
const active = sessionID
|
||||||
|
? permission.isAutoAccepting(sessionID, sdk.directory)
|
||||||
|
: permission.isAutoAcceptingDirectory(sdk.directory)
|
||||||
|
showToast({
|
||||||
|
title: active
|
||||||
|
? language.t("toast.permissions.autoaccept.on.title")
|
||||||
|
: language.t("toast.permissions.autoaccept.off.title"),
|
||||||
|
description: active
|
||||||
|
? language.t("toast.permissions.autoaccept.on.description")
|
||||||
|
: language.t("toast.permissions.autoaccept.off.description"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const undo = async () => {
|
||||||
|
const sessionID = params.id
|
||||||
|
if (!sessionID) return
|
||||||
|
|
||||||
|
if (status().type !== "idle") {
|
||||||
|
await sdk.client.session.abort({ sessionID }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const revert = info()?.revert?.messageID
|
||||||
|
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
|
||||||
|
if (!message) return
|
||||||
|
|
||||||
|
await sdk.client.session.revert({ sessionID, messageID: message.id })
|
||||||
|
const parts = sync.data.part[message.id]
|
||||||
|
if (parts) {
|
||||||
|
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
|
||||||
|
prompt.set(restored)
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = findLast(userMessages(), (x) => x.id < message.id)
|
||||||
|
setActiveMessage(prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
const redo = async () => {
|
||||||
|
const sessionID = params.id
|
||||||
|
if (!sessionID) return
|
||||||
|
|
||||||
|
const revertMessageID = info()?.revert?.messageID
|
||||||
|
if (!revertMessageID) return
|
||||||
|
|
||||||
|
const next = userMessages().find((x) => x.id > revertMessageID)
|
||||||
|
if (!next) {
|
||||||
|
await sdk.client.session.unrevert({ sessionID })
|
||||||
|
prompt.reset()
|
||||||
|
const last = findLast(userMessages(), (x) => x.id >= revertMessageID)
|
||||||
|
setActiveMessage(last)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await sdk.client.session.revert({ sessionID, messageID: next.id })
|
||||||
|
const prev = findLast(userMessages(), (x) => x.id < next.id)
|
||||||
|
setActiveMessage(prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
const compact = async () => {
|
||||||
|
const sessionID = params.id
|
||||||
|
if (!sessionID) return
|
||||||
|
|
||||||
|
const model = local.model.current()
|
||||||
|
if (!model) {
|
||||||
|
showToast({
|
||||||
|
title: language.t("toast.model.none.title"),
|
||||||
|
description: language.t("toast.model.none.description"),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await sdk.client.session.summarize({
|
||||||
|
sessionID,
|
||||||
|
modelID: model.id,
|
||||||
|
providerID: model.provider.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const fork = () => {
|
||||||
|
void import("@/components/dialog-fork").then((x) => {
|
||||||
|
dialog.show(() => <x.DialogFork />)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareCmds = () => {
|
||||||
|
if (sync.data.config.share === "disabled") return []
|
||||||
return [
|
return [
|
||||||
sessionCommand({
|
sessionCommand({
|
||||||
id: "session.new",
|
id: "session.share",
|
||||||
title: language.t("command.session.new"),
|
title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
|
||||||
keybind: "mod+shift+s",
|
description: info()?.share?.url
|
||||||
slash: "new",
|
? language.t("toast.session.share.success.description")
|
||||||
onSelect: () => navigate(`/${params.dir}/session`),
|
: language.t("command.session.share.description"),
|
||||||
}),
|
slash: "share",
|
||||||
fileCommand({
|
|
||||||
id: "file.open",
|
|
||||||
title: language.t("command.file.open"),
|
|
||||||
description: language.t("palette.search.placeholder"),
|
|
||||||
keybind: "mod+k,mod+p",
|
|
||||||
slash: "open",
|
|
||||||
onSelect: () => {
|
|
||||||
void import("@/components/dialog-select-file").then((x) => {
|
|
||||||
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
fileCommand({
|
|
||||||
id: "tab.close",
|
|
||||||
title: language.t("command.tab.close"),
|
|
||||||
keybind: "mod+w",
|
|
||||||
disabled: !closableTab(),
|
|
||||||
onSelect: () => {
|
|
||||||
const tab = closableTab()
|
|
||||||
if (!tab) return
|
|
||||||
tabs().close(tab)
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
contextCommand({
|
|
||||||
id: "context.addSelection",
|
|
||||||
title: language.t("command.context.addSelection"),
|
|
||||||
description: language.t("command.context.addSelection.description"),
|
|
||||||
keybind: "mod+shift+l",
|
|
||||||
disabled: !canAddSelectionContext(),
|
|
||||||
onSelect: () => {
|
|
||||||
const tab = activeFileTab()
|
|
||||||
if (!tab) return
|
|
||||||
const path = file.pathFromTab(tab)
|
|
||||||
if (!path) return
|
|
||||||
|
|
||||||
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
|
|
||||||
if (!range) {
|
|
||||||
showToast({
|
|
||||||
title: language.t("toast.context.noLineSelection.title"),
|
|
||||||
description: language.t("toast.context.noLineSelection.description"),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
addSelectionToContext(path, selectionFromLines(range))
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
viewCommand({
|
|
||||||
id: "terminal.toggle",
|
|
||||||
title: language.t("command.terminal.toggle"),
|
|
||||||
keybind: "ctrl+`",
|
|
||||||
slash: "terminal",
|
|
||||||
onSelect: () => view().terminal.toggle(),
|
|
||||||
}),
|
|
||||||
viewCommand({
|
|
||||||
id: "review.toggle",
|
|
||||||
title: language.t("command.review.toggle"),
|
|
||||||
keybind: "mod+shift+r",
|
|
||||||
onSelect: () => view().reviewPanel.toggle(),
|
|
||||||
}),
|
|
||||||
viewCommand({
|
|
||||||
id: "fileTree.toggle",
|
|
||||||
title: language.t("command.fileTree.toggle"),
|
|
||||||
keybind: "mod+\\",
|
|
||||||
onSelect: () => layout.fileTree.toggle(),
|
|
||||||
}),
|
|
||||||
viewCommand({
|
|
||||||
id: "input.focus",
|
|
||||||
title: language.t("command.input.focus"),
|
|
||||||
keybind: "ctrl+l",
|
|
||||||
onSelect: focusInput,
|
|
||||||
}),
|
|
||||||
terminalCommand({
|
|
||||||
id: "terminal.new",
|
|
||||||
title: language.t("command.terminal.new"),
|
|
||||||
description: language.t("command.terminal.new.description"),
|
|
||||||
keybind: "ctrl+alt+t",
|
|
||||||
onSelect: () => {
|
|
||||||
if (terminal.all().length > 0) terminal.new()
|
|
||||||
view().terminal.open()
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
sessionCommand({
|
|
||||||
id: "message.previous",
|
|
||||||
title: language.t("command.message.previous"),
|
|
||||||
description: language.t("command.message.previous.description"),
|
|
||||||
keybind: "mod+alt+[",
|
|
||||||
disabled: !params.id,
|
disabled: !params.id,
|
||||||
onSelect: () => navigateMessageByOffset(-1),
|
onSelect: share,
|
||||||
}),
|
}),
|
||||||
sessionCommand({
|
sessionCommand({
|
||||||
id: "message.next",
|
id: "session.unshare",
|
||||||
title: language.t("command.message.next"),
|
title: language.t("command.session.unshare"),
|
||||||
description: language.t("command.message.next.description"),
|
description: language.t("command.session.unshare.description"),
|
||||||
keybind: "mod+alt+]",
|
slash: "unshare",
|
||||||
disabled: !params.id,
|
disabled: !params.id || !info()?.share?.url,
|
||||||
onSelect: () => navigateMessageByOffset(1),
|
onSelect: unshare,
|
||||||
}),
|
}),
|
||||||
modelCommand({
|
|
||||||
id: "model.choose",
|
|
||||||
title: language.t("command.model.choose"),
|
|
||||||
description: language.t("command.model.choose.description"),
|
|
||||||
keybind: "mod+'",
|
|
||||||
slash: "model",
|
|
||||||
onSelect: () => {
|
|
||||||
void import("@/components/dialog-select-model").then((x) => {
|
|
||||||
dialog.show(() => <x.DialogSelectModel model={local.model} />)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
mcpCommand({
|
|
||||||
id: "mcp.toggle",
|
|
||||||
title: language.t("command.mcp.toggle"),
|
|
||||||
description: language.t("command.mcp.toggle.description"),
|
|
||||||
keybind: "mod+;",
|
|
||||||
slash: "mcp",
|
|
||||||
onSelect: () => {
|
|
||||||
void import("@/components/dialog-select-mcp").then((x) => {
|
|
||||||
dialog.show(() => <x.DialogSelectMcp />)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
agentCommand({
|
|
||||||
id: "agent.cycle",
|
|
||||||
title: language.t("command.agent.cycle"),
|
|
||||||
description: language.t("command.agent.cycle.description"),
|
|
||||||
keybind: "mod+.",
|
|
||||||
slash: "agent",
|
|
||||||
onSelect: () => local.agent.move(1),
|
|
||||||
}),
|
|
||||||
agentCommand({
|
|
||||||
id: "agent.cycle.reverse",
|
|
||||||
title: language.t("command.agent.cycle.reverse"),
|
|
||||||
description: language.t("command.agent.cycle.reverse.description"),
|
|
||||||
keybind: "shift+mod+.",
|
|
||||||
onSelect: () => local.agent.move(-1),
|
|
||||||
}),
|
|
||||||
modelCommand({
|
|
||||||
id: "model.variant.cycle",
|
|
||||||
title: language.t("command.model.variant.cycle"),
|
|
||||||
description: language.t("command.model.variant.cycle.description"),
|
|
||||||
keybind: "shift+mod+d",
|
|
||||||
onSelect: () => local.model.variant.cycle(),
|
|
||||||
}),
|
|
||||||
permissionsCommand({
|
|
||||||
id: "permissions.autoaccept",
|
|
||||||
title: isAutoAcceptActive()
|
|
||||||
? language.t("command.permissions.autoaccept.disable")
|
|
||||||
: language.t("command.permissions.autoaccept.enable"),
|
|
||||||
keybind: "mod+shift+a",
|
|
||||||
disabled: false,
|
|
||||||
onSelect: () => {
|
|
||||||
const sessionID = params.id
|
|
||||||
if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
|
|
||||||
else permission.toggleAutoAcceptDirectory(sdk.directory)
|
|
||||||
|
|
||||||
const active = sessionID
|
|
||||||
? permission.isAutoAccepting(sessionID, sdk.directory)
|
|
||||||
: permission.isAutoAcceptingDirectory(sdk.directory)
|
|
||||||
showToast({
|
|
||||||
title: active
|
|
||||||
? language.t("toast.permissions.autoaccept.on.title")
|
|
||||||
: language.t("toast.permissions.autoaccept.off.title"),
|
|
||||||
description: active
|
|
||||||
? language.t("toast.permissions.autoaccept.on.description")
|
|
||||||
: language.t("toast.permissions.autoaccept.off.description"),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
sessionCommand({
|
|
||||||
id: "session.undo",
|
|
||||||
title: language.t("command.session.undo"),
|
|
||||||
description: language.t("command.session.undo.description"),
|
|
||||||
slash: "undo",
|
|
||||||
disabled: !params.id || visibleUserMessages().length === 0,
|
|
||||||
onSelect: async () => {
|
|
||||||
const sessionID = params.id
|
|
||||||
if (!sessionID) return
|
|
||||||
if (status().type !== "idle") {
|
|
||||||
await sdk.client.session.abort({ sessionID }).catch(() => {})
|
|
||||||
}
|
|
||||||
const revert = info()?.revert?.messageID
|
|
||||||
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
|
|
||||||
if (!message) return
|
|
||||||
await sdk.client.session.revert({ sessionID, messageID: message.id })
|
|
||||||
const parts = sync.data.part[message.id]
|
|
||||||
if (parts) {
|
|
||||||
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
|
|
||||||
prompt.set(restored)
|
|
||||||
}
|
|
||||||
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
|
|
||||||
setActiveMessage(priorMessage)
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
sessionCommand({
|
|
||||||
id: "session.redo",
|
|
||||||
title: language.t("command.session.redo"),
|
|
||||||
description: language.t("command.session.redo.description"),
|
|
||||||
slash: "redo",
|
|
||||||
disabled: !params.id || !info()?.revert?.messageID,
|
|
||||||
onSelect: async () => {
|
|
||||||
const sessionID = params.id
|
|
||||||
if (!sessionID) return
|
|
||||||
const revertMessageID = info()?.revert?.messageID
|
|
||||||
if (!revertMessageID) return
|
|
||||||
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
|
|
||||||
if (!nextMessage) {
|
|
||||||
await sdk.client.session.unrevert({ sessionID })
|
|
||||||
prompt.reset()
|
|
||||||
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
|
|
||||||
setActiveMessage(lastMsg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
|
|
||||||
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
|
|
||||||
setActiveMessage(priorMsg)
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
sessionCommand({
|
|
||||||
id: "session.compact",
|
|
||||||
title: language.t("command.session.compact"),
|
|
||||||
description: language.t("command.session.compact.description"),
|
|
||||||
slash: "compact",
|
|
||||||
disabled: !params.id || visibleUserMessages().length === 0,
|
|
||||||
onSelect: async () => {
|
|
||||||
const sessionID = params.id
|
|
||||||
if (!sessionID) return
|
|
||||||
const model = local.model.current()
|
|
||||||
if (!model) {
|
|
||||||
showToast({
|
|
||||||
title: language.t("toast.model.none.title"),
|
|
||||||
description: language.t("toast.model.none.description"),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await sdk.client.session.summarize({
|
|
||||||
sessionID,
|
|
||||||
modelID: model.id,
|
|
||||||
providerID: model.provider.id,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
sessionCommand({
|
|
||||||
id: "session.fork",
|
|
||||||
title: language.t("command.session.fork"),
|
|
||||||
description: language.t("command.session.fork.description"),
|
|
||||||
slash: "fork",
|
|
||||||
disabled: !params.id || visibleUserMessages().length === 0,
|
|
||||||
onSelect: () => {
|
|
||||||
void import("@/components/dialog-fork").then((x) => {
|
|
||||||
dialog.show(() => <x.DialogFork />)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
...share,
|
|
||||||
]
|
]
|
||||||
})
|
}
|
||||||
|
|
||||||
|
const sessionCmds = () => [
|
||||||
|
sessionCommand({
|
||||||
|
id: "session.new",
|
||||||
|
title: language.t("command.session.new"),
|
||||||
|
keybind: "mod+shift+s",
|
||||||
|
slash: "new",
|
||||||
|
onSelect: () => navigate(`/${params.dir}/session`),
|
||||||
|
}),
|
||||||
|
sessionCommand({
|
||||||
|
id: "session.undo",
|
||||||
|
title: language.t("command.session.undo"),
|
||||||
|
description: language.t("command.session.undo.description"),
|
||||||
|
slash: "undo",
|
||||||
|
disabled: !params.id || visibleUserMessages().length === 0,
|
||||||
|
onSelect: undo,
|
||||||
|
}),
|
||||||
|
sessionCommand({
|
||||||
|
id: "session.redo",
|
||||||
|
title: language.t("command.session.redo"),
|
||||||
|
description: language.t("command.session.redo.description"),
|
||||||
|
slash: "redo",
|
||||||
|
disabled: !params.id || !info()?.revert?.messageID,
|
||||||
|
onSelect: redo,
|
||||||
|
}),
|
||||||
|
sessionCommand({
|
||||||
|
id: "session.compact",
|
||||||
|
title: language.t("command.session.compact"),
|
||||||
|
description: language.t("command.session.compact.description"),
|
||||||
|
slash: "compact",
|
||||||
|
disabled: !params.id || visibleUserMessages().length === 0,
|
||||||
|
onSelect: compact,
|
||||||
|
}),
|
||||||
|
sessionCommand({
|
||||||
|
id: "session.fork",
|
||||||
|
title: language.t("command.session.fork"),
|
||||||
|
description: language.t("command.session.fork.description"),
|
||||||
|
slash: "fork",
|
||||||
|
disabled: !params.id || visibleUserMessages().length === 0,
|
||||||
|
onSelect: fork,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
const fileCmds = () => [
|
||||||
|
fileCommand({
|
||||||
|
id: "file.open",
|
||||||
|
title: language.t("command.file.open"),
|
||||||
|
description: language.t("palette.search.placeholder"),
|
||||||
|
keybind: "mod+k,mod+p",
|
||||||
|
slash: "open",
|
||||||
|
onSelect: openFile,
|
||||||
|
}),
|
||||||
|
fileCommand({
|
||||||
|
id: "tab.close",
|
||||||
|
title: language.t("command.tab.close"),
|
||||||
|
keybind: "mod+w",
|
||||||
|
disabled: !closableTab(),
|
||||||
|
onSelect: closeTab,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
const contextCmds = () => [
|
||||||
|
contextCommand({
|
||||||
|
id: "context.addSelection",
|
||||||
|
title: language.t("command.context.addSelection"),
|
||||||
|
description: language.t("command.context.addSelection.description"),
|
||||||
|
keybind: "mod+shift+l",
|
||||||
|
disabled: !canAddSelectionContext(),
|
||||||
|
onSelect: addSelection,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
const viewCmds = () => [
|
||||||
|
viewCommand({
|
||||||
|
id: "terminal.toggle",
|
||||||
|
title: language.t("command.terminal.toggle"),
|
||||||
|
keybind: "ctrl+`",
|
||||||
|
slash: "terminal",
|
||||||
|
onSelect: () => view().terminal.toggle(),
|
||||||
|
}),
|
||||||
|
viewCommand({
|
||||||
|
id: "review.toggle",
|
||||||
|
title: language.t("command.review.toggle"),
|
||||||
|
keybind: "mod+shift+r",
|
||||||
|
onSelect: () => view().reviewPanel.toggle(),
|
||||||
|
}),
|
||||||
|
viewCommand({
|
||||||
|
id: "fileTree.toggle",
|
||||||
|
title: language.t("command.fileTree.toggle"),
|
||||||
|
keybind: "mod+\\",
|
||||||
|
onSelect: () => layout.fileTree.toggle(),
|
||||||
|
}),
|
||||||
|
viewCommand({
|
||||||
|
id: "input.focus",
|
||||||
|
title: language.t("command.input.focus"),
|
||||||
|
keybind: "ctrl+l",
|
||||||
|
onSelect: focusInput,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
const terminalCmds = () => [
|
||||||
|
terminalCommand({
|
||||||
|
id: "terminal.new",
|
||||||
|
title: language.t("command.terminal.new"),
|
||||||
|
description: language.t("command.terminal.new.description"),
|
||||||
|
keybind: "ctrl+alt+t",
|
||||||
|
onSelect: openTerminal,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
const messageCmds = () => [
|
||||||
|
sessionCommand({
|
||||||
|
id: "message.previous",
|
||||||
|
title: language.t("command.message.previous"),
|
||||||
|
description: language.t("command.message.previous.description"),
|
||||||
|
keybind: "mod+alt+[",
|
||||||
|
disabled: !params.id,
|
||||||
|
onSelect: () => navigateMessageByOffset(-1),
|
||||||
|
}),
|
||||||
|
sessionCommand({
|
||||||
|
id: "message.next",
|
||||||
|
title: language.t("command.message.next"),
|
||||||
|
description: language.t("command.message.next.description"),
|
||||||
|
keybind: "mod+alt+]",
|
||||||
|
disabled: !params.id,
|
||||||
|
onSelect: () => navigateMessageByOffset(1),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
const modelCmds = () => [
|
||||||
|
modelCommand({
|
||||||
|
id: "model.choose",
|
||||||
|
title: language.t("command.model.choose"),
|
||||||
|
description: language.t("command.model.choose.description"),
|
||||||
|
keybind: "mod+'",
|
||||||
|
slash: "model",
|
||||||
|
onSelect: chooseModel,
|
||||||
|
}),
|
||||||
|
modelCommand({
|
||||||
|
id: "model.variant.cycle",
|
||||||
|
title: language.t("command.model.variant.cycle"),
|
||||||
|
description: language.t("command.model.variant.cycle.description"),
|
||||||
|
keybind: "shift+mod+d",
|
||||||
|
onSelect: () => local.model.variant.cycle(),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
const mcpCmds = () => [
|
||||||
|
mcpCommand({
|
||||||
|
id: "mcp.toggle",
|
||||||
|
title: language.t("command.mcp.toggle"),
|
||||||
|
description: language.t("command.mcp.toggle.description"),
|
||||||
|
keybind: "mod+;",
|
||||||
|
slash: "mcp",
|
||||||
|
onSelect: chooseMcp,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
const agentCmds = () => [
|
||||||
|
agentCommand({
|
||||||
|
id: "agent.cycle",
|
||||||
|
title: language.t("command.agent.cycle"),
|
||||||
|
description: language.t("command.agent.cycle.description"),
|
||||||
|
keybind: "mod+.",
|
||||||
|
slash: "agent",
|
||||||
|
onSelect: () => local.agent.move(1),
|
||||||
|
}),
|
||||||
|
agentCommand({
|
||||||
|
id: "agent.cycle.reverse",
|
||||||
|
title: language.t("command.agent.cycle.reverse"),
|
||||||
|
description: language.t("command.agent.cycle.reverse.description"),
|
||||||
|
keybind: "shift+mod+.",
|
||||||
|
onSelect: () => local.agent.move(-1),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
const permissionsCmds = () => [
|
||||||
|
permissionsCommand({
|
||||||
|
id: "permissions.autoaccept",
|
||||||
|
title: isAutoAcceptActive()
|
||||||
|
? language.t("command.permissions.autoaccept.disable")
|
||||||
|
: language.t("command.permissions.autoaccept.enable"),
|
||||||
|
keybind: "mod+shift+a",
|
||||||
|
disabled: false,
|
||||||
|
onSelect: toggleAutoAccept,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
command.register("session", () => [
|
||||||
|
...sessionCmds(),
|
||||||
|
...shareCmds(),
|
||||||
|
...fileCmds(),
|
||||||
|
...contextCmds(),
|
||||||
|
...viewCmds(),
|
||||||
|
...terminalCmds(),
|
||||||
|
...messageCmds(),
|
||||||
|
...modelCmds(),
|
||||||
|
...mcpCmds(),
|
||||||
|
...agentCmds(),
|
||||||
|
...permissionsCmds(),
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ export type PromptProbeState = {
|
|||||||
selects: number
|
selects: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PromptSendState = {
|
||||||
|
started: number
|
||||||
|
count: number
|
||||||
|
sessionID?: string
|
||||||
|
directory?: string
|
||||||
|
}
|
||||||
|
|
||||||
export const promptEnabled = () => {
|
export const promptEnabled = () => {
|
||||||
if (typeof window === "undefined") return false
|
if (typeof window === "undefined") return false
|
||||||
return (window as E2EWindow).__opencode_e2e?.prompt?.enabled === true
|
return (window as E2EWindow).__opencode_e2e?.prompt?.enabled === true
|
||||||
@@ -53,4 +60,24 @@ export const promptProbe = {
|
|||||||
if (!state) return
|
if (!state) return
|
||||||
state.current = undefined
|
state.current = undefined
|
||||||
},
|
},
|
||||||
|
start() {
|
||||||
|
const state = root()
|
||||||
|
if (!state) return
|
||||||
|
state.sent = {
|
||||||
|
started: (state.sent?.started ?? 0) + 1,
|
||||||
|
count: state.sent?.count ?? 0,
|
||||||
|
sessionID: state.sent?.sessionID,
|
||||||
|
directory: state.sent?.directory,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
submit(input: { sessionID: string; directory: string }) {
|
||||||
|
const state = root()
|
||||||
|
if (!state) return
|
||||||
|
state.sent = {
|
||||||
|
started: state.sent?.started ?? 0,
|
||||||
|
count: (state.sent?.count ?? 0) + 1,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
directory: input.directory,
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export type E2EWindow = Window & {
|
|||||||
prompt?: {
|
prompt?: {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
current?: import("./prompt").PromptProbeState
|
current?: import("./prompt").PromptProbeState
|
||||||
|
sent?: import("./prompt").PromptSendState
|
||||||
}
|
}
|
||||||
terminal?: {
|
terminal?: {
|
||||||
enabled?: boolean
|
enabled?: boolean
|
||||||
|
|||||||
@@ -5,9 +5,30 @@ const defaults: Record<string, string> = {
|
|||||||
plan: "var(--icon-agent-plan-base)",
|
plan: "var(--icon-agent-plan-base)",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const palette = [
|
||||||
|
"var(--icon-agent-ask-base)",
|
||||||
|
"var(--icon-agent-build-base)",
|
||||||
|
"var(--icon-agent-docs-base)",
|
||||||
|
"var(--icon-agent-plan-base)",
|
||||||
|
"var(--syntax-info)",
|
||||||
|
"var(--syntax-success)",
|
||||||
|
"var(--syntax-warning)",
|
||||||
|
"var(--syntax-property)",
|
||||||
|
"var(--syntax-constant)",
|
||||||
|
"var(--text-diff-add-base)",
|
||||||
|
"var(--text-diff-delete-base)",
|
||||||
|
"var(--icon-warning-base)",
|
||||||
|
]
|
||||||
|
|
||||||
|
function tone(name: string) {
|
||||||
|
let hash = 0
|
||||||
|
for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0
|
||||||
|
return palette[hash % palette.length]
|
||||||
|
}
|
||||||
|
|
||||||
export function agentColor(name: string, custom?: string) {
|
export function agentColor(name: string, custom?: string) {
|
||||||
if (custom) return custom
|
if (custom) return custom
|
||||||
return defaults[name] ?? defaults[name.toLowerCase()]
|
return defaults[name] ?? defaults[name.toLowerCase()] ?? tone(name.toLowerCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
export function messageAgentColor(
|
export function messageAgentColor(
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
||||||
|
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { diffs, message } from "./diffs"
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
file: "src/app.ts",
|
||||||
|
patch: "@@ -1 +1 @@\n-old\n+new\n",
|
||||||
|
additions: 1,
|
||||||
|
deletions: 1,
|
||||||
|
status: "modified",
|
||||||
|
} satisfies SnapshotFileDiff
|
||||||
|
|
||||||
|
describe("diffs", () => {
|
||||||
|
test("keeps valid arrays", () => {
|
||||||
|
expect(diffs([item])).toEqual([item])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("wraps a single diff object", () => {
|
||||||
|
expect(diffs(item)).toEqual([item])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reads keyed diff objects", () => {
|
||||||
|
expect(diffs({ a: item })).toEqual([item])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("drops invalid entries", () => {
|
||||||
|
expect(
|
||||||
|
diffs([
|
||||||
|
item,
|
||||||
|
{ file: "src/bad.ts", additions: 1, deletions: 1 },
|
||||||
|
{ patch: item.patch, additions: 1, deletions: 1 },
|
||||||
|
]),
|
||||||
|
).toEqual([item])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("message", () => {
|
||||||
|
test("normalizes user summaries with object diffs", () => {
|
||||||
|
const input = {
|
||||||
|
id: "msg_1",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1 },
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "openai", modelID: "gpt-5" },
|
||||||
|
summary: {
|
||||||
|
title: "Edit",
|
||||||
|
diffs: { a: item },
|
||||||
|
},
|
||||||
|
} as unknown as Message
|
||||||
|
|
||||||
|
expect(message(input)).toMatchObject({
|
||||||
|
summary: {
|
||||||
|
title: "Edit",
|
||||||
|
diffs: [item],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("drops invalid user summaries", () => {
|
||||||
|
const input = {
|
||||||
|
id: "msg_1",
|
||||||
|
sessionID: "ses_1",
|
||||||
|
role: "user",
|
||||||
|
time: { created: 1 },
|
||||||
|
agent: "build",
|
||||||
|
model: { providerID: "openai", modelID: "gpt-5" },
|
||||||
|
summary: true,
|
||||||
|
} as unknown as Message
|
||||||
|
|
||||||
|
expect(message(input)).toMatchObject({ summary: undefined })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||||
|
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
||||||
|
type Diff = SnapshotFileDiff | VcsFileDiff
|
||||||
|
|
||||||
|
function diff(value: unknown): value is Diff {
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false
|
||||||
|
if (!("file" in value) || typeof value.file !== "string") return false
|
||||||
|
if (!("patch" in value) || typeof value.patch !== "string") return false
|
||||||
|
if (!("additions" in value) || typeof value.additions !== "number") return false
|
||||||
|
if (!("deletions" in value) || typeof value.deletions !== "number") return false
|
||||||
|
if (!("status" in value) || value.status === undefined) return true
|
||||||
|
return value.status === "added" || value.status === "deleted" || value.status === "modified"
|
||||||
|
}
|
||||||
|
|
||||||
|
function object(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function diffs(value: unknown): Diff[] {
|
||||||
|
if (Array.isArray(value) && value.every(diff)) return value
|
||||||
|
if (Array.isArray(value)) return value.filter(diff)
|
||||||
|
if (diff(value)) return [value]
|
||||||
|
if (!object(value)) return []
|
||||||
|
return Object.values(value).filter(diff)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function message(value: Message): Message {
|
||||||
|
if (value.role !== "user") return value
|
||||||
|
|
||||||
|
const raw = value.summary as unknown
|
||||||
|
if (raw === undefined) return value
|
||||||
|
if (!object(raw)) return { ...value, summary: undefined }
|
||||||
|
|
||||||
|
const title = typeof raw.title === "string" ? raw.title : undefined
|
||||||
|
const body = typeof raw.body === "string" ? raw.body : undefined
|
||||||
|
const next = diffs(raw.diffs)
|
||||||
|
|
||||||
|
if (title === raw.title && body === raw.body && next === raw.diffs) return value
|
||||||
|
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
summary: {
|
||||||
|
...(title === undefined ? {} : { title }),
|
||||||
|
...(body === undefined ? {} : { body }),
|
||||||
|
diffs: next,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
||||||
|
|
||||||
|
export function sessionTitle(title?: string) {
|
||||||
|
if (!title) return title
|
||||||
|
const match = title.match(pattern)
|
||||||
|
return match?.[1] ?? title
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { bodyText, inputMatch, promptMatch } from "../../e2e/prompt/mock"
|
||||||
|
|
||||||
|
function hit(body: Record<string, unknown>) {
|
||||||
|
return { body }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("promptMatch", () => {
|
||||||
|
test("matches token in serialized body", () => {
|
||||||
|
const match = promptMatch("hello")
|
||||||
|
expect(match(hit({ messages: [{ role: "user", content: "say hello" }] }))).toBe(true)
|
||||||
|
expect(match(hit({ messages: [{ role: "user", content: "say goodbye" }] }))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("inputMatch", () => {
|
||||||
|
test("matches exact tool input in chat completions body", () => {
|
||||||
|
const input = { questions: [{ header: "Need input", question: "Pick one" }] }
|
||||||
|
const match = inputMatch(input)
|
||||||
|
|
||||||
|
// The seed prompt embeds JSON.stringify(input) in the user message
|
||||||
|
const prompt = `Use this JSON input: ${JSON.stringify(input)}`
|
||||||
|
const body = { messages: [{ role: "user", content: prompt }] }
|
||||||
|
expect(match(hit(body))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matches exact tool input in responses API body", () => {
|
||||||
|
const input = { questions: [{ header: "Need input", question: "Pick one" }] }
|
||||||
|
const match = inputMatch(input)
|
||||||
|
|
||||||
|
const prompt = `Use this JSON input: ${JSON.stringify(input)}`
|
||||||
|
const body = { model: "test", input: [{ role: "user", content: [{ type: "input_text", text: prompt }] }] }
|
||||||
|
expect(match(hit(body))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("matches patchText with newlines", () => {
|
||||||
|
const patchText = "*** Begin Patch\n*** Add File: test.txt\n+line1\n*** End Patch"
|
||||||
|
const match = inputMatch({ patchText })
|
||||||
|
|
||||||
|
const prompt = `Use this JSON input: ${JSON.stringify({ patchText })}`
|
||||||
|
const body = { messages: [{ role: "user", content: prompt }] }
|
||||||
|
expect(match(hit(body))).toBe(true)
|
||||||
|
|
||||||
|
// Also works in responses API format
|
||||||
|
const respBody = { model: "test", input: [{ role: "user", content: [{ type: "input_text", text: prompt }] }] }
|
||||||
|
expect(match(hit(respBody))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not match unrelated requests", () => {
|
||||||
|
const input = { questions: [{ header: "Need input" }] }
|
||||||
|
const match = inputMatch(input)
|
||||||
|
|
||||||
|
expect(match(hit({ messages: [{ role: "user", content: "hello" }] }))).toBe(false)
|
||||||
|
expect(match(hit({ model: "test", input: [] }))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not match partial input", () => {
|
||||||
|
const input = { questions: [{ header: "Need input", question: "Pick one" }] }
|
||||||
|
const match = inputMatch(input)
|
||||||
|
|
||||||
|
// Only header, missing question
|
||||||
|
const partial = `Use this JSON input: ${JSON.stringify({ questions: [{ header: "Need input" }] })}`
|
||||||
|
const body = { messages: [{ role: "user", content: partial }] }
|
||||||
|
expect(match(hit(body))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import path from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
const dir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../e2e")
|
||||||
|
|
||||||
|
function hasPrompt(src: string) {
|
||||||
|
if (!src.includes("withProject(")) return false
|
||||||
|
if (src.includes("withNoReplyPrompt(")) return false
|
||||||
|
if (src.includes("session.promptAsync({") && !src.includes("noReply: true")) return true
|
||||||
|
if (!src.includes("promptSelector")) return false
|
||||||
|
return src.includes('keyboard.press("Enter")') || src.includes('prompt.press("Enter")')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("e2e llm guard", () => {
|
||||||
|
test("withProject specs do not submit prompt replies", async () => {
|
||||||
|
const bad: string[] = []
|
||||||
|
|
||||||
|
for await (const file of new Bun.Glob("**/*.spec.ts").scan({ cwd: dir, absolute: true })) {
|
||||||
|
const src = await Bun.file(file).text()
|
||||||
|
if (!hasPrompt(src)) continue
|
||||||
|
bad.push(path.relative(dir, file))
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(bad).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.3.7",
|
"version": "1.4.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user