mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b677cf383 | ||
|
|
27962e7d95 | ||
|
|
bea5daa725 | ||
|
|
6dd0b063e8 | ||
|
|
96f850438c | ||
|
|
2edf7fdae0 | ||
|
|
f8bef2ac7d | ||
|
|
c92b7b528c | ||
|
|
da6edfcc6f | ||
|
|
f2b3f40901 | ||
|
|
4b00b175e5 | ||
|
|
dfbc998926 | ||
|
|
8dd8666194 | ||
|
|
82f39271ff | ||
|
|
9cbe684a5a | ||
|
|
3a5b4d9fef | ||
|
|
1c17637267 | ||
|
|
ead3fec4fd | ||
|
|
2dcc7fd5a8 | ||
|
|
d23d347897 | ||
|
|
85833b9c07 | ||
|
|
279ec453fe | ||
|
|
7f445eafaa | ||
|
|
36c293cd80 | ||
|
|
8b6fa8c452 | ||
|
|
a7b5a0487e | ||
|
|
d2d37c47ed | ||
|
|
56667add01 | ||
|
|
74fa880ef3 | ||
|
|
7a75155823 | ||
|
|
92fa78cbc3 | ||
|
|
4facd0d6bd | ||
|
|
bc1add0b86 | ||
|
|
9d00a3716d | ||
|
|
221a7645b6 | ||
|
|
d2f0030732 | ||
|
|
48812e90f1 | ||
|
|
7ae4219dcc | ||
|
|
4a4426634b | ||
|
|
255189876b | ||
|
|
10eb314bdb | ||
|
|
808abb9f3a | ||
|
|
4f54c51eef | ||
|
|
89f52f1b6e | ||
|
|
f50babf8a5 | ||
|
|
2f1302b449 | ||
|
|
c0fecd8779 | ||
|
|
0645b0e8e3 | ||
|
|
72f0210bb4 | ||
|
|
69984b1bca | ||
|
|
072cf44246 | ||
|
|
9a38adde4d | ||
|
|
d6f196ac94 | ||
|
|
59df72bde9 | ||
|
|
72a0bf225e | ||
|
|
72382df0e9 | ||
|
|
1192fad2f5 | ||
|
|
1437d5a24a | ||
|
|
6db192b8e7 | ||
|
|
7932bc1e46 | ||
|
|
a1fecd3aa0 | ||
|
|
a84f2aeb03 | ||
|
|
b96f152a07 | ||
|
|
09ce7df436 | ||
|
|
3eda5346a9 | ||
|
|
d580bb38d5 | ||
|
|
068f35dc77 | ||
|
|
dce3b3a70e | ||
|
|
e38623e255 | ||
|
|
28d0ab8677 | ||
|
|
a0337caf9b | ||
|
|
203ab34a81 | ||
|
|
bb0dd15e1b | ||
|
|
387cc22517 | ||
|
|
1e408f5e33 | ||
|
|
252ff75f17 | ||
|
|
a6f96dc2e4 | ||
|
|
c79a15de28 | ||
|
|
395aa3d799 | ||
|
|
52cc819466 | ||
|
|
67bd9f19f1 | ||
|
|
bd33d5138c | ||
|
|
790d06c4a3 | ||
|
|
5ceab67371 | ||
|
|
909bea3618 | ||
|
|
82a9d866d6 | ||
|
|
e4afe93738 | ||
|
|
89b7555861 | ||
|
|
3b723f8f37 | ||
|
|
4ce57642ae | ||
|
|
95f592a53d | ||
|
|
5fe20ebda8 | ||
|
|
4da3f3e488 | ||
|
|
c69b4d9540 | ||
|
|
4d1998b0ca | ||
|
|
3d2ed10742 | ||
|
|
f4591d7c1c | ||
|
|
895972d792 | ||
|
|
190a52283d | ||
|
|
7f9491966c | ||
|
|
97c746625a | ||
|
|
0f4e3f3bb8 | ||
|
|
b922cb2f64 | ||
|
|
53138c7658 | ||
|
|
b1c357812d | ||
|
|
345212f7fb | ||
|
|
d60c52e5c7 | ||
|
|
d58238782c | ||
|
|
aabf833416 | ||
|
|
2d1b53d7be | ||
|
|
4ed24c6bf4 | ||
|
|
cf9afb9d9e | ||
|
|
7378f0ff1f | ||
|
|
1aebd4fcdf | ||
|
|
9f43d13f5e | ||
|
|
22608aa2c7 | ||
|
|
b9beebd5bf | ||
|
|
082e188198 | ||
|
|
e155699904 | ||
|
|
1f46e71c69 | ||
|
|
8d4cfad768 | ||
|
|
7af507b2c3 | ||
|
|
6bb5e61b7f | ||
|
|
adadf64fa1 | ||
|
|
d61dc916c8 | ||
|
|
d1a3cc999c | ||
|
|
97bc4488b5 | ||
|
|
9c7c87e964 | ||
|
|
1e7a44102c | ||
|
|
3211ad62a9 | ||
|
|
4f3bfa9030 | ||
|
|
1aac234fc6 | ||
|
|
caaead1cd9 | ||
|
|
075b961254 | ||
|
|
8ea1c4905b | ||
|
|
cce4bfa2bb | ||
|
|
8ab68bf0fc | ||
|
|
6fae377e2f | ||
|
|
5ac17b55f0 | ||
|
|
4065a6218b | ||
|
|
c33c372a2f | ||
|
|
1054c188d3 | ||
|
|
bfe416b706 | ||
|
|
728d4af4ce | ||
|
|
df181876f3 | ||
|
|
7ea789d93b | ||
|
|
34a337ac0f | ||
|
|
1cc80b8100 | ||
|
|
4c6dc74500 | ||
|
|
7e2d115df3 | ||
|
|
a8e9421bf9 | ||
|
|
88598c8fc3 | ||
|
|
e6d574587a | ||
|
|
98a1fb29a0 | ||
|
|
aa503bca85 | ||
|
|
98166cbc60 | ||
|
|
bd045b0dfd | ||
|
|
0bf3c44a11 | ||
|
|
faaa43d459 | ||
|
|
a238ace545 | ||
|
|
ab25819640 | ||
|
|
5c18d06e1e | ||
|
|
36c9a44728 | ||
|
|
b9612efc5f | ||
|
|
9d1925ee6e | ||
|
|
39bfc0b045 | ||
|
|
2ecc3bbee0 | ||
|
|
ece9c41f8f | ||
|
|
1071bc41e7 | ||
|
|
52f1b7535e | ||
|
|
f541bd353a | ||
|
|
a7582e3d20 | ||
|
|
f4ef59f2ff | ||
|
|
7bb7290c2f | ||
|
|
07794a0533 | ||
|
|
bc18992293 | ||
|
|
b942018011 | ||
|
|
080e0d1182 | ||
|
|
f9b2c3abe8 | ||
|
|
2c9f2c7788 | ||
|
|
89c3958040 | ||
|
|
f0221714c9 | ||
|
|
5311e0c9eb | ||
|
|
6159661f92 | ||
|
|
963b43c625 | ||
|
|
e5494594c2 | ||
|
|
56ce1d0a18 | ||
|
|
0f7f43e744 | ||
|
|
564afe1981 | ||
|
|
666a947bce | ||
|
|
06bbd210ee | ||
|
|
118e7f9c3b | ||
|
|
e509626a00 | ||
|
|
f5fb4c5b75 | ||
|
|
ebdfe03167 | ||
|
|
e4272b885b | ||
|
|
155f08893c | ||
|
|
4cf882e80d | ||
|
|
761627c655 | ||
|
|
32fe51e1cb | ||
|
|
e8ebd833b1 | ||
|
|
a43bf21e4d | ||
|
|
efa942eb91 | ||
|
|
d1637e2415 | ||
|
|
f6a2074130 | ||
|
|
451c9f95cc | ||
|
|
f3223bcfbd | ||
|
|
90e7b1d531 | ||
|
|
1d3320e876 | ||
|
|
2d2841fdbd | ||
|
|
a2eebf8f41 | ||
|
|
2c6afe20d4 | ||
|
|
3426dea1f7 | ||
|
|
c541413fde | ||
|
|
09db5dc7ff | ||
|
|
f87c736a96 | ||
|
|
ab3c108b30 | ||
|
|
c7c127c310 | ||
|
|
885c7886ef | ||
|
|
377c3c3f90 | ||
|
|
96ff4b6503 | ||
|
|
a987c902bf | ||
|
|
2ed83f545d | ||
|
|
7fdb4fbc8a | ||
|
|
d75036fd3b | ||
|
|
807b98a077 | ||
|
|
bd7d0fcd67 | ||
|
|
1e4f38271c | ||
|
|
223f34678f | ||
|
|
393f8508ca | ||
|
|
565216c28c | ||
|
|
82e7ba1068 | ||
|
|
24946f31c7 | ||
|
|
dfa0928859 | ||
|
|
11fc7d1b33 | ||
|
|
0a1839adc9 | ||
|
|
c3c3cac1f9 | ||
|
|
9c06b33ffa | ||
|
|
39270df554 | ||
|
|
fd9f8027f4 | ||
|
|
d5949f79bb | ||
|
|
250b9f6f91 | ||
|
|
c454608baf | ||
|
|
b95698d03c | ||
|
|
65118e2d9b | ||
|
|
62cbf89624 | ||
|
|
0bffc871da | ||
|
|
65be8e9d0c | ||
|
|
c0867f60a5 | ||
|
|
020a1f1757 | ||
|
|
02d8bf531f | ||
|
|
76dc8a48bc | ||
|
|
01c4e63e58 | ||
|
|
6abb384aac | ||
|
|
afeb51806f | ||
|
|
af1d9151ec | ||
|
|
f6f90dc279 | ||
|
|
92539bbd1b | ||
|
|
311a613c0c | ||
|
|
e76d093c57 | ||
|
|
7a95eed7c2 | ||
|
|
96795f99d1 | ||
|
|
e6b77215fd | ||
|
|
403bfcab50 | ||
|
|
df5ee95898 | ||
|
|
14396181b8 | ||
|
|
0076b8816a | ||
|
|
99928cebeb | ||
|
|
de1fb49951 | ||
|
|
5c8e238eef | ||
|
|
68a1a47322 | ||
|
|
0f5a7f143c | ||
|
|
4a6dcda402 | ||
|
|
6e6c32627b | ||
|
|
d30e6f1c37 | ||
|
|
7c64489c69 | ||
|
|
73f622326f | ||
|
|
95cf355b25 | ||
|
|
54d5fe65b4 | ||
|
|
9237eb82b6 | ||
|
|
62a95e31b0 | ||
|
|
a1bbcd7194 | ||
|
|
b6f0a8ebbd | ||
|
|
23ce2a7af7 | ||
|
|
c880b7ad73 | ||
|
|
499f7f2e44 | ||
|
|
53086911fd | ||
|
|
850c4b5db5 | ||
|
|
c57062b788 | ||
|
|
26e5b6ad01 | ||
|
|
010b761286 | ||
|
|
519f94232f | ||
|
|
d0e0d4e4f3 | ||
|
|
d451290d46 | ||
|
|
29c859b7b0 | ||
|
|
91035ddd8e | ||
|
|
4eb0b6e1c3 | ||
|
|
3cff8c9ffc | ||
|
|
0003bef4e7 | ||
|
|
b82a97dd1d | ||
|
|
6968f1dca3 | ||
|
|
ecd14ab332 | ||
|
|
174ea10844 | ||
|
|
c87628398f | ||
|
|
e02e62c310 | ||
|
|
fee799a7ed | ||
|
|
1e7379c29d | ||
|
|
0b579f2110 | ||
|
|
1ea049d9c2 | ||
|
|
8e5d9e815d | ||
|
|
cba9390d2b | ||
|
|
34ca109ec4 | ||
|
|
4c7386f921 | ||
|
|
224c6e61d3 | ||
|
|
b44e9af7e5 | ||
|
|
e7a8a1320d | ||
|
|
7f51ad178b | ||
|
|
8664212c45 | ||
|
|
4ec73b2441 | ||
|
|
acd567721c | ||
|
|
a72c392b68 | ||
|
|
2e15cc9d59 | ||
|
|
eaba743bc5 | ||
|
|
157809c4d7 | ||
|
|
1447b62908 | ||
|
|
d3b5111ca8 | ||
|
|
a306a07f82 | ||
|
|
15101b87f4 | ||
|
|
6f989b09c5 | ||
|
|
110c5ece63 | ||
|
|
7837083eba | ||
|
|
f14ce69798 | ||
|
|
bfda54539b | ||
|
|
ee20852d1b | ||
|
|
a7a256adc1 | ||
|
|
0466fef9d6 | ||
|
|
dc68694cdb | ||
|
|
a1029786ad | ||
|
|
50a658e434 | ||
|
|
9a4f56e4cf | ||
|
|
06634777c1 | ||
|
|
7bd9c13f01 | ||
|
|
107456d605 | ||
|
|
f19c00f141 | ||
|
|
2ba719ebab | ||
|
|
7cb64f55e0 | ||
|
|
201423138e | ||
|
|
787fe8a0bf | ||
|
|
9d0890f92a | ||
|
|
79efac68a3 | ||
|
|
608527502a | ||
|
|
61baf002e9 | ||
|
|
0f89cec5ea | ||
|
|
6482375b72 | ||
|
|
5f2eac7053 | ||
|
|
f61118b8fe | ||
|
|
8ecdc894a8 | ||
|
|
faab493b02 | ||
|
|
d7bd736a85 | ||
|
|
4e3a1aea57 | ||
|
|
afa8b710f4 | ||
|
|
02702c8378 | ||
|
|
863d07be20 | ||
|
|
2b105dd575 | ||
|
|
7902c9321d | ||
|
|
7e2a14ef91 | ||
|
|
7135b32e14 | ||
|
|
acc954a506 | ||
|
|
4e440421af | ||
|
|
a9928d3ef5 | ||
|
|
74b9dd77b6 | ||
|
|
1e8fc36577 | ||
|
|
ec814165dd | ||
|
|
4b4b976fcd | ||
|
|
aad432d693 | ||
|
|
a6807da7f8 | ||
|
|
bf24e28377 | ||
|
|
31d3b7c0e7 | ||
|
|
63fbad01d3 | ||
|
|
d2e91a8a11 | ||
|
|
aeb6431f73 | ||
|
|
98ee87101b | ||
|
|
27b86732c2 | ||
|
|
912bbd86ae | ||
|
|
939975881e | ||
|
|
746d06800a | ||
|
|
bd31c9e10e | ||
|
|
f55083fa62 | ||
|
|
23b429d3d5 | ||
|
|
a87adced3c | ||
|
|
573f58a1c2 | ||
|
|
b7d1e95c50 | ||
|
|
a8fcd8c1c8 | ||
|
|
3307be74d9 | ||
|
|
639736f5fa | ||
|
|
2c7ad4973e | ||
|
|
eefcb09038 | ||
|
|
02a1741a3d | ||
|
|
0f2ce6529f | ||
|
|
9bc6344fa1 | ||
|
|
dbced6638a | ||
|
|
253faa132f | ||
|
|
606d782379 | ||
|
|
ad3d06343d | ||
|
|
ac5f37f40b | ||
|
|
9b5c870bd3 | ||
|
|
2b29e2d92f | ||
|
|
bafd9db24d | ||
|
|
8ff0b64884 | ||
|
|
8de3dba0ff | ||
|
|
d2ec3a1d68 | ||
|
|
9b70f121c0 | ||
|
|
a71a850213 | ||
|
|
95d06df682 | ||
|
|
addd1125d9 | ||
|
|
08cf2b4585 | ||
|
|
052708f6f4 | ||
|
|
554e881a69 | ||
|
|
4e6db19690 | ||
|
|
044936cd5b | ||
|
|
48f9b9418a | ||
|
|
8ac66a4b18 | ||
|
|
b99c08a52c | ||
|
|
61e943beb3 | ||
|
|
9e9d9e6e44 | ||
|
|
f7c6048fa4 | ||
|
|
42ee48f214 | ||
|
|
6acdf3edb3 | ||
|
|
5d63d8e29a | ||
|
|
26ef771c7a | ||
|
|
bf53799e8a | ||
|
|
ee9d976622 | ||
|
|
d5ba463964 | ||
|
|
4762cd33d6 | ||
|
|
9838a27280 | ||
|
|
484bca854f | ||
|
|
9ff0b20589 | ||
|
|
a2eaf80601 | ||
|
|
cf2a8b061b | ||
|
|
fcd6180976 | ||
|
|
6cbffaa78b | ||
|
|
9ee254b2ec | ||
|
|
cae565c158 | ||
|
|
3255803636 | ||
|
|
c8ac697010 | ||
|
|
8d8bccd334 | ||
|
|
3b0b9c0546 | ||
|
|
3608146829 | ||
|
|
3b3c9dba57 | ||
|
|
e1c2f46e8a | ||
|
|
798c7ce85a | ||
|
|
2ade3cd50d | ||
|
|
da348d552e | ||
|
|
96431bc31f | ||
|
|
2b56585f8f | ||
|
|
4ea38a7a22 | ||
|
|
5b8a9c0cd5 | ||
|
|
1f4d811ac3 | ||
|
|
7cab46ac3a | ||
|
|
0a38be6852 | ||
|
|
0ae11ebdef | ||
|
|
fdb99115d8 | ||
|
|
2bcac618d7 | ||
|
|
25184026bc | ||
|
|
3a1d04ae23 | ||
|
|
e73221c8b7 | ||
|
|
bd9923d644 | ||
|
|
6892b5a035 | ||
|
|
885ad5627f | ||
|
|
e702eb082e | ||
|
|
ef1d287fc0 | ||
|
|
c146456d8a | ||
|
|
ebd73457da | ||
|
|
3f037fa745 | ||
|
|
f83b84b896 | ||
|
|
be59e66976 | ||
|
|
d924d998a0 | ||
|
|
8b04b164dc | ||
|
|
118fe34378 | ||
|
|
170771fc3a | ||
|
|
06fc964bc9 | ||
|
|
6643299111 | ||
|
|
72ce3d8d93 | ||
|
|
0aad3bd186 | ||
|
|
1858974dc7 | ||
|
|
ac2f6695c5 | ||
|
|
69d0185ff6 | ||
|
|
4d8caaa944 | ||
|
|
5e40ed682b | ||
|
|
1284d1c6c1 | ||
|
|
3093f03585 | ||
|
|
f39f06896f | ||
|
|
cb1a0bfd18 | ||
|
|
c757d24483 | ||
|
|
c38b0801cc | ||
|
|
8d42210d7c | ||
|
|
191772931f | ||
|
|
56a53237f4 | ||
|
|
c045230307 | ||
|
|
946255ce89 | ||
|
|
397605ab13 | ||
|
|
a753893797 | ||
|
|
23bf5dd464 | ||
|
|
c600b12693 | ||
|
|
d2cf3addc7 | ||
|
|
f1951c5d1f | ||
|
|
82a9d6829e | ||
|
|
7981c96d70 | ||
|
|
ac1b2ad45d | ||
|
|
f00bb1ea85 | ||
|
|
68b7ea6c07 | ||
|
|
d239e346f6 | ||
|
|
5527e8e805 | ||
|
|
cf7ee9e400 | ||
|
|
c580472167 | ||
|
|
462fd5e281 | ||
|
|
8ddad32b4b | ||
|
|
2bc0d2f010 | ||
|
|
af13bc76be | ||
|
|
4d767f3db4 | ||
|
|
5b81febdab | ||
|
|
c58805afea | ||
|
|
44876d2607 | ||
|
|
8ecdf392ad |
No files matched your search
@@ -48,6 +48,7 @@ jobs:
|
||||
run: go build ./...
|
||||
|
||||
- name: test
|
||||
if: runner.os != 'Windows'
|
||||
env:
|
||||
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
|
||||
# regression there silently tanks the cache hit rate the project is
|
||||
@@ -55,6 +56,19 @@ jobs:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
run: go test ./...
|
||||
|
||||
- name: test
|
||||
if: runner.os == 'Windows'
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
|
||||
# regression there silently tanks the cache hit rate the project is
|
||||
# built around.
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
# Bound sandbox helper children in Windows tests so a failed OS-level
|
||||
# launch cannot pin the Actions step after Go's package timeout fires.
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -p 4 -timeout=3m ./...
|
||||
|
||||
race:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Deploy accounts worker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main-v2]
|
||||
paths:
|
||||
- 'workers/accounts/**'
|
||||
- '.github/workflows/deploy-accounts-worker.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-accounts-worker
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: workers/accounts/pnpm-lock.yaml
|
||||
- name: Deploy
|
||||
working-directory: workers/accounts
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
npx wrangler deploy
|
||||
- name: Sync RESEND_API_KEY to the worker
|
||||
working-directory: workers/accounts
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||
run: |
|
||||
if [ -n "$RESEND_API_KEY" ]; then
|
||||
printf '%s' "$RESEND_API_KEY" | npx wrangler secret put RESEND_API_KEY
|
||||
else
|
||||
echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)."
|
||||
fi
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Deploy forum worker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main-v2]
|
||||
paths:
|
||||
- 'workers/forum/**'
|
||||
- '.github/workflows/deploy-forum-worker.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-forum-worker
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: workers/forum/pnpm-lock.yaml
|
||||
- name: Deploy
|
||||
working-directory: workers/forum
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
npx wrangler deploy
|
||||
@@ -8,7 +8,8 @@ name: Release desktop
|
||||
# Wails cannot cross-compile a CGO/WebKit binary, so build/ fans out to one native
|
||||
# runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a
|
||||
# latest.json manifest is generated, and everything is published to a GitHub
|
||||
# release and mirrored to R2 (the updater reads R2 first, GitHub as fallback).
|
||||
# release and mirrored to R2 (the updater reads R2 first, then the crash worker
|
||||
# release gateway; stable desktop releases own GitHub's repository-wide "latest").
|
||||
#
|
||||
# The same build/sign/manifest pipeline serves two channels. A `desktop-v*` tag
|
||||
# (or manual stable dispatch) publishes a GitHub release and moves R2's latest/
|
||||
@@ -260,16 +261,22 @@ jobs:
|
||||
working-directory: desktop
|
||||
run: go run ./cmd/sign manifest ../dist "${{ steps.ver.outputs.version }}" "${{ steps.ver.outputs.tag }}"
|
||||
|
||||
# Canary is R2-only and never appears on the GitHub releases page; the mirror
|
||||
# job picks up the signed dist via the canary-dist artifact below. Stable
|
||||
# publishes a GitHub release as usual.
|
||||
# Canary never appears on the GitHub releases page; the mirror job picks up
|
||||
# the signed dist via the canary-dist artifact below. Stable publishes a
|
||||
# GitHub release as usual.
|
||||
- name: Publish GitHub release
|
||||
if: steps.ver.outputs.channel != 'canary'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# Keep the repository homepage focused on the installable desktop app;
|
||||
# the CLI release line is configured not to claim repository-wide latest.
|
||||
args=(--title "Reasonix Desktop ${{ steps.ver.outputs.version }}" --generate-notes)
|
||||
[ "${{ steps.ver.outputs.prerelease }}" = "true" ] && args+=(--prerelease)
|
||||
if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
|
||||
args+=(--prerelease --latest=false)
|
||||
else
|
||||
args+=(--latest)
|
||||
fi
|
||||
gh release create "${{ steps.ver.outputs.tag }}" dist/* "${args[@]}"
|
||||
|
||||
- name: Upload canary dist for mirror
|
||||
@@ -285,7 +292,7 @@ jobs:
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # gh release download
|
||||
contents: write # gh release download + compatibility manifest upload
|
||||
actions: write # dispatch pages.yml to re-bake the site version
|
||||
# Skip cleanly when R2 isn't configured; GitHub release still works as fallback.
|
||||
if: ${{ github.repository_owner == 'esengine' }}
|
||||
@@ -366,6 +373,109 @@ jobs:
|
||||
aws s3 cp assets/ "s3://${R2_BUCKET}/latest/" --recursive --endpoint-url "$ENDPOINT"
|
||||
fi
|
||||
|
||||
# dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
|
||||
# protection), so smoke the mirrored objects over the authenticated S3 API
|
||||
# instead of the public edge. This verifies the mirror landed; the public
|
||||
# edge itself is not reachable from CI and is covered by end users' traffic.
|
||||
- name: Smoke desktop release pointers
|
||||
if: env.HAS_R2 == 'true'
|
||||
env:
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
CHANNEL: ${{ steps.ver.outputs.channel }}
|
||||
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
f=assets/latest.json
|
||||
jq -e --arg version "$VERSION" --arg prefix "https://dl.reasonix.io/${TAG}/" '
|
||||
.version == $version and
|
||||
.download_page == "https://reasonix.io/#start" and
|
||||
([.platforms[] | (.url, .sig)] |
|
||||
all(type == "string" and startswith($prefix) and (contains("/releases/latest/") | not)))
|
||||
' "$f" >/dev/null
|
||||
|
||||
aws s3 cp "s3://${R2_BUCKET}/${TAG}/latest.json" /tmp/reasonix-desktop-tag-latest.json --endpoint-url "$ENDPOINT"
|
||||
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-tag-latest.json >/dev/null
|
||||
if [ "$CHANNEL" = "canary" ]; then
|
||||
pointer="canary"
|
||||
elif [ "$PRERELEASE" = "true" ]; then
|
||||
pointer=""
|
||||
else
|
||||
pointer="latest"
|
||||
fi
|
||||
if [ -n "$pointer" ]; then
|
||||
aws s3 cp "s3://${R2_BUCKET}/${pointer}/latest.json" /tmp/reasonix-desktop-pointer-latest.json --endpoint-url "$ENDPOINT"
|
||||
jq -e --arg version "$VERSION" '.version == $version' /tmp/reasonix-desktop-pointer-latest.json >/dev/null
|
||||
fi
|
||||
|
||||
jq -r '.platforms[] | .url, .sig' "$f" | while IFS= read -r asset; do
|
||||
key="${asset#https://dl.reasonix.io/}"
|
||||
aws s3api head-object --bucket "$R2_BUCKET" --key "$key" --endpoint-url "$ENDPOINT" >/dev/null
|
||||
done
|
||||
|
||||
# Best-effort probe of the release gateway — the updater's second
|
||||
# manifest source — over the same public edge and Go client UA end users
|
||||
# hit. A 403 here is the known Cloudflare bot-protection gap (#6005:
|
||||
# datacenter/proxy egress gets blocked before the worker runs) and must
|
||||
# not fail the release until a WAF skip rule for /v1/desktop/releases/*
|
||||
# lands; it is surfaced as a warning so the run shows whether the edge
|
||||
# is open. Anything else unexpected (404, 5xx, wrong version) means the
|
||||
# gateway route or pointer regressed and fails hard.
|
||||
- name: Probe public release gateway
|
||||
if: env.HAS_R2 == 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
CHANNEL: ${{ steps.ver.outputs.channel }}
|
||||
PRERELEASE: ${{ steps.ver.outputs.prerelease }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "$PRERELEASE" = "true" ]; then
|
||||
echo "prerelease — no pointer moved; skipping gateway probe"
|
||||
exit 0
|
||||
fi
|
||||
chan="stable"
|
||||
[ "$CHANNEL" = "canary" ] && chan="canary"
|
||||
url="https://crash.reasonix.io/v1/desktop/releases/${chan}/latest.json"
|
||||
# curl already prints 000 for a transport failure; || true keeps -e
|
||||
# from killing the step so the case below can route it.
|
||||
code="$(curl -sS -A "Go-http-client/2.0" -o /tmp/gateway-latest.json -w '%{http_code}' "$url" || true)"
|
||||
case "$code" in
|
||||
200)
|
||||
if jq -e --arg version "$VERSION" '.version == $version' /tmp/gateway-latest.json >/dev/null; then
|
||||
echo "gateway serves $VERSION on $chan"
|
||||
else
|
||||
echo "::error::gateway responded 200 but serves $(jq -r '.version // "<none>"' /tmp/gateway-latest.json), want $VERSION — stale or wrong pointer"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
403)
|
||||
echo "::warning::gateway returned 403 to CI egress — known bot-protection gap (#6005), not failing the release"
|
||||
;;
|
||||
000|"")
|
||||
echo "::warning::gateway unreachable from CI (transport error), not failing the release"
|
||||
;;
|
||||
*)
|
||||
echo "::error::gateway returned $code for $url — route or pointer regression"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Attach desktop manifest to matching CLI release
|
||||
if: env.HAS_R2 == 'true' && steps.ver.outputs.channel != 'canary' && steps.ver.outputs.prerelease != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ steps.ver.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$VERSION" >/dev/null 2>&1; then
|
||||
gh release upload "$VERSION" assets/latest.json --clobber
|
||||
else
|
||||
echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes."
|
||||
fi
|
||||
|
||||
# Stable release moved R2 latest/ — rebuild the site so its build-time baked
|
||||
# version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO).
|
||||
- name: Refresh site to the new version
|
||||
|
||||
@@ -6,16 +6,17 @@ name: Release npm
|
||||
# own tag lets a prerelease ship to npm's `next` channel without touching the
|
||||
# stable Homebrew cask (and lets a stable cask ship without forcing npm to leave
|
||||
# its rc). Bump with:
|
||||
# git tag npm-vX.Y.Z # stable -> publishes under `next`
|
||||
# git tag npm-vX.Y.Z # stable -> publishes under `latest`
|
||||
# git tag npm-vX.Y.Z-rc.N # prerelease -> publishes under `next`
|
||||
# git push origin <tag>
|
||||
#
|
||||
# A manual workflow_dispatch can publish either an opt-in canary from the current
|
||||
# ref or an approved stable/next version when tag creation is restricted. Canary
|
||||
# publishes to the `canary` dist-tag (npm i reasonix@canary) and never moves
|
||||
# `next`/`latest`. build.mjs decides the dist-tag: 0.x stable is `latest`; a
|
||||
# `-canary.` build is `canary`; the 1.x line and rc prereleases are `next`. Promote
|
||||
# a 1.x stable to the default install with `npm dist-tag add [email protected] latest`.
|
||||
# `next`/`latest`. build.mjs decides the dist-tag: a `-canary.` build is `canary`,
|
||||
# any other prerelease is `next`, and a stable version is `latest` (#5822 — the
|
||||
# old "promote latest by hand" rule left it pinned at 0.53.2 and downgraded
|
||||
# `npm update -g` users). The verify step below asserts the tag actually landed.
|
||||
on:
|
||||
push:
|
||||
tags: ['npm-v*']
|
||||
@@ -86,3 +87,28 @@ jobs:
|
||||
- run: node npm/build.mjs "${{ steps.ver.outputs.arg }}" --publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
# Assert the dist-tag landed where build.mjs aimed it. This is the guard
|
||||
# that #5822 lacked: `latest` sat on 0.53.2 for months while stables went
|
||||
# to `next`, and nothing noticed until users got downgraded. The registry
|
||||
# applies tags asynchronously after publish, hence the poll.
|
||||
- name: Verify dist-tags
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${{ steps.ver.outputs.arg }}"
|
||||
version="${version#npm-}"; version="${version#v}"
|
||||
case "$version" in
|
||||
*-canary.*) tag=canary ;;
|
||||
*-*) tag=next ;;
|
||||
*) tag=latest ;;
|
||||
esac
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
got="$(npm view reasonix dist-tags."$tag" 2>/dev/null || true)"
|
||||
if [ "$got" = "$version" ]; then
|
||||
echo "dist-tag $tag -> $got"
|
||||
exit 0
|
||||
fi
|
||||
echo "dist-tag $tag -> ${got:-<unset>}, want $version (attempt $attempt)"
|
||||
sleep 10
|
||||
done
|
||||
echo "::error::npm dist-tag '$tag' never landed on $version"
|
||||
exit 1
|
||||
@@ -30,7 +30,9 @@ jobs:
|
||||
name: archives + checksums + homebrew tap
|
||||
needs: cache-guard
|
||||
runs-on: ubuntu-latest
|
||||
# CLI stable release (archives + GitHub release + Homebrew cask). Gated on the
|
||||
# CLI stable release (archives + GitHub release + Homebrew cask). It does not
|
||||
# claim GitHub's repository-wide Latest badge; that belongs to desktop so the
|
||||
# repository homepage points users at the installable app. Gated on the
|
||||
# `release` environment so only esengine can approve it going public.
|
||||
environment: release
|
||||
steps:
|
||||
@@ -48,3 +50,124 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
|
||||
- name: Attach desktop manifest compatibility asset
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TAG" in
|
||||
*-*)
|
||||
echo "prerelease $TAG — GitHub latest does not move here; skipping desktop manifest compatibility asset"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
if [ "$HAS_R2" != "true" ]; then
|
||||
echo "R2 secrets not configured; skipping desktop manifest compatibility asset"
|
||||
exit 0
|
||||
fi
|
||||
# dl.reasonix.io serves 403 to GitHub Actions egress IPs (Cloudflare bot
|
||||
# protection), so read the manifest over the authenticated S3 API instead
|
||||
# of the public edge.
|
||||
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
|
||||
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
|
||||
aws configure set region auto
|
||||
aws s3 cp "s3://${R2_BUCKET}/latest/latest.json" latest.raw.json \
|
||||
--endpoint-url "https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
jq '.download_page = "https://reasonix.io/#start"' latest.raw.json > latest.json
|
||||
jq -e '
|
||||
([.platforms[] | (.url, .sig)] |
|
||||
all(type == "string" and startswith("https://dl.reasonix.io/") and (contains("/releases/latest/") | not)))
|
||||
' latest.json >/dev/null
|
||||
gh release upload "$TAG" latest.json --clobber
|
||||
|
||||
# The compatibility asset exists for pre-v1.16 desktop updaters that
|
||||
# still poll GitHub's repository-wide latest URL. Desktop releases now
|
||||
# own that Latest badge, but this check still exercises the public fallback
|
||||
# path exactly the way those clients fetch it: anonymously, over the public
|
||||
# edge, with a Go client UA. Unlike dl.reasonix.io (whose bot protection
|
||||
# 403s Actions egress — see the R2 note above), GitHub serves its own
|
||||
# runners, so this can hard-fail. #5826/#5858 shipped a broken update check
|
||||
# for weeks precisely because nothing exercised the public path. Retries
|
||||
# cover the release CDN propagating the freshly uploaded asset.
|
||||
- name: Smoke public compatibility manifest
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TAG" in
|
||||
*-*)
|
||||
echo "prerelease $TAG — no compatibility asset uploaded; skipping"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
if [ "$HAS_R2" != "true" ]; then
|
||||
echo "R2 secrets not configured; no compatibility asset uploaded; skipping"
|
||||
exit 0
|
||||
fi
|
||||
url="https://github.com/${{ github.repository }}/releases/latest/download/latest.json"
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
if curl -fsSL -A "Go-http-client/2.0" -o /tmp/compat-latest.json "$url"; then
|
||||
jq -e '(.version | type == "string") and (.platforms | type == "object")' /tmp/compat-latest.json >/dev/null
|
||||
echo "public compatibility manifest OK (desktop version $(jq -r .version /tmp/compat-latest.json))"
|
||||
exit 0
|
||||
fi
|
||||
echo "attempt $attempt failed; retrying in 10s"
|
||||
sleep 10
|
||||
done
|
||||
echo "::error::public compatibility manifest unreachable at $url"
|
||||
exit 1
|
||||
|
||||
# A stable CLI release must never leave the npm line behind: v1.17.5
|
||||
# shipped as binaries/Homebrew while npm `latest` still pointed at 0.53.2
|
||||
# (#5822) — every `npm update -g` user was silently downgraded to a
|
||||
# months-old version, and nothing noticed because the npm line
|
||||
# (release-npm.yml, `npm-vX.Y.Z` tags) is triggered independently and the
|
||||
# stable npm tag was simply never pushed. release-npm.yml's own verify
|
||||
# step only guards runs that happen; this guard catches the run that
|
||||
# DIDN'T.
|
||||
#
|
||||
# Two distinct states, two responses (the runs race: both workflows wait
|
||||
# on the release environment independently, and npm dist-tags propagate
|
||||
# asynchronously, so "tag pushed but latest not moved yet" is a NORMAL
|
||||
# mid-release state, not a failure):
|
||||
# - npm-v<version> tag missing -> hard fail. This is the #5822 gap:
|
||||
# nobody pushed the npm release at all.
|
||||
# - tag pushed, latest lagging -> poll briefly, then WARN and pass.
|
||||
# The npm run may still be awaiting its own environment approval;
|
||||
# release-npm.yml's verify step owns asserting the dist-tag lands.
|
||||
- name: Check npm latest dist-tag freshness
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TAG" in
|
||||
*-*)
|
||||
echo "prerelease $TAG — npm latest does not move on prereleases; skipping"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
version="${TAG#v}"
|
||||
if ! git ls-remote --exit-code origin "refs/tags/npm-v$version" >/dev/null; then
|
||||
echo "::error::the npm-v$version tag was never pushed — the npm channel is being left behind and 'npm update -g' users will be downgraded to the old 'latest'. Push it: git tag npm-v$version ${TAG} && git push origin npm-v$version (or 'npm dist-tag add reasonix@$version latest' for an already-published version)."
|
||||
exit 1
|
||||
fi
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
got="$(npm view reasonix dist-tags.latest 2>/dev/null || true)"
|
||||
if [ -n "$got" ]; then
|
||||
newest="$(printf '%s\n%s\n' "$got" "$version" | sort -V | tail -1)"
|
||||
if [ "$newest" = "$got" ]; then
|
||||
echo "npm latest -> $got (>= $version) OK"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo "npm latest -> ${got:-<unreadable>}, want >= $version (attempt $attempt)"
|
||||
sleep 10
|
||||
done
|
||||
echo "::warning::npm-v$version is pushed but npm 'latest' is still ${got:-<unreadable>} — the npm publish run is likely awaiting release-environment approval or still propagating. Approve/monitor the release-npm run; its verify step asserts the dist-tag lands."
|
||||
exit 0
|
||||
@@ -58,4 +58,5 @@ homebrew_casks:
|
||||
|
||||
release:
|
||||
prerelease: auto
|
||||
make_latest: false
|
||||
name_template: "Reasonix CLI {{ .Tag }}"
|
||||
@@ -63,6 +63,37 @@ make hooks # install git hooks (pre-push: go vet)
|
||||
make cross # cross-compile for all 6 targets
|
||||
```
|
||||
|
||||
### Isolated development environment
|
||||
|
||||
A source-built binary shares no on-disk state with a stable release when launched
|
||||
with `REASONIX_HOME` set. This gives each build its own self-contained directory
|
||||
tree — config, credentials, sessions, cache, skills, commands, hooks, and
|
||||
desktop tab state — so the two builds never interfere:
|
||||
|
||||
**CLI**
|
||||
|
||||
```bash
|
||||
REASONIX_HOME=/tmp/reasonix-dev go run ./cmd/reasonix
|
||||
# or after building:
|
||||
# REASONIX_HOME=/tmp/reasonix-dev ./bin/reasonix
|
||||
```
|
||||
|
||||
**Desktop**
|
||||
|
||||
```bash
|
||||
cd desktop && wails build
|
||||
REASONIX_HOME=/tmp/reasonix-dev-isolated build/bin/reasonix-desktop
|
||||
```
|
||||
|
||||
On Windows, use `$env:REASONIX_HOME` in PowerShell or `set REASONIX_HOME=` in
|
||||
Command Prompt; the binary extension is `.exe`.
|
||||
|
||||
The directory is empty on first launch; the app behaves exactly like a fresh
|
||||
install. Every subsequent write — config saves, credential storage, session
|
||||
logs — stays under `REASONIX_HOME`. Legacy migration, OS-home convention
|
||||
directory scanning, and all other fallback paths are skipped so no production
|
||||
data leaks in or out.
|
||||
|
||||
### Cache-first review gate
|
||||
|
||||
Reasonix treats high prompt-cache hit rate as product behavior. Changes that
|
||||
|
||||
@@ -2,7 +2,7 @@ VERSION := $(shell git describe --tags --always 2>/dev/null || echo dev)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||
GOEXE := $(shell go env GOEXE)
|
||||
|
||||
.PHONY: build vet fmt test hooks cross clean
|
||||
.PHONY: build vet fmt test desktop-test desktop-test-short desktop-test-times hooks cross clean
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
|
||||
@@ -17,6 +17,15 @@ fmt:
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
desktop-test:
|
||||
cd desktop && go test .
|
||||
|
||||
desktop-test-short:
|
||||
cd desktop && go test -short .
|
||||
|
||||
desktop-test-times:
|
||||
cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
|
||||
|
||||
hooks:
|
||||
@git config core.hooksPath .githooks
|
||||
@echo "installed: core.hooksPath -> .githooks (pre-push runs go vet)"
|
||||
|
||||
@@ -33,12 +33,6 @@
|
||||
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/9e931d80-2050-4b10-902e-44970cc133ad.svg" alt="oosmetrics — Top 2 in Agents by velocity"/></a>
|
||||
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/556d94b3-61b7-486b-baf2-888b9327deab.svg" alt="oosmetrics — Top 3 in LLMs by velocity"/></a>
|
||||
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/0f457d4c-efca-4d15-ad2b-139691ff342c.svg" alt="oosmetrics — Top 3 in CLI by velocity"/></a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<h3 align="center">A DeepSeek-native AI coding agent for your terminal.</h3>
|
||||
@@ -137,6 +131,8 @@ commands, `@` references, and two-model setup are all in the
|
||||
from the desktop app, then use approvals, YOLO, and commands from IM.
|
||||
- **[Spec](./docs/SPEC.md)** — engineering contract: architecture, registries,
|
||||
data types, and roadmap.
|
||||
- **[Task contracts & pause policy](./docs/TASK_CONTRACT.md)** — structure
|
||||
complex requests with context, output boundaries, constraints, and when to ask.
|
||||
- **[Tool contract](./docs/TOOL_CONTRACT.md)** — provider-visible built-in tool
|
||||
names, read-only flags, and schema snapshot guard.
|
||||
- **[Migrating from 0.x](./docs/MIGRATING.md)** — moving from the legacy
|
||||
|
||||
+1
-6
@@ -33,12 +33,6 @@
|
||||
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/9e931d80-2050-4b10-902e-44970cc133ad.svg" alt="oosmetrics — Top 2 in Agents by velocity"/></a>
|
||||
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/556d94b3-61b7-486b-baf2-888b9327deab.svg" alt="oosmetrics — Top 3 in LLMs by velocity"/></a>
|
||||
<a href="https://oosmetrics.com/repo/esengine/reasonix"><img src="https://api.oosmetrics.com/api/v1/badge/achievement/0f457d4c-efca-4d15-ad2b-139691ff342c.svg" alt="oosmetrics — Top 3 in CLI by velocity"/></a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<h3 align="center">面向终端的 DeepSeek 原生 AI coding agent。</h3>
|
||||
@@ -126,6 +120,7 @@ provider key 的运行时 fallback,但仍会作为当前 workspace 范围内
|
||||
- **[机器人使用指南](./docs/BOT_GUIDE.zh-CN.md)** —— 桌面端连接飞书、Lark、微信
|
||||
Bot,以及 IM 里的审批、YOLO 和命令交互。
|
||||
- **[规格](./docs/SPEC.md)** —— 工程契约:架构、registry、数据类型与路线图。
|
||||
- **[任务合约与暂停策略](./docs/TASK_CONTRACT.zh-CN.md)** —— 用背景、输出边界、约束和暂停条件组织复杂请求。
|
||||
- **[工具合约](./docs/TOOL_CONTRACT.zh-CN.md)** —— provider 可见的内置工具名、
|
||||
read-only 标记和 schema 快照保护。
|
||||
- **[从 0.x 迁移](./docs/MIGRATING.md)** —— 从 legacy TypeScript 版本迁到 1.0 Go 重写版。
|
||||
|
||||
+10
-40
@@ -10,6 +10,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"reasonix/internal/shellparse"
|
||||
)
|
||||
|
||||
type diffOpts struct {
|
||||
@@ -546,57 +548,25 @@ func failingTestNames(out string) []string {
|
||||
}
|
||||
|
||||
func runTests(repo, testCmd string, pkgs []string) (bool, string) {
|
||||
fields := splitShellFields(testCmd)
|
||||
test, err := shellparse.ParseStaticCommand(testCmd, shellparse.StaticCommandPolicy{AllowEnvAssignments: true, AllowStderrToStdout: true})
|
||||
if err != nil {
|
||||
return false, "invalid test command: " + err.Error()
|
||||
}
|
||||
fields := test.Argv
|
||||
if len(fields) == 0 {
|
||||
fields = []string{"go", "test"}
|
||||
}
|
||||
args := append(fields[1:], pkgs...)
|
||||
cmd := exec.Command(fields[0], args...)
|
||||
cmd.Dir = repo
|
||||
if len(test.Env) > 0 {
|
||||
cmd.Env = append(os.Environ(), test.Env...)
|
||||
}
|
||||
cmd.WaitDelay = 5 * time.Minute // bound the wait if `go test` hangs
|
||||
out, err := cmd.CombinedOutput()
|
||||
return err == nil, string(out)
|
||||
}
|
||||
|
||||
// splitShellFields splits on whitespace, honoring single/double-quoted spans and stripping the quotes.
|
||||
func splitShellFields(s string) []string {
|
||||
var out []string
|
||||
var cur strings.Builder
|
||||
inSingle, inDouble := false, false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case inSingle:
|
||||
if c == '\'' {
|
||||
inSingle = false
|
||||
} else {
|
||||
cur.WriteByte(c)
|
||||
}
|
||||
case inDouble:
|
||||
if c == '"' {
|
||||
inDouble = false
|
||||
} else {
|
||||
cur.WriteByte(c)
|
||||
}
|
||||
case c == '\'':
|
||||
inSingle = true
|
||||
case c == '"':
|
||||
inDouble = true
|
||||
case c == ' ' || c == '\t' || c == '\n':
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
default:
|
||||
cur.WriteByte(c)
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// changedGoFiles lists .go files changed by base...HEAD, excluding *_test.go
|
||||
// when includeTests is false (we want the source under test).
|
||||
func changedGoFiles(repo, base string, includeTests bool) []string {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunTestsAllowsStaticEnvAssignment(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
writeE2EFile(t, filepath.Join(repo, "go.mod"), "module example.com/e2ebenchtest\n\ngo 1.23\n")
|
||||
writeE2EFile(t, filepath.Join(repo, "env_test.go"), `package e2ebenchtest
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnv(t *testing.T) {
|
||||
if got := os.Getenv("REASONIX_E2E_ENV"); got != "ok" {
|
||||
t.Fatalf("REASONIX_E2E_ENV = %q", got)
|
||||
}
|
||||
}
|
||||
`)
|
||||
|
||||
ok, out := runTests(repo, "GOWORK=off REASONIX_E2E_ENV=ok go test", []string{"./..."})
|
||||
if !ok {
|
||||
t.Fatalf("runTests failed:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTestsRejectsDynamicEnvAssignment(t *testing.T) {
|
||||
ok, out := runTests(t.TempDir(), "REASONIX_E2E_ENV=$(echo ok) go test", []string{"./..."})
|
||||
if ok {
|
||||
t.Fatal("runTests accepted dynamic env assignment")
|
||||
}
|
||||
if !strings.Contains(out, "invalid test command: shell expansion") {
|
||||
t.Fatalf("output = %q, want shell expansion rejection", out)
|
||||
}
|
||||
}
|
||||
|
||||
func writeE2EFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
+28
-4
@@ -62,6 +62,24 @@ In a plain browser the native bindings are absent, so `bridge.ts` falls back to
|
||||
exact same event contract — so layout, streaming, markdown, tool cards, and the
|
||||
diff seam can all be built without rebuilding Go.
|
||||
|
||||
## Test
|
||||
|
||||
The desktop package is a nested Go module, so parent `go test ./...` does not run
|
||||
it. Use the full lane before merging desktop changes, and the short lane for fast
|
||||
local feedback:
|
||||
|
||||
```sh
|
||||
make desktop-test # cd desktop && go test .
|
||||
make desktop-test-short # skips slow desktop integration/e2e checks
|
||||
```
|
||||
|
||||
To find the next bottleneck, rank individual test cases from the JSON stream:
|
||||
|
||||
```sh
|
||||
make desktop-test-times
|
||||
# or: cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
|
||||
```
|
||||
|
||||
### Frontend UI review checklist
|
||||
|
||||
For anchored menus, dropdowns, tooltips, and other portaled UI, review both the
|
||||
@@ -101,7 +119,10 @@ Desktop releases ride their own tag namespace, `desktop-v<semver>` (plain `v*`
|
||||
tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`,
|
||||
which builds on a native runner per platform (Wails can't cross-compile a
|
||||
CGO/WebKit binary), packages each artifact, signs it with minisign, generates a
|
||||
`latest.json` manifest, publishes a GitHub release, and mirrors everything to R2.
|
||||
`latest.json` manifest, publishes a GitHub release, marks the desktop release as
|
||||
GitHub's repository-wide `Latest`, mirrors everything to R2, and attaches the
|
||||
current desktop manifest to the matching CLI release for old clients that still
|
||||
ask GitHub's repository-wide `latest` release for it.
|
||||
The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
|
||||
`libwebkit2gtk-4.1-0` at runtime — present by default on Ubuntu 22.04+, Fedora 40+.
|
||||
|
||||
@@ -109,9 +130,12 @@ The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
|
||||
git tag desktop-v1.1.0 && git push origin desktop-v1.1.0
|
||||
```
|
||||
|
||||
The app checks `latest.json` on startup (R2 first, GitHub as fallback) and shows
|
||||
an update banner when a newer version is published; **Settings → Software update**
|
||||
has a manual check. Self-update behavior by platform:
|
||||
The app checks `latest.json` on startup (R2 first, then the
|
||||
`crash.reasonix.io` desktop release gateway) and shows an update banner when a
|
||||
newer version is published; **Settings → Software update** has a manual check.
|
||||
The gateway resolves only the desktop `desktop-v*` release line and never uses
|
||||
GitHub's repository-wide `/releases/latest` shortcut, so updater behavior does
|
||||
not depend on homepage badge semantics. Self-update behavior by platform:
|
||||
|
||||
- **Linux / Windows** — download, verify the minisign signature, then update in
|
||||
place: Linux replaces the binary and relaunches; Windows runs the per-user NSIS
|
||||
|
||||
+1627
-393
File diff suppressed because it is too large.
Load diff
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -49,7 +50,12 @@ func waitForFile(t *testing.T, path, want string) {
|
||||
|
||||
func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
waitForAutosaveIdleWithin(t, tab, 2*time.Second)
|
||||
}
|
||||
|
||||
func waitForAutosaveIdleWithin(t *testing.T, tab *WorkspaceTab, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
tab.saveMu.Lock()
|
||||
idle := !tab.saving && !tab.saveAgain
|
||||
@@ -87,13 +93,12 @@ func appWithTab(t *testing.T, path string) (*App, *WorkspaceTab) {
|
||||
// nil sink ctx (no webview) must not disable persistence.
|
||||
func TestTurnDonePersistsSession(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
_ = path
|
||||
a, tab := appWithTab(t, path)
|
||||
_, tab := appWithTab(t, path)
|
||||
|
||||
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
|
||||
waitForFile(t, path, "remember this turn")
|
||||
_ = a
|
||||
waitForAutosaveIdle(t, tab)
|
||||
}
|
||||
|
||||
// TestNonTurnDoneDoesNotPersist confirms only TurnDone triggers a save, so the
|
||||
@@ -132,6 +137,314 @@ func TestScheduleSnapshotCoalesces(t *testing.T) {
|
||||
waitForAutosaveIdle(t, tab)
|
||||
}
|
||||
|
||||
func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir blocked path: %v", err)
|
||||
}
|
||||
a, tab := appWithTab(t, path)
|
||||
_ = a
|
||||
|
||||
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
waitForAutosaveIdleWithin(t, tab, 5*time.Second)
|
||||
|
||||
tab.saveMu.Lock()
|
||||
failures := tab.saveFailures
|
||||
tab.saveMu.Unlock()
|
||||
if failures == 0 {
|
||||
t.Fatal("autosave failure should be recorded and retried")
|
||||
}
|
||||
if info, err := os.Stat(path); err != nil || !info.IsDir() {
|
||||
t.Fatalf("blocked session path should still be the directory, info=%v err=%v", info, err)
|
||||
}
|
||||
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatalf("remove blocked dir: %v", err)
|
||||
}
|
||||
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
waitForFile(t, path, "remember this turn")
|
||||
waitForAutosaveIdle(t, tab)
|
||||
|
||||
tab.saveMu.Lock()
|
||||
failures = tab.saveFailures
|
||||
tab.saveMu.Unlock()
|
||||
if failures != 0 {
|
||||
t.Fatalf("autosave failures after recovery = %d, want 0", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
originalPath := filepath.Join(dir, "session.jsonl")
|
||||
originalTopic := "topic_original"
|
||||
if err := setTopicTitle("", originalTopic, "Original"); err != nil {
|
||||
t.Fatalf("set original topic title: %v", err)
|
||||
}
|
||||
current := agent.NewSession("sys")
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
||||
if err := current.Save(originalPath); err != nil {
|
||||
t.Fatalf("Save current: %v", err)
|
||||
}
|
||||
if err := agent.SaveBranchMeta(originalPath, agent.BranchMeta{
|
||||
Scope: "global",
|
||||
TopicID: originalTopic,
|
||||
TopicTitle: "Original",
|
||||
Preview: "first",
|
||||
Turns: 2,
|
||||
SchemaVersion: agent.BranchMetaCountsVersion,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveBranchMeta original: %v", err)
|
||||
}
|
||||
|
||||
staleSess := agent.NewSession("sys")
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
|
||||
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{},
|
||||
detachedSessions: map[string]*WorkspaceTab{},
|
||||
activeTabID: "recovery_tab",
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "recovery_tab",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
TopicID: originalTopic,
|
||||
TopicTitle: "Original",
|
||||
SessionPath: originalPath,
|
||||
Ready: true,
|
||||
model: "test-model",
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
tab.Ctrl = control.New(control.Options{
|
||||
Executor: staleExec,
|
||||
SessionDir: dir,
|
||||
SessionPath: originalPath,
|
||||
Label: "test",
|
||||
Sink: tab.sink,
|
||||
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
|
||||
OnSessionRecovered: app.handleTabSessionRecovered(tab),
|
||||
})
|
||||
app.tabs[tab.ID] = tab
|
||||
|
||||
if err := tab.Ctrl.Snapshot(); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
recoveryPath := tab.Ctrl.SessionPath()
|
||||
if recoveryPath == "" || recoveryPath == originalPath {
|
||||
t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
|
||||
}
|
||||
if tab.SessionPath != recoveryPath {
|
||||
t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
|
||||
}
|
||||
if tab.TopicID != originalTopic {
|
||||
t.Fatalf("tab topic ID = %q, want original topic %q", tab.TopicID, originalTopic)
|
||||
}
|
||||
meta, ok, err := agent.LoadBranchMeta(recoveryPath)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
|
||||
}
|
||||
if !meta.Recovered || meta.TopicID != tab.TopicID || meta.TopicTitle != tab.TopicTitle {
|
||||
t.Fatalf("recovery meta = %+v, tab topic=%q/%q", meta, tab.TopicID, tab.TopicTitle)
|
||||
}
|
||||
tabMeta := app.tabMeta(tab, true)
|
||||
if !tabMeta.Recovered || tabMeta.RecoveryDigest != meta.RecoveryDigest || tabMeta.RecoveryParentID != string(meta.ParentID) {
|
||||
t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
|
||||
}
|
||||
nodes := app.ListProjectTree()
|
||||
foundOriginal := false
|
||||
var walk func([]ProjectNode)
|
||||
walk = func(list []ProjectNode) {
|
||||
for _, node := range list {
|
||||
if node.Recovered {
|
||||
t.Fatalf("project tree should hide recovery metadata, got node %+v", node)
|
||||
}
|
||||
if node.TopicID == originalTopic {
|
||||
foundOriginal = true
|
||||
}
|
||||
walk(node.Children)
|
||||
}
|
||||
}
|
||||
walk(nodes)
|
||||
if !foundOriginal {
|
||||
t.Fatalf("project tree did not include original topic %q: %#v", originalTopic, nodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSnapshotConflictRecoveryRequiresRecoveryLease(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
originalPath := filepath.Join(dir, "session.jsonl")
|
||||
current := agent.NewSession("sys")
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
||||
if err := current.Save(originalPath); err != nil {
|
||||
t.Fatalf("Save current: %v", err)
|
||||
}
|
||||
|
||||
staleSess := agent.NewSession("sys")
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
|
||||
recovery, err := staleSess.SaveRecoveryBranch(agent.RecoveryBranchOptions{
|
||||
OriginalPath: originalPath,
|
||||
BranchMeta: agent.BranchMeta{
|
||||
Name: agent.RecoveryBranchDefaultName,
|
||||
Scope: "global",
|
||||
TopicID: "topic_recovery",
|
||||
TopicTitle: "Recovery",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRecoveryBranch: %v", err)
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(recovery.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease recovery: %v", err)
|
||||
}
|
||||
defer lease.Release()
|
||||
|
||||
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
|
||||
runtimeEvents := make(chan runtimeEventEnvelope, 4)
|
||||
app := &App{
|
||||
ctx: context.Background(),
|
||||
tabs: map[string]*WorkspaceTab{},
|
||||
detachedSessions: map[string]*WorkspaceTab{},
|
||||
activeTabID: "recovery_tab",
|
||||
}
|
||||
app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...interface{}) {
|
||||
runtimeEvents <- runtimeEventEnvelope{
|
||||
ctx: ctx,
|
||||
name: name,
|
||||
payload: append([]interface{}(nil), payload...),
|
||||
}
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "recovery_tab",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
TopicID: "topic_original",
|
||||
TopicTitle: "Original",
|
||||
SessionPath: originalPath,
|
||||
Ready: true,
|
||||
model: "test-model",
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
tab.Ctrl = control.New(control.Options{
|
||||
Executor: staleExec,
|
||||
SessionDir: dir,
|
||||
SessionPath: originalPath,
|
||||
Label: "test",
|
||||
Sink: tab.sink,
|
||||
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
|
||||
OnSessionRecovered: app.handleTabSessionRecovered(tab),
|
||||
})
|
||||
app.tabs[tab.ID] = tab
|
||||
|
||||
err = tab.Ctrl.Snapshot()
|
||||
if !errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
t.Fatalf("Snapshot err = %v, want ErrSessionLeaseHeld", err)
|
||||
}
|
||||
if got := tab.Ctrl.SessionPath(); got != originalPath {
|
||||
t.Fatalf("controller session path = %q, want original %q", got, originalPath)
|
||||
}
|
||||
if tab.SessionPath != originalPath {
|
||||
t.Fatalf("tab session path = %q, want original %q", tab.SessionPath, originalPath)
|
||||
}
|
||||
if tab.TopicID != "topic_original" {
|
||||
t.Fatalf("tab topic ID = %q, want original topic", tab.TopicID)
|
||||
}
|
||||
|
||||
deadline := time.After(time.Second)
|
||||
for {
|
||||
select {
|
||||
case emitted := <-runtimeEvents:
|
||||
if emitted.name != "session:recovery-failed" {
|
||||
continue
|
||||
}
|
||||
if len(emitted.payload) != 1 {
|
||||
t.Fatalf("session:recovery-failed payload count = %d, want 1", len(emitted.payload))
|
||||
}
|
||||
failed, ok := emitted.payload[0].(sessionRecoveryFailedEvent)
|
||||
if !ok {
|
||||
t.Fatalf("session:recovery-failed payload type = %T, want sessionRecoveryFailedEvent", emitted.payload[0])
|
||||
}
|
||||
if failed.Reason != "lease_held" {
|
||||
t.Fatalf("session:recovery-failed reason = %q, want lease_held", failed.Reason)
|
||||
}
|
||||
return
|
||||
case <-deadline:
|
||||
t.Fatal("session:recovery-failed event was not emitted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetActiveTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir blocked path: %v", err)
|
||||
}
|
||||
a, _ := appWithTab(t, path)
|
||||
a.tabs["target_tab"] = &WorkspaceTab{
|
||||
ID: "target_tab",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
a.tabOrder = []string{"test_tab", "target_tab"}
|
||||
|
||||
err := a.SetActiveTab("target_tab")
|
||||
if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
|
||||
t.Fatalf("SetActiveTab error = %v, want persistence failure", err)
|
||||
}
|
||||
if a.activeTabID != "test_tab" {
|
||||
t.Fatalf("active tab = %q, want original tab after failed save", a.activeTabID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebindSessionBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir blocked path: %v", err)
|
||||
}
|
||||
a, tab := appWithTab(t, path)
|
||||
target := filepath.Join(t.TempDir(), "target.jsonl")
|
||||
sess := agent.NewSession("system")
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "target prompt"})
|
||||
if err := sess.Save(target); err != nil {
|
||||
t.Fatalf("save target: %v", err)
|
||||
}
|
||||
loaded, err := agent.LoadSession(target)
|
||||
if err != nil {
|
||||
t.Fatalf("load target: %v", err)
|
||||
}
|
||||
|
||||
err = a.rebindTabToLoadedSessionPath(tab, target, loaded)
|
||||
if err == nil || !strings.Contains(err.Error(), "save current session before switching sessions") {
|
||||
t.Fatalf("rebind error = %v, want persistence failure", err)
|
||||
}
|
||||
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
|
||||
t.Fatalf("tab controller/path changed after failed save: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseTabNoResurrectionFromAutosave is the regression test for #4384.
|
||||
// It proves that after CloseTab returns, the per-turn autosave goroutine can no
|
||||
// longer write the session file — even when it is in flight at the moment the
|
||||
@@ -187,6 +500,34 @@ func TestCloseTabNoResurrectionFromAutosave(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseTabBlocksWhenSessionCannotPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir blocked path: %v", err)
|
||||
}
|
||||
a, tab := appWithTab(t, path)
|
||||
survivor := &WorkspaceTab{
|
||||
ID: "survivor_tab",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
|
||||
a.tabs[survivor.ID] = survivor
|
||||
a.tabOrder = []string{tab.ID, survivor.ID}
|
||||
|
||||
err := a.CloseTab(tab.ID)
|
||||
if err == nil || !strings.Contains(err.Error(), "save current session before closing tab") {
|
||||
t.Fatalf("CloseTab error = %v, want persistence failure", err)
|
||||
}
|
||||
if _, ok := a.tabs[tab.ID]; !ok {
|
||||
t.Fatal("tab was removed even though its session could not be saved")
|
||||
}
|
||||
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
|
||||
t.Fatalf("tab controller/path changed after failed close: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseTabSurvivorKeepsAutosave ensures the survivor tab is untouched: the
|
||||
// closing/drain logic is per-tab and must not leak to other tabs.
|
||||
func TestCloseTabSurvivorKeepsAutosave(t *testing.T) {
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -166,6 +168,60 @@ func TestEnsureBlankTabReusesIndexedTopicWithEmptyStub(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabStoresCreatedAt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
before := time.Now().UnixMilli()
|
||||
meta, err := app.EnsureBlankTab("global", "")
|
||||
after := time.Now().UnixMilli()
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
|
||||
createdAt := loadTopicCreatedAt("", meta.TopicID)
|
||||
if createdAt < before || createdAt > after {
|
||||
t.Fatalf("createdAt = %d, want between %d and %d", createdAt, before, after)
|
||||
}
|
||||
|
||||
nodes := app.ListProjectTree()
|
||||
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 {
|
||||
t.Fatalf("project tree = %#v, want Global with one topic", nodes)
|
||||
}
|
||||
if got := nodes[0].Children[0].CreatedAt; got != createdAt {
|
||||
t.Fatalf("project tree createdAt = %d, want %d", got, createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabRepairsMissingCreatedAtForReusedTopic(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
const topicID = "topic_20260704-104018_deadbeef"
|
||||
if err := setTopicTitleWithSource("", topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
if err := prependTopicInProjectsFile("", topicID, false); err != nil {
|
||||
t.Fatalf("prepend topic: %v", err)
|
||||
}
|
||||
if got := loadTopicCreatedAt("", topicID); got != 0 {
|
||||
t.Fatalf("createdAt before reuse = %d, want 0", got)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
meta, err := app.EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if meta.TopicID != topicID {
|
||||
t.Fatalf("EnsureBlankTab topic = %q, want reused topic %q", meta.TopicID, topicID)
|
||||
}
|
||||
|
||||
expected := time.Date(2026, 7, 4, 10, 40, 18, 0, time.UTC).UnixMilli()
|
||||
if got := loadTopicCreatedAt("", topicID); got != expected {
|
||||
t.Fatalf("repaired createdAt = %d, want %d", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureBlankTab reuses an already-open project-scoped blank tab.
|
||||
|
||||
func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
|
||||
@@ -189,6 +245,221 @@ func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabStartsProjectRuntimeWithCurrentWorkspacePrompt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("project", projectA)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project A): %v", err)
|
||||
}
|
||||
tabA := waitForTabReady(t, app, first.ID)
|
||||
if got := normalizeProjectRoot(tabA.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
|
||||
t.Fatalf("project A controller workspace root = %q, want %q", got, normalizeProjectRoot(projectA))
|
||||
}
|
||||
|
||||
second, err := app.EnsureBlankTab("project", projectB)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project B): %v", err)
|
||||
}
|
||||
if second.ID == first.ID {
|
||||
t.Fatalf("EnsureBlankTab reused project A tab %q for project B", second.ID)
|
||||
}
|
||||
tabB := waitForTabReady(t, app, second.ID)
|
||||
|
||||
if got := normalizeProjectRoot(tabB.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("project B tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("project B controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
if !sameDesktopPath(tabB.Ctrl.SessionDir(), desktopSessionDir(projectB)) {
|
||||
t.Fatalf("project B controller session dir = %q, want %q", tabB.Ctrl.SessionDir(), desktopSessionDir(projectB))
|
||||
}
|
||||
if !sameDesktopPath(filepath.Dir(tabB.Ctrl.SessionPath()), desktopSessionDir(projectB)) {
|
||||
t.Fatalf("project B controller session path = %q, want under %q", tabB.Ctrl.SessionPath(), desktopSessionDir(projectB))
|
||||
}
|
||||
sys := systemPromptFrom(tabB.Ctrl.History())
|
||||
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
|
||||
t.Fatalf("project B system prompt missing current workspace %q:\n%s", projectB, sys)
|
||||
}
|
||||
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
|
||||
t.Fatalf("project B system prompt retained project A workspace %q:\n%s", projectA, sys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlankTabSessionPathRejectsOtherProjectWorkspace(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
pathA, err := createEmptySessionFile(desktopSessionDir(projectA), "test-model")
|
||||
if err != nil {
|
||||
t.Fatalf("create project A empty session: %v", err)
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "blank-project-b",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectB,
|
||||
SessionPath: pathA,
|
||||
}
|
||||
|
||||
if blankTabSessionPathHasNoContent(tab) {
|
||||
t.Fatalf("blank tab treated session %q from project A as reusable for project B %q", pathA, projectB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForkKeepsProjectWorkspacePrompt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("project", projectA)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project A): %v", err)
|
||||
}
|
||||
waitForTabReady(t, app, first.ID)
|
||||
|
||||
second, err := app.EnsureBlankTab("project", projectB)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project B): %v", err)
|
||||
}
|
||||
tabB := waitForTabReady(t, app, second.ID)
|
||||
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
|
||||
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
|
||||
|
||||
forked, err := app.Fork(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("Fork: %v", err)
|
||||
}
|
||||
if forked.ID == "" || forked.ID == second.ID {
|
||||
t.Fatalf("forked tab ID = %q, want a fresh tab distinct from %q", forked.ID, second.ID)
|
||||
}
|
||||
forkTab := waitForTabReady(t, app, forked.ID)
|
||||
if got := normalizeProjectRoot(forkTab.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("fork tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
if got := normalizeProjectRoot(forkTab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("fork controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
sys := systemPromptFrom(forkTab.Ctrl.History())
|
||||
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
|
||||
t.Fatalf("fork system prompt missing project B workspace %q:\n%s", projectB, sys)
|
||||
}
|
||||
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
|
||||
t.Fatalf("fork system prompt retained project A workspace %q:\n%s", projectA, sys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewindKeepsProjectWorkspacePrompt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("project", projectA)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project A): %v", err)
|
||||
}
|
||||
waitForTabReady(t, app, first.ID)
|
||||
|
||||
second, err := app.EnsureBlankTab("project", projectB)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project B): %v", err)
|
||||
}
|
||||
tabB := waitForTabReady(t, app, second.ID)
|
||||
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
|
||||
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
|
||||
|
||||
if err := app.Rewind(turn, "conversation"); err != nil {
|
||||
t.Fatalf("Rewind: %v", err)
|
||||
}
|
||||
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("rewound controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
sys := systemPromptFrom(tabB.Ctrl.History())
|
||||
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
|
||||
t.Fatalf("rewound system prompt missing project B workspace %q:\n%s", projectB, sys)
|
||||
}
|
||||
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
|
||||
t.Fatalf("rewound system prompt retained project A workspace %q:\n%s", projectA, sys)
|
||||
}
|
||||
}
|
||||
|
||||
func installStubControllerWithCurrentPrompt(t *testing.T, app *App, tab *WorkspaceTab) *control.Controller {
|
||||
t.Helper()
|
||||
if tab == nil || tab.Ctrl == nil {
|
||||
t.Fatal("tab controller is required")
|
||||
}
|
||||
sys := systemPromptFrom(tab.Ctrl.History())
|
||||
if strings.TrimSpace(sys) == "" {
|
||||
t.Fatal("tab controller did not expose a system prompt")
|
||||
}
|
||||
sessionDir := tab.Ctrl.SessionDir()
|
||||
sessionPath := tab.Ctrl.SessionPath()
|
||||
workspaceRoot := tab.Ctrl.WorkspaceRoot()
|
||||
label := tab.Ctrl.Label()
|
||||
tab.Ctrl.Close()
|
||||
|
||||
sess := agent.NewSession(sys)
|
||||
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{
|
||||
Runner: ag,
|
||||
Executor: ag,
|
||||
SessionDir: sessionDir,
|
||||
SessionPath: sessionPath,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Label: label,
|
||||
SystemPrompt: sys,
|
||||
Sink: event.Discard,
|
||||
})
|
||||
tab.Ctrl = ctrl
|
||||
app.bindControllerDisplayRecorder(ctrl)
|
||||
return ctrl
|
||||
}
|
||||
|
||||
func submitStubTurnAndWaitForCheckpoint(t *testing.T, ctrl control.SessionAPI, input string) int {
|
||||
t.Helper()
|
||||
ctrl.SubmitUserTurn(input, input)
|
||||
waitNotRunning(t, ctrl)
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
checkpoints := ctrl.Checkpoints()
|
||||
if len(checkpoints) > 0 {
|
||||
return checkpoints[len(checkpoints)-1].Turn
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("controller did not record a checkpoint")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabResetsReusableAutoTopicTitle(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
@@ -371,6 +642,41 @@ func TestEnsureBlankTabDoesNotReuseProjectTopicWithSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureBlankTab must not reuse a tombstoned topic: the reused ID would flow
|
||||
// into ensureTopicIndexed, whose intentional prepend clears the delete
|
||||
// tombstone and resurrects the topic the user removed.
|
||||
func TestEnsureBlankTabDoesNotReuseTombstonedTopic(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
// Race product on disk: deleted topic whose default title lingered in the
|
||||
// global title map (title-only, absent from GlobalTopics, no sessions).
|
||||
tombstonedID := "topic_tombstone_blank"
|
||||
if err := setTopicTitle("", tombstonedID, defaultTopicTitle); err != nil {
|
||||
t.Fatalf("set lingering title: %v", err)
|
||||
}
|
||||
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
||||
f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
|
||||
return true, nil
|
||||
}); err != nil {
|
||||
t.Fatalf("seed tombstone: %v", err)
|
||||
}
|
||||
|
||||
meta, err := NewApp().EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if meta.TopicID == tombstonedID {
|
||||
t.Fatalf("EnsureBlankTab reused tombstoned topic %q", meta.TopicID)
|
||||
}
|
||||
f := loadProjectsFile()
|
||||
if !containsDesktopString(f.DeletedTopics, tombstonedID) {
|
||||
t.Fatalf("deletedTopics = %#v, tombstone must survive blank-tab creation", f.DeletedTopics)
|
||||
}
|
||||
if containsDesktopString(f.GlobalTopics, tombstonedID) {
|
||||
t.Fatalf("globalTopics = %#v, tombstoned topic must not be re-indexed", f.GlobalTopics)
|
||||
}
|
||||
}
|
||||
|
||||
// NewSession skips the snapshot when the current tab has no real conversation content.
|
||||
|
||||
func TestNewSessionNoopsWhenCurrentTabIsBlank(t *testing.T) {
|
||||
|
||||
+2430
-15
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReportTabSnapshotErrorDebouncesAutosave(t *testing.T) {
|
||||
app := NewApp()
|
||||
tab := &WorkspaceTab{ID: "tab_warn", sink: &tabEventSink{tabID: "tab_warn", app: app}}
|
||||
failure := errors.New("disk unhappy")
|
||||
|
||||
app.reportTabSnapshotError(tab, "autosave", failure)
|
||||
tab.saveMu.Lock()
|
||||
first := tab.lastAutosaveWarnAt
|
||||
tab.saveMu.Unlock()
|
||||
if first.IsZero() {
|
||||
t.Fatal("first autosave warning did not record its timestamp")
|
||||
}
|
||||
|
||||
// Within the window: suppressed, timestamp untouched.
|
||||
app.reportTabSnapshotError(tab, "autosave", failure)
|
||||
tab.saveMu.Lock()
|
||||
second := tab.lastAutosaveWarnAt
|
||||
tab.saveMu.Unlock()
|
||||
if !second.Equal(first) {
|
||||
t.Fatalf("suppressed warning moved the debounce timestamp: %v -> %v", first, second)
|
||||
}
|
||||
|
||||
// Window expired: warns again.
|
||||
tab.saveMu.Lock()
|
||||
tab.lastAutosaveWarnAt = time.Now().Add(-autosaveWarnInterval - time.Second)
|
||||
tab.saveMu.Unlock()
|
||||
app.reportTabSnapshotError(tab, "autosave", failure)
|
||||
tab.saveMu.Lock()
|
||||
third := tab.lastAutosaveWarnAt
|
||||
tab.saveMu.Unlock()
|
||||
if !third.After(first) {
|
||||
t.Fatal("expired window did not re-arm the autosave warning")
|
||||
}
|
||||
|
||||
// Explicit user actions are never debounced (state untouched).
|
||||
app.reportTabSnapshotError(tab, "changing model", failure)
|
||||
tab.saveMu.Lock()
|
||||
fourth := tab.lastAutosaveWarnAt
|
||||
tab.saveMu.Unlock()
|
||||
if !fourth.Equal(third) {
|
||||
t.Fatal("action-save warning must not consume the autosave debounce window")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/bot/feishu"
|
||||
"reasonix/internal/bot/weixin"
|
||||
"reasonix/internal/botruntime"
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
@@ -48,6 +49,7 @@ type BotConnectionView struct {
|
||||
Model string `json:"model"`
|
||||
ToolApprovalMode string `json:"toolApprovalMode"`
|
||||
WorkspaceRoot string `json:"workspaceRoot"`
|
||||
Access BotAccessView `json:"access"`
|
||||
Credential BotConnectionCredentialView `json:"credential"`
|
||||
SessionMappings []BotConnectionSessionMappingView `json:"sessionMappings"`
|
||||
LastError string `json:"lastError"`
|
||||
@@ -136,12 +138,19 @@ func (a *App) StartBotConnectionInstall(provider, domain string) (BotInstallStar
|
||||
|
||||
func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult, error) {
|
||||
installID = strings.TrimSpace(installID)
|
||||
// Copy the session under a.mu: overlapping polls of the same install can
|
||||
// race the locked PollDomain upgrade below with unlocked field reads.
|
||||
a.mu.RLock()
|
||||
session := a.botInstalls[installID]
|
||||
sessionPtr := a.botInstalls[installID]
|
||||
var sessionCopy botInstallSession
|
||||
if sessionPtr != nil {
|
||||
sessionCopy = *sessionPtr
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
if session == nil {
|
||||
if sessionPtr == nil {
|
||||
return BotInstallPollResult{Error: "install session not found"}, nil
|
||||
}
|
||||
session := &sessionCopy
|
||||
if time.Now().After(session.ExpireAt) {
|
||||
a.deleteBotInstall(installID)
|
||||
return BotInstallPollResult{Status: "expired", Error: "install session expired"}, nil
|
||||
@@ -164,6 +173,7 @@ func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult,
|
||||
Label: "微信",
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
Access: botInstallAccess(result.UserID),
|
||||
Credential: config.BotConnectionCredential{AccountID: result.AccountID, TokenEnv: "WEIXIN_BOT_TOKEN"},
|
||||
}, func(c *config.Config) {
|
||||
c.Bot.Enabled = true
|
||||
@@ -518,6 +528,7 @@ func (a *App) pollFeishuConnectionInstall(installID string, session *botInstallS
|
||||
Label: label,
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
Access: botInstallAccess(userID),
|
||||
Credential: config.BotConnectionCredential{AppID: appID, AppSecretEnv: secretEnv},
|
||||
}, func(c *config.Config) {
|
||||
c.Bot.Enabled = true
|
||||
@@ -545,6 +556,9 @@ func (a *App) upsertBotConnection(conn config.BotConnectionConfig, updateLegacy
|
||||
if conn.Status == "" {
|
||||
conn.Status = "connected"
|
||||
}
|
||||
if normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode) == "" {
|
||||
conn.ToolApprovalMode = "ask"
|
||||
}
|
||||
if conn.ID == "" {
|
||||
conn.ID = connectionID(conn.Provider, conn.Domain)
|
||||
}
|
||||
@@ -556,6 +570,9 @@ func (a *App) upsertBotConnection(conn config.BotConnectionConfig, updateLegacy
|
||||
for i, existing := range c.Bot.Connections {
|
||||
if existing.ID == conn.ID {
|
||||
conn.CreatedAt = firstNonEmptyBot(existing.CreatedAt, conn.CreatedAt)
|
||||
if !botruntime.BotAccessActive(conn.Access) && botruntime.BotAccessActive(existing.Access) {
|
||||
conn.Access = existing.Access
|
||||
}
|
||||
c.Bot.Connections[i] = conn
|
||||
replaced = true
|
||||
break
|
||||
@@ -697,6 +714,7 @@ func botConnectionView(conn config.BotConnectionConfig) BotConnectionView {
|
||||
return BotConnectionView{
|
||||
ID: conn.ID, Provider: conn.Provider, Domain: conn.Domain, Label: conn.Label, Enabled: conn.Enabled, Status: conn.Status,
|
||||
Model: conn.Model, ToolApprovalMode: normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode), WorkspaceRoot: conn.WorkspaceRoot,
|
||||
Access: botAccessViewFromConfig(conn.Access),
|
||||
Credential: BotConnectionCredentialView{
|
||||
AppID: conn.Credential.AppID, AppSecretEnv: conn.Credential.AppSecretEnv, AccountID: conn.Credential.AccountID, TokenEnv: conn.Credential.TokenEnv,
|
||||
SecretSet: botCredentialSecretSet(conn),
|
||||
@@ -763,8 +781,9 @@ func botConnectionConfig(view BotConnectionView) config.BotConnectionConfig {
|
||||
Enabled: view.Enabled,
|
||||
Status: strings.TrimSpace(view.Status),
|
||||
Model: strings.TrimSpace(view.Model),
|
||||
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode),
|
||||
ToolApprovalMode: firstNonEmptyBot(normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode), "ask"),
|
||||
WorkspaceRoot: strings.TrimSpace(view.WorkspaceRoot),
|
||||
Access: botAccessConfigFromView(view.Access),
|
||||
Credential: config.BotConnectionCredential{
|
||||
AppID: strings.TrimSpace(view.Credential.AppID),
|
||||
AppSecretEnv: strings.TrimSpace(view.Credential.AppSecretEnv),
|
||||
@@ -873,6 +892,15 @@ func connectionID(provider, domain string) string {
|
||||
return strings.Trim(strings.ToLower(provider+"-"+domain), "-")
|
||||
}
|
||||
|
||||
func botInstallAccess(userID string) config.BotAccessConfig {
|
||||
userID = strings.TrimSpace(userID)
|
||||
access := config.BotAccessConfig{Enabled: true, PairingEnabled: true}
|
||||
if userID != "" {
|
||||
access.Users = []string{userID}
|
||||
}
|
||||
return access
|
||||
}
|
||||
|
||||
func randomInstallID() string {
|
||||
var b [12]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
const botForwardSendTimeout = 30 * time.Second
|
||||
const botForwardQueueSize = 64
|
||||
|
||||
// ── Forward target ──────────────────────────────────────────────────────────
|
||||
|
||||
// botForwardTarget identifies one remote chat to send forwarded events to.
|
||||
type botForwardTarget struct {
|
||||
ConnID string
|
||||
Domain string
|
||||
ChatID string
|
||||
ChatType bot.ChatType
|
||||
}
|
||||
|
||||
// ── Event forwarder ─────────────────────────────────────────────────────────
|
||||
|
||||
// botEventForwarder implements event.Sink and forwards relevant events to
|
||||
// connected bot channels through the desktopBotRuntime. It is attached to a
|
||||
// tabEventSink when a heartbeat task should push AI output to IM channels.
|
||||
//
|
||||
// It accumulates Text events and sends them as complete messages on TurnDone
|
||||
// (and occasionally during generation when the buffer grows large enough), so
|
||||
// the remote side sees progressive streaming output rather than one big blob.
|
||||
type botEventForwarder struct {
|
||||
runtime *desktopBotRuntime
|
||||
targets []botForwardTarget
|
||||
|
||||
mu sync.Mutex
|
||||
buf strings.Builder
|
||||
queueMu sync.Mutex
|
||||
queue chan string
|
||||
closed bool
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// newBotEventForwarder creates a forwarder that sends to all given targets.
|
||||
// runtime may be nil — Emit calls are then no-ops.
|
||||
func newBotEventForwarder(runtime *desktopBotRuntime, targets []botForwardTarget) *botEventForwarder {
|
||||
f := &botEventForwarder{
|
||||
runtime: runtime,
|
||||
targets: targets,
|
||||
queue: make(chan string, botForwardQueueSize),
|
||||
}
|
||||
go f.run()
|
||||
return f
|
||||
}
|
||||
|
||||
// Emit implements event.Sink. It forwards text and lifecycle events to the
|
||||
// connected bot channels; reasoning, tool dispatch, and other internal events
|
||||
// are dropped to avoid noisy IM output.
|
||||
func (f *botEventForwarder) Emit(e event.Event) {
|
||||
if f.runtime == nil || len(f.targets) == 0 {
|
||||
return
|
||||
}
|
||||
switch e.Kind {
|
||||
case event.TurnStarted:
|
||||
f.mu.Lock()
|
||||
f.buf.Reset()
|
||||
f.mu.Unlock()
|
||||
|
||||
case event.Text:
|
||||
f.mu.Lock()
|
||||
f.buf.WriteString(e.Text)
|
||||
size := f.buf.Len()
|
||||
f.mu.Unlock()
|
||||
// Flush opportunistically when the buffer crosses a threshold, so long
|
||||
// streams (e.g. "tell me three jokes") produce multiple messages.
|
||||
if size >= 400 {
|
||||
f.flush()
|
||||
}
|
||||
|
||||
case event.TurnDone:
|
||||
f.flush()
|
||||
f.Close()
|
||||
|
||||
case event.ApprovalRequest:
|
||||
// The heartbeat turn belongs to the desktop tab controller, not the bot
|
||||
// gateway session, so remote /approve replies cannot satisfy this ID.
|
||||
text := "⚠️ 需要在 Reasonix 桌面端批准操作: " + e.Approval.Tool + " — " + e.Approval.Subject
|
||||
text += "\n请回到桌面窗口处理。"
|
||||
f.sendToAll(text)
|
||||
|
||||
case event.AskRequest:
|
||||
var qb strings.Builder
|
||||
qb.WriteString("❓ 需要在 Reasonix 桌面端回答问题:\n")
|
||||
for i, q := range e.Ask.Questions {
|
||||
if i > 0 {
|
||||
qb.WriteString("\n")
|
||||
}
|
||||
qb.WriteString(q.Prompt)
|
||||
}
|
||||
qb.WriteString("\n请回到桌面窗口处理。")
|
||||
f.sendToAll(qb.String())
|
||||
|
||||
case event.Notice:
|
||||
if e.Level == event.LevelWarn {
|
||||
f.sendToAll("⚠️ " + e.Text)
|
||||
}
|
||||
|
||||
case event.CompactionStarted:
|
||||
f.sendToAll("🔄 正在压缩上下文...")
|
||||
}
|
||||
}
|
||||
|
||||
// flush sends the accumulated buffer as one message per target channel.
|
||||
func (f *botEventForwarder) flush() {
|
||||
f.mu.Lock()
|
||||
text := strings.TrimSpace(f.buf.String())
|
||||
if text == "" {
|
||||
f.mu.Unlock()
|
||||
return
|
||||
}
|
||||
f.buf.Reset()
|
||||
f.mu.Unlock()
|
||||
|
||||
f.sendToAll(text)
|
||||
}
|
||||
|
||||
// sendToAll dispatches text to every target channel. Errors are logged and
|
||||
// non-fatal; a failed target does not block other targets.
|
||||
func (f *botEventForwarder) sendToAll(text string) {
|
||||
text = strings.TrimSpace(text)
|
||||
if f.runtime == nil || len(f.targets) == 0 || text == "" {
|
||||
return
|
||||
}
|
||||
f.queueMu.Lock()
|
||||
defer f.queueMu.Unlock()
|
||||
if f.closed {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case f.queue <- text:
|
||||
default:
|
||||
log.Printf("[bot-forward] send queue full; dropping message for %d target(s)", len(f.targets))
|
||||
}
|
||||
}
|
||||
|
||||
func (f *botEventForwarder) run() {
|
||||
for text := range f.queue {
|
||||
f.sendToAllNow(text)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *botEventForwarder) sendToAllNow(text string) {
|
||||
for _, tgt := range f.targets {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), botForwardSendTimeout)
|
||||
_, err := f.runtime.SendToAdapter(ctx, tgt.ConnID, tgt.Domain, bot.OutboundMessage{
|
||||
ChatID: tgt.ChatID,
|
||||
ChatType: tgt.ChatType,
|
||||
Text: text,
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("[bot-forward] send to %s/%s failed: %v", tgt.ConnID, tgt.ChatType, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *botEventForwarder) Close() {
|
||||
f.closeOnce.Do(func() {
|
||||
f.flush()
|
||||
f.queueMu.Lock()
|
||||
f.closed = true
|
||||
close(f.queue)
|
||||
f.queueMu.Unlock()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
type desktopForwardTestAdapter struct {
|
||||
platform bot.Platform
|
||||
name string
|
||||
messages chan bot.InboundMessage
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
sent chan bot.OutboundMessage
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newDesktopForwardTestAdapter() *desktopForwardTestAdapter {
|
||||
return &desktopForwardTestAdapter{
|
||||
platform: bot.PlatformFeishu,
|
||||
name: "forward-test",
|
||||
messages: make(chan bot.InboundMessage),
|
||||
entered: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
sent: make(chan bot.OutboundMessage, 8),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *desktopForwardTestAdapter) Platform() bot.Platform { return a.platform }
|
||||
func (a *desktopForwardTestAdapter) Name() string { return a.name }
|
||||
func (a *desktopForwardTestAdapter) Start(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
func (a *desktopForwardTestAdapter) Stop() error { return nil }
|
||||
func (a *desktopForwardTestAdapter) SendTyping(context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
func (a *desktopForwardTestAdapter) Messages() <-chan bot.InboundMessage {
|
||||
return a.messages
|
||||
}
|
||||
func (a *desktopForwardTestAdapter) Send(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
|
||||
a.once.Do(func() { close(a.entered) })
|
||||
select {
|
||||
case <-a.release:
|
||||
case <-ctx.Done():
|
||||
return bot.SendResult{}, ctx.Err()
|
||||
}
|
||||
a.sent <- msg
|
||||
return bot.SendResult{MessageID: "sent"}, nil
|
||||
}
|
||||
|
||||
func newDesktopForwardTestRuntime(adapter bot.Adapter) *desktopBotRuntime {
|
||||
gw := bot.NewGatewayWithAdapterBindings(bot.GatewayConfig{}, []bot.AdapterBinding{{
|
||||
ID: "feishu-lark",
|
||||
Domain: "lark",
|
||||
Platform: bot.PlatformFeishu,
|
||||
Adapter: adapter,
|
||||
}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
return &desktopBotRuntime{gw: gw}
|
||||
}
|
||||
|
||||
func TestBotEventForwarderDoesNotBlockEventEmissionOnSlowSend(t *testing.T) {
|
||||
adapter := newDesktopForwardTestAdapter()
|
||||
forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
|
||||
ConnID: "feishu-lark",
|
||||
Domain: "lark",
|
||||
ChatID: "oc-group-1",
|
||||
ChatType: bot.ChatGroup,
|
||||
}})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
forwarder.Emit(event.Event{Kind: event.Text, Text: strings.Repeat("x", 400)})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("Emit blocked behind slow bot send")
|
||||
}
|
||||
select {
|
||||
case <-adapter.entered:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("adapter send did not start")
|
||||
}
|
||||
|
||||
forwarder.Close()
|
||||
close(adapter.release)
|
||||
select {
|
||||
case <-adapter.sent:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("queued bot message was not sent after adapter release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotEventForwarderApprovalNoticeDoesNotExposeReplyID(t *testing.T) {
|
||||
adapter := newDesktopForwardTestAdapter()
|
||||
close(adapter.release)
|
||||
forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
|
||||
ConnID: "feishu-lark",
|
||||
Domain: "lark",
|
||||
ChatID: "oc-group-1",
|
||||
ChatType: bot.ChatGroup,
|
||||
}})
|
||||
|
||||
forwarder.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
|
||||
ID: "approval-1",
|
||||
Tool: "shell",
|
||||
Subject: "run command",
|
||||
}})
|
||||
forwarder.Close()
|
||||
|
||||
select {
|
||||
case msg := <-adapter.sent:
|
||||
if strings.Contains(msg.Text, "approval-1") || strings.Contains(msg.Text, "/approve") {
|
||||
t.Fatalf("approval notice exposed unusable reply routing: %q", msg.Text)
|
||||
}
|
||||
if !strings.Contains(msg.Text, "桌面") {
|
||||
t.Fatalf("approval notice = %q, want desktop guidance", msg.Text)
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("approval notice was not sent")
|
||||
}
|
||||
}
|
||||
+168
-27
@@ -23,16 +23,45 @@ type BotRuntimeStatusView struct {
|
||||
}
|
||||
|
||||
type desktopBotRuntime struct {
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
gw *bot.BotGateway
|
||||
status BotRuntimeStatusView
|
||||
// lifecycleMu serializes start/stop transitions so two apply/stop calls
|
||||
// can't race a gateway into existence. The slow work (gw.Stop teardown,
|
||||
// gw.Start dials) runs while holding it but NOT r.mu, so status/send reads
|
||||
// never block on a restart.
|
||||
lifecycleMu sync.Mutex
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
gw *bot.BotGateway
|
||||
status BotRuntimeStatusView
|
||||
}
|
||||
|
||||
func newDesktopBotRuntime() *desktopBotRuntime {
|
||||
return &desktopBotRuntime{status: BotRuntimeStatusView{Status: "stopped", Message: "bot runtime is not started"}}
|
||||
}
|
||||
|
||||
func desktopBotChannelsWithLegacyQQ(qq config.QQBotConfig, channels map[bot.Platform]bot.ChannelConfig, connectionChannels map[string]bot.ChannelConfig) (map[bot.Platform]bot.ChannelConfig, map[string]bot.ChannelConfig) {
|
||||
channel := bot.ChannelConfig{
|
||||
Model: strings.TrimSpace(qq.Model),
|
||||
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(qq.ToolApprovalMode),
|
||||
WorkspaceRoot: strings.TrimSpace(qq.WorkspaceRoot),
|
||||
}
|
||||
if channel.Model == "" && channel.ToolApprovalMode == "" && channel.WorkspaceRoot == "" {
|
||||
return channels, connectionChannels
|
||||
}
|
||||
if channels == nil {
|
||||
channels = make(map[bot.Platform]bot.ChannelConfig)
|
||||
}
|
||||
if _, ok := channels[bot.PlatformQQ]; !ok {
|
||||
channels[bot.PlatformQQ] = channel
|
||||
}
|
||||
if connectionChannels == nil {
|
||||
connectionChannels = make(map[string]bot.ChannelConfig)
|
||||
}
|
||||
if _, ok := connectionChannels[string(bot.PlatformQQ)]; !ok {
|
||||
connectionChannels[string(bot.PlatformQQ)] = channel
|
||||
}
|
||||
return channels, connectionChannels
|
||||
}
|
||||
|
||||
func (a *App) refreshBotRuntimeAsync() {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
@@ -41,8 +70,11 @@ func (a *App) refreshBotRuntimeAsync() {
|
||||
}
|
||||
|
||||
func (a *App) refreshBotRuntime() {
|
||||
// NewApp always pre-fills botRuntime; a nil here means a test-constructed
|
||||
// App with no bot runtime, which must not lazily create one from a
|
||||
// background goroutine (that would race a concurrent refresh).
|
||||
if a.botRuntime == nil {
|
||||
a.botRuntime = newDesktopBotRuntime()
|
||||
return
|
||||
}
|
||||
cfg, err := a.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
@@ -53,7 +85,11 @@ func (a *App) refreshBotRuntime() {
|
||||
}
|
||||
|
||||
func (a *App) loadDesktopBotConfig() (*config.Config, error) {
|
||||
cfg, _, err := a.loadDesktopUserConfigForEdit()
|
||||
// Read-only load feeding the bot runtime and connection diagnostics. It
|
||||
// must load credentials: the runtime resolves app secrets and control
|
||||
// tokens from the process env (AppSecretEnv, Control.TokenEnv), which the
|
||||
// credential-free view load would leave unset on a fresh process.
|
||||
cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -81,24 +117,44 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
parent = context.Background()
|
||||
}
|
||||
plan := desktopBotRuntimePlan(cfg)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.stopLocked()
|
||||
r.lifecycleMu.Lock()
|
||||
defer r.lifecycleMu.Unlock()
|
||||
r.stopCurrent()
|
||||
if !plan.Start {
|
||||
r.status = BotRuntimeStatusView{Status: plan.Status, Message: plan.Message}
|
||||
r.setStatus(BotRuntimeStatusView{Status: plan.Status, Message: plan.Message})
|
||||
return nil
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
modelName := botruntime.ModelName(cfg, "")
|
||||
channels := botruntime.ChannelConfigs(cfg.Bot.Connections, true, true)
|
||||
connectionChannels := botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true)
|
||||
channels, connectionChannels = desktopBotChannelsWithLegacyQQ(cfg.Bot.QQ, channels, connectionChannels)
|
||||
gwCfg := bot.GatewayConfig{
|
||||
Model: modelName,
|
||||
ToolApprovalMode: cfg.Bot.ToolApprovalMode,
|
||||
MaxSteps: cfg.Bot.MaxSteps,
|
||||
QueueMode: cfg.Bot.QueueMode,
|
||||
QueueCap: cfg.Bot.QueueCap,
|
||||
QueueDrop: cfg.Bot.QueueDrop,
|
||||
PairingEnabled: cfg.Bot.Pairing.Enabled,
|
||||
PairingTTL: time.Duration(cfg.Bot.Pairing.RequestTTLMinutes) * time.Minute,
|
||||
PairingMaxPending: cfg.Bot.Pairing.MaxPendingPerPlatform,
|
||||
IgnoreSelfMessages: cfg.Bot.IgnoreSelfMessages,
|
||||
SelfUserIDs: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.SelfUserIDs.QQ,
|
||||
bot.PlatformFeishu: cfg.Bot.SelfUserIDs.Feishu,
|
||||
bot.PlatformWeixin: cfg.Bot.SelfUserIDs.Weixin,
|
||||
},
|
||||
ControlEnabled: cfg.Bot.Control.Enabled,
|
||||
ControlAddr: cfg.Bot.Control.Addr,
|
||||
ControlToken: os.Getenv(strings.TrimSpace(cfg.Bot.Control.TokenEnv)),
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Channels: botruntime.ChannelConfigs(cfg.Bot.Connections, true, true),
|
||||
ConnectionChannels: botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true),
|
||||
Channels: channels,
|
||||
ConnectionChannels: connectionChannels,
|
||||
Routes: botruntime.RouteConfigs(cfg.Bot.Routes, true, true),
|
||||
ConnectionAccess: botruntime.ConnectionAccessConfigs(cfg),
|
||||
Enabled: plan.Enabled,
|
||||
Allowlist: bot.AllowlistConfig{
|
||||
Enabled: cfg.Bot.Allowlist.Enabled,
|
||||
@@ -108,6 +164,16 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuUsers,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinUsers,
|
||||
},
|
||||
Approvers: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQApprovers,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuApprovers,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinApprovers,
|
||||
},
|
||||
Admins: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQAdmins,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuAdmins,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinAdmins,
|
||||
},
|
||||
Groups: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQGroups,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuGroups,
|
||||
@@ -122,14 +188,14 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
bindings := botruntime.AdapterBindings(cfg, plan.Enabled, nil, logger)
|
||||
if len(bindings) == 0 {
|
||||
cancel()
|
||||
r.status = BotRuntimeStatusView{Status: "stopped", Message: "no bot adapters configured"}
|
||||
r.setStatus(BotRuntimeStatusView{Status: "stopped", Message: "no bot adapters configured"})
|
||||
return nil
|
||||
}
|
||||
gw := bot.NewGatewayWithAdapterBindings(gwCfg, bindings, logger)
|
||||
if err := gw.Start(ctx); err != nil {
|
||||
cancel()
|
||||
gw.Stop()
|
||||
r.status = BotRuntimeStatusView{Status: "error", Message: err.Error(), Connections: gw.AdapterCount()}
|
||||
r.setStatus(BotRuntimeStatusView{Status: "error", Message: err.Error(), Connections: gw.AdapterCount()})
|
||||
return err
|
||||
}
|
||||
runningConnections := gw.AdapterCount()
|
||||
@@ -140,6 +206,7 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
status = "degraded"
|
||||
message = fmt.Sprintf("%d bot connection(s) running; %d failed to start: %s", runningConnections, len(startErrors), summarizeBotRuntimeErrors(startErrors))
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.cancel = cancel
|
||||
r.gw = gw
|
||||
r.status = BotRuntimeStatusView{
|
||||
@@ -149,6 +216,7 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
Connections: runningConnections,
|
||||
StartedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -206,8 +274,8 @@ func desktopBotRuntimePlan(cfg *config.Config) botRuntimePlan {
|
||||
if !cfg.Bot.Enabled {
|
||||
return botRuntimePlan{Status: "stopped", Message: "bot is disabled"}
|
||||
}
|
||||
if !cfg.Bot.Allowlist.AllowAll && (!cfg.Bot.Allowlist.Enabled || botruntime.AllowlistUserCount(cfg.Bot.Allowlist) == 0) {
|
||||
return botRuntimePlan{Status: "blocked", Message: "bot requires an allowlist or allow_all=true"}
|
||||
if !botruntime.BotConfigHasAccessControl(cfg.Bot) {
|
||||
return botRuntimePlan{Status: "blocked", Message: "bot requires an allowlist, pairing, per-bot access, or allow_all=true"}
|
||||
}
|
||||
enabled, unknown := botruntime.EnabledPlatforms(cfg, nil)
|
||||
if len(unknown) > 0 {
|
||||
@@ -220,23 +288,36 @@ func desktopBotRuntimePlan(cfg *config.Config) botRuntimePlan {
|
||||
}
|
||||
|
||||
func (r *desktopBotRuntime) stop(status, message string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.stopLocked()
|
||||
r.status = BotRuntimeStatusView{Status: status, Message: message}
|
||||
r.lifecycleMu.Lock()
|
||||
defer r.lifecycleMu.Unlock()
|
||||
r.stopCurrent()
|
||||
r.setStatus(BotRuntimeStatusView{Status: status, Message: message})
|
||||
}
|
||||
|
||||
func (r *desktopBotRuntime) stopLocked() {
|
||||
if r.cancel != nil {
|
||||
r.cancel()
|
||||
r.cancel = nil
|
||||
// stopCurrent detaches the running gateway under r.mu, then tears it down
|
||||
// off-lock: gw.Stop() closes every session controller (up to the jobs teardown
|
||||
// grace each) and must not stall status/send readers. Callers hold lifecycleMu.
|
||||
func (r *desktopBotRuntime) stopCurrent() {
|
||||
r.mu.Lock()
|
||||
cancel := r.cancel
|
||||
gw := r.gw
|
||||
r.cancel = nil
|
||||
r.gw = nil
|
||||
r.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if r.gw != nil {
|
||||
r.gw.Stop()
|
||||
r.gw = nil
|
||||
if gw != nil {
|
||||
gw.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *desktopBotRuntime) setStatus(status BotRuntimeStatusView) {
|
||||
r.mu.Lock()
|
||||
r.status = status
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *desktopBotRuntime) snapshot() BotRuntimeStatusView {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@@ -258,3 +339,63 @@ func (r *desktopBotRuntime) updateConnectionToolApprovalMode(connID, mode string
|
||||
r.gw.UpdateConnectionToolApprovalMode(connID, mode)
|
||||
return true
|
||||
}
|
||||
|
||||
// SendToAdapter sends a message through the running gateway's adapter
|
||||
// identified by connID. Returns an error if the gateway is not running
|
||||
// or no matching adapter is found.
|
||||
func (r *desktopBotRuntime) SendToAdapter(ctx context.Context, connID, domain string, msg bot.OutboundMessage) (bot.SendResult, error) {
|
||||
r.mu.Lock()
|
||||
gw := r.gw
|
||||
r.mu.Unlock()
|
||||
if gw == nil {
|
||||
return bot.SendResult{}, nil // gateway not running — silent no-op
|
||||
}
|
||||
return gw.SendToAdapter(ctx, connID, domain, msg)
|
||||
}
|
||||
|
||||
// Running returns true if the bot gateway is currently active.
|
||||
func (r *desktopBotRuntime) Running() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.gw != nil
|
||||
}
|
||||
|
||||
// ForwardTargets returns the list of bot forward targets derived from the
|
||||
// current config's bot connections and their session mappings. Each mapping
|
||||
// produces one target (connID + chatID + chatType) for event forwarding.
|
||||
func (r *desktopBotRuntime) ForwardTargets(cfg *config.Config) []botForwardTarget {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
var targets []botForwardTarget
|
||||
seen := make(map[botForwardTarget]bool)
|
||||
for _, conn := range cfg.Bot.Connections {
|
||||
if !conn.Enabled {
|
||||
continue
|
||||
}
|
||||
connID := botruntime.ConnectionRuntimeID(conn)
|
||||
domain := strings.TrimSpace(conn.Domain)
|
||||
for _, sm := range conn.SessionMappings {
|
||||
remoteID := strings.TrimSpace(sm.RemoteID)
|
||||
if remoteID == "" {
|
||||
continue
|
||||
}
|
||||
chatType := bot.ChatDM
|
||||
if sm.ChatType != "" {
|
||||
chatType = bot.ChatType(sm.ChatType)
|
||||
}
|
||||
target := botForwardTarget{
|
||||
ConnID: connID,
|
||||
Domain: domain,
|
||||
ChatID: remoteID,
|
||||
ChatType: chatType,
|
||||
}
|
||||
if seen[target] {
|
||||
continue
|
||||
}
|
||||
seen[target] = true
|
||||
targets = append(targets, target)
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
@@ -39,6 +39,7 @@ func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Enabled = true
|
||||
cfg.Bot.Allowlist.Enabled = true
|
||||
cfg.Bot.Pairing.Enabled = false
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
|
||||
}
|
||||
@@ -49,6 +50,44 @@ func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimePlanStartsWithPairing(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Enabled = true
|
||||
cfg.Bot.Allowlist.Enabled = true
|
||||
cfg.Bot.Pairing.Enabled = true
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
|
||||
}
|
||||
|
||||
plan := desktopBotRuntimePlan(cfg)
|
||||
if !plan.Start {
|
||||
t.Fatalf("plan = %+v, want start with pairing enabled", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotChannelsWithLegacyQQConfig(t *testing.T) {
|
||||
channels, connectionChannels := desktopBotChannelsWithLegacyQQ(config.QQBotConfig{
|
||||
Model: "qq-model",
|
||||
ToolApprovalMode: "auto",
|
||||
WorkspaceRoot: "/tmp/qq-project",
|
||||
}, nil, nil)
|
||||
|
||||
channel, ok := channels[bot.PlatformQQ]
|
||||
if !ok {
|
||||
t.Fatalf("platform QQ channel missing: %+v", channels)
|
||||
}
|
||||
if channel.Model != "qq-model" || channel.ToolApprovalMode != "auto" || channel.WorkspaceRoot != "/tmp/qq-project" {
|
||||
t.Fatalf("platform channel = %+v, want QQ-specific runtime fields", channel)
|
||||
}
|
||||
connectionChannel, ok := connectionChannels["qq"]
|
||||
if !ok {
|
||||
t.Fatalf("connection QQ channel missing: %+v", connectionChannels)
|
||||
}
|
||||
if connectionChannel.Model != "qq-model" || connectionChannel.ToolApprovalMode != "auto" || connectionChannel.WorkspaceRoot != "/tmp/qq-project" {
|
||||
t.Fatalf("connection channel = %+v, want QQ-specific runtime fields", connectionChannel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Enabled = false
|
||||
@@ -63,6 +102,37 @@ func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimeForwardTargetsDeduplicatesMappedChats(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{{
|
||||
ID: "feishu-lark",
|
||||
Provider: "feishu",
|
||||
Domain: "lark",
|
||||
Enabled: true,
|
||||
SessionMappings: []config.BotConnectionSessionMapping{
|
||||
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-1"},
|
||||
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-2"},
|
||||
{RemoteID: "oc-dm-1", ChatType: string(bot.ChatDM), UserID: "ou-user-1"},
|
||||
},
|
||||
}}
|
||||
|
||||
targets := newDesktopBotRuntime().ForwardTargets(cfg)
|
||||
if len(targets) != 2 {
|
||||
t.Fatalf("targets = %+v, want one group target plus one dm target", targets)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, target := range targets {
|
||||
key := target.ConnID + "|" + target.Domain + "|" + target.ChatID + "|" + string(target.ChatType)
|
||||
if seen[key] {
|
||||
t.Fatalf("duplicate target %q in %+v", key, targets)
|
||||
}
|
||||
seen[key] = true
|
||||
}
|
||||
if !seen["feishu-lark|lark|oc-group-1|group"] || !seen["feishu-lark|lark|oc-dm-1|dm"] {
|
||||
t.Fatalf("targets = %+v, want group and dm targets", targets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimeConfigUsesUserBotSettings(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
@@ -232,7 +302,8 @@ status = "connected"
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
got, err := NewApp().loadDesktopBotConfig()
|
||||
app := NewApp()
|
||||
got, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
@@ -240,6 +311,17 @@ status = "connected"
|
||||
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
|
||||
}
|
||||
|
||||
// The bot-runtime load is a pure read: the merge above stays in memory and
|
||||
// the user config file is not rewritten.
|
||||
preWrite := config.LoadForEdit(config.UserConfigPath())
|
||||
if preWrite.Bot.Enabled || len(preWrite.Bot.Connections) != 0 {
|
||||
t.Fatalf("read path persisted bot config = %+v, want disk untouched until a locked write", preWrite.Bot)
|
||||
}
|
||||
|
||||
// The first locked write path performs the on-disk migration.
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
persisted := config.LoadForEdit(config.UserConfigPath())
|
||||
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
|
||||
@@ -281,7 +363,8 @@ status = "connected"
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
got, err := NewApp().loadDesktopBotConfig()
|
||||
app := NewApp()
|
||||
got, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
@@ -289,13 +372,21 @@ status = "connected"
|
||||
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
|
||||
}
|
||||
|
||||
// The bot-runtime load is a pure read: it serves the legacy config from
|
||||
// memory and must not create the user config file.
|
||||
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("read path must not create the user config, stat err = %v", err)
|
||||
}
|
||||
|
||||
// The first locked write path creates the user config with the migrated
|
||||
// bot settings (adopting the legacy config, ConfigVersion-bumped).
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
persisted := config.LoadForEdit(config.UserConfigPath())
|
||||
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
|
||||
}
|
||||
if persisted.DesktopTheme() == "dark" {
|
||||
t.Fatal("legacy project desktop theme should not be persisted during bot-only migration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSettingsBotMigrationPersistsOnlyBotBeforeFirstEdit(t *testing.T) {
|
||||
|
||||
@@ -42,9 +42,9 @@ func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) {
|
||||
if got := app.HooksSettings("global"); got.Hooks == nil || got.Events == nil {
|
||||
t.Fatalf("HooksSettings(global) arrays = hooks:%v events:%v, want non-nil", got.Hooks, got.Events)
|
||||
}
|
||||
if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderKinds == nil ||
|
||||
if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderPresets == nil || got.ProviderKinds == nil ||
|
||||
got.Permissions.Allow == nil || got.Permissions.Ask == nil || got.Permissions.Deny == nil ||
|
||||
got.Sandbox.AllowWrite == nil ||
|
||||
got.Sandbox.AllowWrite == nil || got.Sandbox.EffectiveWriteRoots == nil ||
|
||||
got.Bot.Allowlist.QQUsers == nil || got.Bot.Allowlist.FeishuUsers == nil || got.Bot.Allowlist.WeixinUsers == nil ||
|
||||
got.Bot.Allowlist.QQGroups == nil || got.Bot.Allowlist.FeishuGroups == nil || got.Bot.Allowlist.WeixinGroups == nil {
|
||||
t.Fatalf("Settings() contains nil array fields: %+v", got)
|
||||
|
||||
@@ -36,6 +36,7 @@ Unicode true
|
||||
####
|
||||
!include "wails_tools.nsh"
|
||||
!include "FileFunc.nsh"
|
||||
!include "LogicLib.nsh"
|
||||
|
||||
# The version information for this two must consist of 4 parts
|
||||
VIProductVersion "${INFO_PRODUCTVERSION}.0"
|
||||
@@ -76,6 +77,8 @@ ManifestDPIAware true
|
||||
Name "${INFO_PRODUCTNAME}"
|
||||
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
|
||||
!define REASONIX_DEFAULT_INSTALLDIR "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}"
|
||||
!define REASONIX_UPDATE_HELPER "reasonix-update-helper.exe"
|
||||
!define REASONIX_UNLOCK_RETRIES 60
|
||||
InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation" # Reuse the previous install path on update; .onInit falls back to the default on first install.
|
||||
InstallDir "${REASONIX_DEFAULT_INSTALLDIR}" # Per-user install location (no admin rights required).
|
||||
ShowInstDetails show # This will always show the installation details.
|
||||
@@ -130,14 +133,54 @@ fallback:
|
||||
done:
|
||||
FunctionEnd
|
||||
|
||||
Function reasonix.waitForExecutableUnlock
|
||||
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 done
|
||||
StrCpy $0 0
|
||||
|
||||
retry:
|
||||
ClearErrors
|
||||
FileOpen $1 "$INSTDIR\${PRODUCT_EXECUTABLE}" a
|
||||
IfErrors locked
|
||||
FileClose $1
|
||||
Goto done
|
||||
|
||||
locked:
|
||||
IntOp $0 $0 + 1
|
||||
IntCmp $0 ${REASONIX_UNLOCK_RETRIES} failed 0 0
|
||||
Sleep 1000
|
||||
Goto retry
|
||||
|
||||
failed:
|
||||
IfSilent silent interactive
|
||||
|
||||
interactive:
|
||||
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "Reasonix is still running. Close Reasonix, then click Retry to continue the installation." IDRETRY retry IDCANCEL abort
|
||||
Goto retry
|
||||
|
||||
silent:
|
||||
SetErrorLevel 1618
|
||||
|
||||
abort:
|
||||
Abort "Reasonix is still running. Close Reasonix and run the installer again."
|
||||
|
||||
done:
|
||||
FunctionEnd
|
||||
|
||||
Section
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
!insertmacro wails.webview2runtime
|
||||
|
||||
Call reasonix.waitForExecutableUnlock
|
||||
|
||||
SetOutPath $INSTDIR
|
||||
|
||||
!insertmacro wails.files
|
||||
!if /FileExists "${REASONIX_UPDATE_HELPER}"
|
||||
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
|
||||
!else
|
||||
!warning "${REASONIX_UPDATE_HELPER} was not found; Windows auto-update will fall back to installer-side waiting only."
|
||||
!endif
|
||||
|
||||
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
@@ -155,6 +198,7 @@ Section "uninstall"
|
||||
|
||||
; Precision uninstall: delete main application files
|
||||
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
|
||||
|
||||
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
|
||||
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
|
||||
|
||||
@@ -25,6 +25,28 @@ func seedCheckpoint(t *testing.T, ckptDir string, c checkpoint.Checkpoint) {
|
||||
}
|
||||
}
|
||||
|
||||
func assertCheckpointFilesEncodeAsArray(t *testing.T, metas []CheckpointMeta) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(metas)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload []struct {
|
||||
Files json.RawMessage `json:"files"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, item := range payload {
|
||||
if string(item.Files) == "null" {
|
||||
t.Fatalf("checkpoint %d files encoded as null; frontend expects []", i)
|
||||
}
|
||||
if len(item.Files) == 0 || item.Files[0] != '[' {
|
||||
t.Fatalf("checkpoint %d files encoded as %s, want JSON array", i, item.Files)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckpointsCanCodePropagatesToEarlierTurns covers #3438: RestoreCode(turn)
|
||||
// reverts files touched in that turn or any later one, so a turn with no file
|
||||
// changes of its own can still rewind code when a later turn changed files. The
|
||||
@@ -77,4 +99,56 @@ func TestCheckpointsCanCodePropagatesToEarlierTurns(t *testing.T) {
|
||||
if len(metas[0].Files) != 1 || metas[0].Files[0] != "a.txt" {
|
||||
t.Fatalf("turn 0 cumulative files = %#v, want [a.txt]", metas[0].Files)
|
||||
}
|
||||
if metas[0].FileCount != 1 || metas[0].FilesTruncated {
|
||||
t.Fatalf("turn 0 file summary = count %d truncated %v, want count 1 truncated false", metas[0].FileCount, metas[0].FilesTruncated)
|
||||
}
|
||||
if len(metas[2].Files) != 0 {
|
||||
t.Fatalf("turn 2 cumulative files = %#v, want empty", metas[2].Files)
|
||||
}
|
||||
assertCheckpointFilesEncodeAsArray(t, metas)
|
||||
}
|
||||
|
||||
func TestCheckpointsForTabLimitsCumulativeFilePreview(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "s.jsonl")
|
||||
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
|
||||
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "old"
|
||||
files := make([]checkpoint.FileSnap, 0, checkpointFilePreviewLimit+5)
|
||||
for i := 0; i < checkpointFilePreviewLimit+5; i++ {
|
||||
files = append(files, checkpoint.FileSnap{Path: "file-" + strconv.Itoa(1000+i) + ".txt", Content: &content})
|
||||
}
|
||||
now := time.Now()
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 0, Time: now, Prompt: "before edits", MsgIndex: 0})
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "edit many files", MsgIndex: 2, Files: files})
|
||||
|
||||
ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
|
||||
ctrl.SetSessionPath(sessionPath)
|
||||
|
||||
app := &App{}
|
||||
app.setTestCtrl(ctrl, "test")
|
||||
|
||||
metas := app.CheckpointsForTab("test")
|
||||
if len(metas) != 2 {
|
||||
t.Fatalf("checkpoints = %d, want 2", len(metas))
|
||||
}
|
||||
if metas[0].FileCount != checkpointFilePreviewLimit+5 {
|
||||
t.Fatalf("turn 0 cumulative file count = %d, want %d", metas[0].FileCount, checkpointFilePreviewLimit+5)
|
||||
}
|
||||
if len(metas[0].Files) != checkpointFilePreviewLimit {
|
||||
t.Fatalf("turn 0 preview files = %d, want %d", len(metas[0].Files), checkpointFilePreviewLimit)
|
||||
}
|
||||
if !metas[0].FilesTruncated {
|
||||
t.Fatal("turn 0 should mark file preview as truncated")
|
||||
}
|
||||
if metas[0].TurnFileCount != 0 {
|
||||
t.Fatalf("turn 0 file count = %d, want 0 for this turn", metas[0].TurnFileCount)
|
||||
}
|
||||
if metas[1].TurnFileCount != checkpointFilePreviewLimit+5 {
|
||||
t.Fatalf("turn 1 file count = %d, want %d", metas[1].TurnFileCount, checkpointFilePreviewLimit+5)
|
||||
}
|
||||
assertCheckpointFilesEncodeAsArray(t, metas)
|
||||
}
|
||||
@@ -166,12 +166,12 @@ func signFiles(files []string) error {
|
||||
// release tag used in download URLs (e.g. "desktop-v1.1.0").
|
||||
func genManifest(dir, version, tag string) error {
|
||||
repo := os.Getenv("GITHUB_REPOSITORY")
|
||||
if repo == "" {
|
||||
repo = "esengine/reasonix"
|
||||
if repo == "" || repo == "esengine/reasonix" {
|
||||
repo = "esengine/DeepSeek-Reasonix"
|
||||
}
|
||||
m := update.Manifest{
|
||||
Version: version,
|
||||
DownloadPage: fmt.Sprintf("https://github.com/%s/releases/latest", repo),
|
||||
DownloadPage: "https://reasonix.io/#start",
|
||||
Platforms: map[string]update.Asset{},
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
|
||||
@@ -85,6 +85,9 @@ func TestGenManifest(t *testing.T) {
|
||||
if m.Version != "v1.2.0" {
|
||||
t.Fatalf("version = %q, want v1.2.0", m.Version)
|
||||
}
|
||||
if m.DownloadPage != "https://reasonix.io/#start" {
|
||||
t.Fatalf("download_page = %q, want official install page", m.DownloadPage)
|
||||
}
|
||||
if len(m.Platforms) != 5 {
|
||||
t.Fatalf("want 5 platforms, got %d: %v", len(m.Platforms), m.Platforms)
|
||||
}
|
||||
@@ -92,7 +95,7 @@ func TestGenManifest(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("windows-amd64 missing")
|
||||
}
|
||||
wantURL := "https://github.com/esengine/reasonix/releases/download/desktop-v1.2.0/Reasonix-windows-amd64-installer.exe"
|
||||
wantURL := "https://github.com/esengine/DeepSeek-Reasonix/releases/download/desktop-v1.2.0/Reasonix-windows-amd64-installer.exe"
|
||||
if win.URL != wantURL {
|
||||
t.Fatalf("windows url = %q, want %q", win.URL, wantURL)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func installerCommandLine(installer, dir string) string {
|
||||
line := fmt.Sprintf(`"%s" /S`, installer)
|
||||
if dir != "" {
|
||||
line += " /D=" + dir
|
||||
}
|
||||
return line
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallerCommandLineIsSilentAndLeavesDFlagLast(t *testing.T) {
|
||||
got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
|
||||
want := `"C:\Temp\Reasonix Installer.exe" /S /D=D:\Tools\Reasonix App`
|
||||
if got != want {
|
||||
t.Fatalf("installerCommandLine = %q, want %q", got, want)
|
||||
}
|
||||
if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) {
|
||||
t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows desktop builds")
|
||||
os.Exit(2)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const parentExitTimeout = 2 * time.Minute
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:]))
|
||||
}
|
||||
|
||||
func run(args []string) int {
|
||||
var parentPID uint
|
||||
var installer, installDir, relaunch string
|
||||
fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
|
||||
fs.StringVar(&installer, "installer", "", "verified NSIS installer path")
|
||||
fs.StringVar(&installDir, "install-dir", "", "Reasonix installation directory")
|
||||
fs.StringVar(&relaunch, "relaunch", "", "Reasonix executable to start after the installer succeeds")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
logger := newLogger()
|
||||
if installer == "" {
|
||||
logger.Print("missing --installer")
|
||||
return 2
|
||||
}
|
||||
if parentPID != 0 {
|
||||
if err := waitForProcessExit(uint32(parentPID), parentExitTimeout); err != nil {
|
||||
logger.Printf("wait for parent process %d: %v", parentPID, err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if err := runInstaller(installer, installDir); err != nil {
|
||||
logger.Printf("run installer: %v", err)
|
||||
return 1
|
||||
}
|
||||
if relaunch != "" {
|
||||
if err := startRelaunch(relaunch, installDir); err != nil {
|
||||
logger.Printf("relaunch: %v", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func newLogger() *log.Logger {
|
||||
dir, err := os.UserCacheDir()
|
||||
if err == nil {
|
||||
dir = filepath.Join(dir, "Reasonix", "updates")
|
||||
if err := os.MkdirAll(dir, 0o700); err == nil {
|
||||
if f, err := os.OpenFile(filepath.Join(dir, "update-helper.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
|
||||
return log.New(f, "", log.LstdFlags)
|
||||
}
|
||||
}
|
||||
}
|
||||
return log.New(os.Stderr, "", log.LstdFlags)
|
||||
}
|
||||
|
||||
func waitForProcessExit(pid uint32, timeout time.Duration) error {
|
||||
h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, pid)
|
||||
if err != nil {
|
||||
if err == windows.ERROR_INVALID_PARAMETER {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer windows.CloseHandle(h)
|
||||
waitMS := uint32(timeout / time.Millisecond)
|
||||
result, err := windows.WaitForSingleObject(h, waitMS)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch result {
|
||||
case windows.WAIT_OBJECT_0:
|
||||
return nil
|
||||
case uint32(windows.WAIT_TIMEOUT):
|
||||
return fmt.Errorf("timed out after %s", timeout)
|
||||
default:
|
||||
return fmt.Errorf("unexpected wait result %d", result)
|
||||
}
|
||||
}
|
||||
|
||||
func runInstaller(installer, installDir string) error {
|
||||
cmd := exec.Command(installer)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(installer, installDir), HideWindow: true}
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func startRelaunch(relaunch, installDir string) error {
|
||||
cmd := exec.Command(relaunch)
|
||||
cmd.Dir = installDir
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
return cmd.Start()
|
||||
}
|
||||
@@ -46,15 +46,32 @@ func writePendingCrash(site string, r any, stack []byte) {
|
||||
report.Stack = sanitizeCrashText(stackText, maxCrashStackBytes)
|
||||
report.TopFrame = topFrameFromStack(report.Stack)
|
||||
report.Message = msg
|
||||
_ = writePendingReport(report, true)
|
||||
}
|
||||
|
||||
func writePendingReport(report crashReport, overwrite bool) bool {
|
||||
body, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
return
|
||||
return false
|
||||
}
|
||||
path := pendingCrashPath()
|
||||
if os.MkdirAll(filepath.Dir(path), 0o755) != nil {
|
||||
return
|
||||
return false
|
||||
}
|
||||
_ = os.WriteFile(path, body, 0o644)
|
||||
if overwrite {
|
||||
return os.WriteFile(path, body, 0o644) == nil
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer f.Close()
|
||||
n, err := f.Write(body)
|
||||
if err != nil || n != len(body) {
|
||||
_ = os.Remove(path)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *App) goSafe(site string, fn func()) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
@@ -67,6 +68,66 @@ func TestWritePendingCrashCaps(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePendingReportCanAvoidOverwritingExistingCrash(t *testing.T) {
|
||||
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
|
||||
writePendingCrash("panic", "boom", []byte("stack"))
|
||||
before, ok := readPending(t)
|
||||
if !ok {
|
||||
t.Fatal("expected initial pending crash")
|
||||
}
|
||||
|
||||
hang := baseCrashReport("performance")
|
||||
hang.Source = "native.watchdog"
|
||||
hang.Label = "mac.main_thread.hang"
|
||||
hang.Message = "hang"
|
||||
if writePendingReport(hang, false) {
|
||||
t.Fatal("writePendingReport overwrite=false should not replace existing report")
|
||||
}
|
||||
after, ok := readPending(t)
|
||||
if !ok {
|
||||
t.Fatal("expected pending crash after skipped write")
|
||||
}
|
||||
if after.Label != before.Label || after.Message != before.Message {
|
||||
t.Fatalf("pending crash was overwritten: before=%+v after=%+v", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePendingReportNonOverwriteAllowsOneConcurrentWriter(t *testing.T) {
|
||||
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
|
||||
const writers = 32
|
||||
start := make(chan struct{})
|
||||
var ready sync.WaitGroup
|
||||
var done sync.WaitGroup
|
||||
var successes atomic.Int32
|
||||
|
||||
for i := 0; i < writers; i++ {
|
||||
ready.Add(1)
|
||||
done.Add(1)
|
||||
go func() {
|
||||
defer done.Done()
|
||||
report := baseCrashReport("performance")
|
||||
report.Source = "native.watchdog"
|
||||
report.Label = "mac.main_thread.hang"
|
||||
report.Message = strings.Repeat("hang", 1024)
|
||||
ready.Done()
|
||||
<-start
|
||||
if writePendingReport(report, false) {
|
||||
successes.Add(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
ready.Wait()
|
||||
close(start)
|
||||
done.Wait()
|
||||
|
||||
if got := successes.Load(); got != 1 {
|
||||
t.Fatalf("successful non-overwrite writers = %d, want 1", got)
|
||||
}
|
||||
if _, ok := readPending(t); !ok {
|
||||
t.Fatal("expected one pending report")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePendingCrashScrubsSensitiveText(t *testing.T) {
|
||||
t.Cleanup(func() { os.Remove(pendingCrashPath()) })
|
||||
apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
// deferredRebuildRetryInterval is how often the retry loop probes a held
|
||||
// session lease. Package-level so tests can shorten it.
|
||||
var deferredRebuildRetryInterval = 2 * time.Second
|
||||
|
||||
const deferredStartupBuildLabel = "__startup__"
|
||||
|
||||
// deferredRebuildState tracks tabs whose settings were saved to disk but whose
|
||||
// runtime could not refresh, plus tabs whose initial startup failed, because
|
||||
// the session lease was held by another Reasonix process. A single background
|
||||
// loop probes the lease and replays the rebuild once the other side releases
|
||||
// it. The loop only runs after enableDeferredRebuildRetry (the wails startup
|
||||
// hook); tests that never call it get the pending bookkeeping without a
|
||||
// background goroutine.
|
||||
type deferredRebuildState struct {
|
||||
mu sync.Mutex
|
||||
pending map[string]string // tab ID -> setting label for notices
|
||||
enabled bool
|
||||
running bool
|
||||
stopped bool
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
// enableDeferredRebuildRetry arms the retry loop; called from startup.
|
||||
func (a *App) enableDeferredRebuildRetry() {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.enabled = true
|
||||
a.startDeferredRebuildLoopLocked()
|
||||
}
|
||||
|
||||
// startDeferredRebuildLoopLocked starts the loop when it is armed, idle, and
|
||||
// has work. Callers must hold d.mu.
|
||||
func (a *App) startDeferredRebuildLoopLocked() {
|
||||
d := &a.deferredRebuild
|
||||
if !d.enabled || d.running || d.stopped || len(d.pending) == 0 {
|
||||
return
|
||||
}
|
||||
d.running = true
|
||||
if d.stop == nil {
|
||||
d.stop = make(chan struct{})
|
||||
}
|
||||
go a.deferredRebuildLoop(d.stop)
|
||||
}
|
||||
|
||||
// scheduleDeferredRebuild records that tabID needs a runtime refresh for
|
||||
// setting and starts the retry loop if it is not running yet. Repeated calls
|
||||
// for the same tab collapse into one retry carrying the latest label.
|
||||
func (a *App) scheduleDeferredRebuild(tabID, setting string) {
|
||||
tabID = strings.TrimSpace(tabID)
|
||||
if tabID == "" {
|
||||
return
|
||||
}
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.stopped {
|
||||
return
|
||||
}
|
||||
if d.pending == nil {
|
||||
d.pending = map[string]string{}
|
||||
}
|
||||
d.pending[tabID] = setting
|
||||
a.startDeferredRebuildLoopLocked()
|
||||
}
|
||||
|
||||
func (a *App) scheduleDeferredStartupBuild(tabID string) {
|
||||
a.scheduleDeferredRebuild(tabID, deferredStartupBuildLabel)
|
||||
}
|
||||
|
||||
func isDeferredStartupBuild(setting string) bool {
|
||||
return setting == deferredStartupBuildLabel
|
||||
}
|
||||
|
||||
func (a *App) clearDeferredRebuild(tabID string) {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
delete(d.pending, tabID)
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *App) deferredRebuildPending(tabID string) bool {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
_, ok := d.pending[tabID]
|
||||
return ok
|
||||
}
|
||||
|
||||
// stopDeferredRebuildRetry permanently stops the retry loop; used on shutdown
|
||||
// and by tests.
|
||||
func (a *App) stopDeferredRebuildRetry() {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.stopped {
|
||||
return
|
||||
}
|
||||
d.stopped = true
|
||||
if d.stop != nil {
|
||||
close(d.stop)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) deferredRebuildLoop(stop <-chan struct{}) {
|
||||
ticker := time.NewTicker(deferredRebuildRetryInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
d.running = false
|
||||
d.mu.Unlock()
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
if a.deferredRebuildTickDone() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deferredRebuildTickDone runs one retry pass and reports true when the loop
|
||||
// should exit because nothing is pending anymore.
|
||||
func (a *App) deferredRebuildTickDone() bool {
|
||||
return a.deferredRebuildTick(true)
|
||||
}
|
||||
|
||||
func (a *App) deferredRebuildTick(markIdle bool) bool {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
if d.stopped || len(d.pending) == 0 {
|
||||
if markIdle {
|
||||
d.running = false
|
||||
}
|
||||
d.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
pending := make(map[string]string, len(d.pending))
|
||||
for id, setting := range d.pending {
|
||||
pending[id] = setting
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
for tabID, setting := range pending {
|
||||
a.retryDeferredRebuild(tabID, setting)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) kickDeferredRebuildRetry() {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
a.goSafe("deferredRebuildKick", func() {
|
||||
_ = a.deferredRebuildTick(false)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) retryDeferredRebuild(tabID, setting string) {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
tab := a.tabByID(tabID)
|
||||
if tab == nil || tab.ID != tabID {
|
||||
// The tab is gone; nothing left to refresh.
|
||||
a.clearDeferredRebuild(tabID)
|
||||
return
|
||||
}
|
||||
if isDeferredStartupBuild(setting) {
|
||||
a.retryDeferredStartupBuild(tabID, tab)
|
||||
return
|
||||
}
|
||||
// Hold the rebuild mutex across probe + rebuild: the probe briefly acquires
|
||||
// the session lease, and a concurrent manual rebuild's ensureSessionLease
|
||||
// would see that probe as "held by another runtime" and spuriously defer.
|
||||
a.runtimeRebuildMu.Lock()
|
||||
defer a.runtimeRebuildMu.Unlock()
|
||||
// rebuildSettingLocked refreshes the active tab only. Wait until the user
|
||||
// is back on this tab so we refresh the runtime the pending setting was
|
||||
// meant for, not whichever tab happens to be focused.
|
||||
if a.activeTab() != tab {
|
||||
return
|
||||
}
|
||||
ctrl := a.controllerForTab(tab)
|
||||
if ctrl == nil {
|
||||
// Mid-(re)build on another path (provider retarget, workspace repair);
|
||||
// racing a second build+swap against it is what this loop must avoid.
|
||||
return
|
||||
}
|
||||
if controllerHasActiveRuntimeWork(ctrl) {
|
||||
return
|
||||
}
|
||||
if !a.deferredRebuildLeaseLooksFree(tab) {
|
||||
return
|
||||
}
|
||||
err := a.rebuildSettingLocked(setting)
|
||||
if err == nil {
|
||||
// rebuildSettingLocked already cleared the pending entry for the tab it
|
||||
// refreshed; just announce it.
|
||||
a.noticeForTab(tabID, fmt.Sprintf("%s applied: session refreshed after the lease was released", setting))
|
||||
return
|
||||
}
|
||||
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
return // grabbed back before we could rebuild; keep waiting
|
||||
}
|
||||
var busy *rebuildBusyError
|
||||
if errors.As(err, &busy) {
|
||||
return // a turn started meanwhile; retry once it finishes
|
||||
}
|
||||
// Anything else will not resolve by waiting; give up loudly instead of
|
||||
// retrying forever.
|
||||
a.clearDeferredRebuild(tabID)
|
||||
slog.Warn("desktop: deferred settings rebuild failed", "setting", setting, "tab", tabID, "err", err)
|
||||
a.warnForTab(tabID, fmt.Sprintf("%s was saved but the session could not refresh: %s", setting, err.Error()))
|
||||
}
|
||||
|
||||
func (a *App) retryDeferredStartupBuild(tabID string, tab *WorkspaceTab) {
|
||||
a.runtimeRebuildMu.Lock()
|
||||
defer a.runtimeRebuildMu.Unlock()
|
||||
if !a.tabHasRetryableStartupLeaseError(tab) {
|
||||
a.clearDeferredRebuild(tabID)
|
||||
return
|
||||
}
|
||||
if !a.deferredRebuildLeaseLooksFree(tab) {
|
||||
return
|
||||
}
|
||||
err := a.rebuildStartupTabLocked(tab)
|
||||
if err == nil {
|
||||
a.clearDeferredRebuild(tabID)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
return
|
||||
}
|
||||
a.clearDeferredRebuild(tabID)
|
||||
slog.Warn("desktop: deferred session startup failed", "tab", tabID, "err", err)
|
||||
}
|
||||
|
||||
func (a *App) tabHasRetryableStartupLeaseError(tab *WorkspaceTab) bool {
|
||||
if tab == nil {
|
||||
return false
|
||||
}
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.tabs[tab.ID] == tab && !tab.removed && tab.Ctrl == nil && tab.StartupErrLeaseHeld
|
||||
}
|
||||
|
||||
func (a *App) rebuildStartupTabLocked(tab *WorkspaceTab) error {
|
||||
buildCtx, cancel := context.WithCancel(a.bootContext())
|
||||
a.mu.Lock()
|
||||
if tab == nil || a.tabs[tab.ID] != tab || tab.removed {
|
||||
a.mu.Unlock()
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
if tab.Ctrl != nil {
|
||||
a.mu.Unlock()
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
if !tab.StartupErrLeaseHeld {
|
||||
a.mu.Unlock()
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
tab.buildGeneration++
|
||||
generation := tab.buildGeneration
|
||||
if tab.buildCancel != nil {
|
||||
tab.buildCancel()
|
||||
}
|
||||
tab.buildCancel = cancel
|
||||
tab.Ready = false
|
||||
clearTabStartupError(tab)
|
||||
tab.ActivityStatus = ""
|
||||
if tab.sink == nil {
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
|
||||
}
|
||||
a.saveTabsLocked()
|
||||
a.mu.Unlock()
|
||||
|
||||
a.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
|
||||
|
||||
a.mu.RLock()
|
||||
stillCurrent := false
|
||||
var ctrl control.SessionAPI
|
||||
startupErr := ""
|
||||
leaseHeld := false
|
||||
if tab != nil {
|
||||
stillCurrent = a.tabs[tab.ID] == tab && !tab.removed
|
||||
ctrl = tab.Ctrl
|
||||
startupErr = tab.StartupErr
|
||||
leaseHeld = tab.StartupErrLeaseHeld
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
if !stillCurrent || ctrl != nil {
|
||||
return nil
|
||||
}
|
||||
if leaseHeld {
|
||||
return agent.ErrSessionLeaseHeld
|
||||
}
|
||||
if strings.TrimSpace(startupErr) != "" {
|
||||
return fmt.Errorf("session startup: %s", startupErr)
|
||||
}
|
||||
return fmt.Errorf("session startup: controller was not built")
|
||||
}
|
||||
|
||||
func (a *App) tryRecoverStartupLeaseHeldTab(tab *WorkspaceTab) bool {
|
||||
if a.ctx == nil || !a.tabHasRetryableStartupLeaseError(tab) {
|
||||
return false
|
||||
}
|
||||
a.runtimeRebuildMu.Lock()
|
||||
defer a.runtimeRebuildMu.Unlock()
|
||||
if !a.tabHasRetryableStartupLeaseError(tab) {
|
||||
return a.controllerForTab(tab) != nil
|
||||
}
|
||||
if !a.deferredRebuildLeaseLooksFree(tab) {
|
||||
return false
|
||||
}
|
||||
err := a.rebuildStartupTabLocked(tab)
|
||||
if err == nil {
|
||||
a.clearDeferredRebuild(tab.ID)
|
||||
return a.controllerForTab(tab) != nil
|
||||
}
|
||||
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
a.scheduleDeferredStartupBuild(tab.ID)
|
||||
} else {
|
||||
a.clearDeferredRebuild(tab.ID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// deferredRebuildLeaseLooksFree cheaply probes whether the tab's session lease
|
||||
// could be acquired right now, without touching tab.sessionLease (only the
|
||||
// serialized rebuild paths may mutate that). The probe path can lag the
|
||||
// reconciled path the rebuild will use; a stale answer either re-defers on the
|
||||
// next tick or lets the rebuild fail back into the pending set, so a mismatch
|
||||
// only delays the retry.
|
||||
func (a *App) deferredRebuildLeaseLooksFree(tab *WorkspaceTab) bool {
|
||||
a.mu.RLock()
|
||||
ctrl := tab.Ctrl
|
||||
path := strings.TrimSpace(tab.SessionPath)
|
||||
a.mu.RUnlock()
|
||||
if ctrl != nil {
|
||||
if p := strings.TrimSpace(ctrl.SessionPath()); p != "" {
|
||||
path = p
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
return true // nothing to probe; let the rebuild decide
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(sessionRuntimeKey(path))
|
||||
if err != nil {
|
||||
var leaseErr *agent.SessionLeaseError
|
||||
if errors.As(err, &leaseErr) && leaseErr.Info != nil && leaseErr.Info.PID == os.Getpid() {
|
||||
if host, _ := os.Hostname(); leaseErr.Info.Hostname == host {
|
||||
// This process (usually this very tab) holds the probed path;
|
||||
// the blocking lease is some other path. Attempt the rebuild
|
||||
// and let its own lease checks decide.
|
||||
return true
|
||||
}
|
||||
}
|
||||
return !errors.Is(err, agent.ErrSessionLeaseHeld)
|
||||
}
|
||||
lease.Release()
|
||||
return true
|
||||
}
|
||||
@@ -10,9 +10,9 @@
|
||||
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
|
||||
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
|
||||
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
|
||||
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
|
||||
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
|
||||
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gsap/react": "^2.1.2",
|
||||
|
||||
@@ -16,7 +16,16 @@ const transpiled = ts.transpileModule(source, {
|
||||
}).outputText;
|
||||
|
||||
const moduleUrl = `data:text/javascript;base64,${Buffer.from(transpiled).toString("base64")}`;
|
||||
const { shouldShowTodoPanel, todoDismissalKey } = await import(moduleUrl);
|
||||
const {
|
||||
dismissedTodoKeyForScope,
|
||||
scopedTodoBatchKey,
|
||||
scopedTodoDismissalKey,
|
||||
shouldOpenTodoPanelByDefault,
|
||||
shouldShowTodoPanel,
|
||||
todoBatchKey,
|
||||
todoDismissalKey,
|
||||
todoPanelScope,
|
||||
} = await import(moduleUrl);
|
||||
|
||||
const completedTodos = [
|
||||
{ content: "Inspect the report", status: "completed" },
|
||||
@@ -42,6 +51,66 @@ assert.equal(
|
||||
false,
|
||||
"a user dismissal still hides that exact todo list",
|
||||
);
|
||||
const completedKey = todoDismissalKey(completedTodos);
|
||||
const dismissedBySession = new Set([scopedTodoDismissalKey("session:a", completedKey)]);
|
||||
assert.equal(
|
||||
shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:a", dismissedBySession, completedKey), completedTodos),
|
||||
false,
|
||||
"a completed todo dismissal hides the list in the session where it was closed",
|
||||
);
|
||||
assert.equal(
|
||||
shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:b", dismissedBySession, completedKey), completedTodos),
|
||||
true,
|
||||
"a completed todo dismissal in one session does not hide another session's list",
|
||||
);
|
||||
dismissedBySession.add(scopedTodoDismissalKey("session:b", completedKey));
|
||||
assert.equal(
|
||||
shouldShowTodoPanel(completedKey, dismissedTodoKeyForScope("session:a", dismissedBySession, completedKey), completedTodos),
|
||||
false,
|
||||
"closing another session's todo does not forget the first session dismissal",
|
||||
);
|
||||
assert.equal(
|
||||
todoPanelScope({
|
||||
activeTabId: "tab-a",
|
||||
activeTab: {
|
||||
id: "tab-a",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
topicId: "topic-a",
|
||||
sessionPath: "/sessions/a.jsonl",
|
||||
},
|
||||
}),
|
||||
"session:/sessions/a.jsonl",
|
||||
"a restored history session uses its session path as the todo panel scope",
|
||||
);
|
||||
assert.equal(
|
||||
todoPanelScope({
|
||||
activeTabId: "tab-b",
|
||||
activeTab: {
|
||||
id: "tab-a",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
topicId: "topic-a",
|
||||
sessionPath: "/sessions/a.jsonl",
|
||||
},
|
||||
eventChannel: "desktop-events",
|
||||
}),
|
||||
"tab:tab-b",
|
||||
"a stale active-tab fallback must not scope dismissal to the previous session",
|
||||
);
|
||||
assert.equal(
|
||||
todoPanelScope({
|
||||
activeTabId: "tab-c",
|
||||
activeTab: {
|
||||
id: "tab-c",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
topicId: "topic-a",
|
||||
},
|
||||
}),
|
||||
"tab:tab-c",
|
||||
"an unsaved topic session uses its tab id so sibling sessions do not share todo state",
|
||||
);
|
||||
assert.equal(shouldShowTodoPanel(null, null, completedTodos), false, "no canonical todo item means no panel");
|
||||
assert.equal(shouldShowTodoPanel("todo-empty", null, []), false, "empty todo lists do not render a panel");
|
||||
|
||||
@@ -61,6 +130,36 @@ assert.notEqual(
|
||||
todoDismissalKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }]),
|
||||
"real progress produces a fresh dismissal key",
|
||||
);
|
||||
assert.equal(
|
||||
todoBatchKey(activeTodos),
|
||||
todoBatchKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }]),
|
||||
"status progress stays in the same todo batch",
|
||||
);
|
||||
assert.equal(
|
||||
scopedTodoBatchKey("session:a", todoBatchKey(activeTodos)),
|
||||
scopedTodoBatchKey("session:a", todoBatchKey([{ ...activeTodos[0], status: "completed" }, { ...activeTodos[1], status: "in_progress" }])),
|
||||
"status progress keeps the same scoped open-state key",
|
||||
);
|
||||
assert.notEqual(
|
||||
scopedTodoBatchKey("session:a", todoBatchKey(activeTodos)),
|
||||
scopedTodoBatchKey("session:b", todoBatchKey(activeTodos)),
|
||||
"the same task list in a different session gets isolated open state",
|
||||
);
|
||||
assert.notEqual(
|
||||
todoBatchKey(activeTodos),
|
||||
todoBatchKey([{ content: "Run a different task", status: "in_progress" }]),
|
||||
"new task content creates a new todo batch",
|
||||
);
|
||||
assert.equal(
|
||||
shouldOpenTodoPanelByDefault(activeTodos),
|
||||
true,
|
||||
"new incomplete todo batches open by default",
|
||||
);
|
||||
assert.equal(
|
||||
shouldOpenTodoPanelByDefault(completedTodos),
|
||||
false,
|
||||
"completed todo batches collapse by default",
|
||||
);
|
||||
|
||||
const iterations = 200_000;
|
||||
const started = performance.now();
|
||||
|
||||
+236
-18
@@ -1,5 +1,5 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, KeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||
import type { CSSProperties, KeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||
import { ShellExpandProvider, useShellExpand } from "./lib/shellExpand";
|
||||
import gsap from "gsap";
|
||||
import { useGSAP } from "@gsap/react";
|
||||
@@ -10,8 +10,11 @@ import {
|
||||
Activity,
|
||||
CircleHelp,
|
||||
Command,
|
||||
Copy as RestoreIcon,
|
||||
Download,
|
||||
Minus,
|
||||
Search,
|
||||
Square,
|
||||
SquarePen,
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
@@ -29,12 +32,14 @@ import {
|
||||
Brain,
|
||||
Cpu,
|
||||
Palette,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useToast } from "./lib/toast";
|
||||
import { useWailsResizeFix } from "./lib/useWailsResizeFix";
|
||||
import { asArray } from "./lib/array";
|
||||
import { clearLegacyLangPref, normalizeLangPref, readLegacyLangPref, useI18n, useT, type Translator } from "./lib/i18n";
|
||||
import { useController, type Item, type LiveStream } from "./lib/useController";
|
||||
import { app, onEvent, onProjectTreeChanged } from "./lib/bridge";
|
||||
import { app, onEvent, onProjectTreeChanged, onSessionRecovered, onSessionRecoveryFailed } from "./lib/bridge";
|
||||
import { generativeMusic, isGenerativeMusicEnabled } from "./lib/generative-music";
|
||||
import { playSuccessChime } from "./lib/sound";
|
||||
import { Transcript } from "./components/Transcript";
|
||||
@@ -59,7 +64,15 @@ import { HeartbeatPanel } from "./custom/features/heartbeat/HeartbeatPanel";
|
||||
import "./custom/features/heartbeat/heartbeat.css";
|
||||
import { CopyButton } from "./components/CopyButton";
|
||||
import { parseTodos } from "./lib/tools";
|
||||
import { shouldShowTodoPanel, todoDismissalKey } from "./lib/todoVisibility";
|
||||
import {
|
||||
dismissedTodoKeyForScope,
|
||||
scopedTodoBatchKey,
|
||||
scopedTodoDismissalKey,
|
||||
shouldShowTodoPanel,
|
||||
todoBatchKey,
|
||||
todoDismissalKey,
|
||||
todoPanelScope,
|
||||
} from "./lib/todoVisibility";
|
||||
import {
|
||||
type BotConnectionView,
|
||||
type BotRuntimeStatusView,
|
||||
@@ -182,9 +195,70 @@ function normalizeDesktopLayoutStyle(style: string | undefined): DesktopLayoutSt
|
||||
return "classic";
|
||||
}
|
||||
const SHOW_CONTEXT_DOCK = true;
|
||||
const DISMISSED_TODO_STORAGE_KEY = "todoPanel:dismissedKeys";
|
||||
const MAX_DISMISSED_TODO_KEYS = 160;
|
||||
type HistoryScopeFilter = { scope: "global" | "project"; workspaceRoot: string };
|
||||
type WorkspaceInsertTarget = "composer" | "planRevision";
|
||||
type DesktopPlatform = "darwin" | "windows" | "linux";
|
||||
|
||||
function WindowsWindowControls() {
|
||||
const [maximised, setMaximised] = useState(false);
|
||||
|
||||
const syncMaximised = useCallback(() => {
|
||||
void app.IsMainWindowMaximised()
|
||||
.then(setMaximised)
|
||||
.catch(() => setMaximised(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
syncMaximised();
|
||||
window.addEventListener("resize", syncMaximised);
|
||||
window.addEventListener("focus", syncMaximised);
|
||||
return () => {
|
||||
window.removeEventListener("resize", syncMaximised);
|
||||
window.removeEventListener("focus", syncMaximised);
|
||||
};
|
||||
}, [syncMaximised]);
|
||||
|
||||
const toggleMaximise = useCallback(() => {
|
||||
void app.ToggleMaximiseMainWindow()
|
||||
.then(() => window.setTimeout(syncMaximised, 80))
|
||||
.catch(() => undefined);
|
||||
}, [syncMaximised]);
|
||||
|
||||
return (
|
||||
<div className="windows-window-controls" aria-label="Window controls">
|
||||
<button
|
||||
className="windows-window-control windows-window-control--minimize"
|
||||
type="button"
|
||||
aria-label="Minimize window"
|
||||
title="Minimize"
|
||||
onClick={() => void app.MinimiseMainWindow()}
|
||||
>
|
||||
<Minus size={13} strokeWidth={1.9} />
|
||||
</button>
|
||||
<button
|
||||
className="windows-window-control windows-window-control--maximize"
|
||||
type="button"
|
||||
aria-label="Maximize or restore window"
|
||||
aria-pressed={maximised}
|
||||
title={maximised ? "Restore" : "Maximize"}
|
||||
onClick={toggleMaximise}
|
||||
>
|
||||
{maximised ? <RestoreIcon size={12} strokeWidth={1.75} /> : <Square size={11} strokeWidth={1.8} />}
|
||||
</button>
|
||||
<button
|
||||
className="windows-window-control windows-window-control--close"
|
||||
type="button"
|
||||
aria-label="Close window"
|
||||
title="Close"
|
||||
onClick={() => void app.CloseMainWindow()}
|
||||
>
|
||||
<X size={13} strokeWidth={1.9} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
type HistoryViewState =
|
||||
| { kind: "history"; source: "scope"; filter: HistoryScopeFilter; sessions: SessionMeta[] }
|
||||
| { kind: "history"; source: "all"; sessions: SessionMeta[] }
|
||||
@@ -231,6 +305,29 @@ type SidebarImConnectionDetailProps = {
|
||||
onManageAllowlist: () => void;
|
||||
};
|
||||
|
||||
function loadDismissedTodoKeys(): Set<string> {
|
||||
try {
|
||||
const saved = window.localStorage.getItem(DISMISSED_TODO_STORAGE_KEY);
|
||||
if (!saved) return new Set();
|
||||
const parsed = JSON.parse(saved) as unknown;
|
||||
if (!Array.isArray(parsed)) return new Set();
|
||||
return new Set(parsed.filter((value): value is string => typeof value === "string" && value.length > 0));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function saveDismissedTodoKeys(keys: ReadonlySet<string>): void {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
DISMISSED_TODO_STORAGE_KEY,
|
||||
JSON.stringify(Array.from(keys).slice(-MAX_DISMISSED_TODO_KEYS)),
|
||||
);
|
||||
} catch {
|
||||
/* ignore quota errors */
|
||||
}
|
||||
}
|
||||
|
||||
function isSidebarImConnection(connection: BotConnectionView): boolean {
|
||||
return connection.provider === "feishu" || connection.provider === "weixin";
|
||||
}
|
||||
@@ -652,6 +749,21 @@ function browserPlatformOverride(): DesktopPlatform | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const GUIDANCE_QUEUE_MOCK_ITEMS = [
|
||||
"先确认发送后输入框为什么残留刚发的消息,再决定修哪里。",
|
||||
"保持真实 steer 协议不变,只调整前端乐观队列和按钮状态。",
|
||||
"最后补后端 submit 悬挂时的回归测试,确保输入框会立刻释放。",
|
||||
] as const;
|
||||
|
||||
function browserMockScenarioParam(): string {
|
||||
if (typeof window === "undefined" || window.runtime) return "";
|
||||
return new URLSearchParams(window.location.search).get("mock")?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function isGuidanceMockScenario(value: string): boolean {
|
||||
return value === "guidance" || value === "guide" || value === "steer";
|
||||
}
|
||||
|
||||
function detectBrowserPlatform(): DesktopPlatform {
|
||||
const override = browserPlatformOverride();
|
||||
if (override) return override;
|
||||
@@ -904,6 +1016,8 @@ export default function App() {
|
||||
const unsub = onEvent((e) => {
|
||||
if (e.kind === "turn_done") {
|
||||
setDockRefreshKey((v) => v + 1);
|
||||
}
|
||||
if (e.kind === "turn_done") {
|
||||
if (!e.err) playSuccessChime();
|
||||
}
|
||||
});
|
||||
@@ -917,6 +1031,9 @@ export default function App() {
|
||||
const rightDockMode = useLayoutStore((s) => s.rightDockMode);
|
||||
const setRightDockMode = useLayoutStore((s) => s.setRightDockMode);
|
||||
const [dockRefreshKey, setDockRefreshKey] = useState(0);
|
||||
const [fileRefRefreshKey, setFileRefRefreshKey] = useState(0);
|
||||
const refreshComposerFileRefs = useCallback(() => setFileRefRefreshKey((value) => value + 1), []);
|
||||
const composerFileRefRefreshKey = `${dockRefreshKey}:${fileRefRefreshKey}`;
|
||||
const [projectRevision, setProjectRevision] = useState(0);
|
||||
const [activeTopicTurns, setActiveTopicTurns] = useState<number | undefined>(undefined);
|
||||
const [composerInsertRequest, setComposerInsertRequest] = useState<ComposerInsertRequest | null>(null);
|
||||
@@ -925,6 +1042,7 @@ export default function App() {
|
||||
const transientOverlayDismissSignal = useOverlayStore((s) => s.transientOverlayDismissSignal);
|
||||
const setTransientOverlayDismissSignal = useOverlayStore((s) => s.setTransientOverlayDismissSignal);
|
||||
const [desktopPlatform, setDesktopPlatform] = useState<DesktopPlatform>(detectBrowserPlatform);
|
||||
useWailsResizeFix(desktopPlatform === "windows");
|
||||
const [statusBarStyle, setStatusBarStyle] = useState<"icon" | "text">("text");
|
||||
const [statusBarItems, setStatusBarItems] = useState<StatusBarItemId[]>(() => [...DEFAULT_STATUS_BAR_ITEMS]);
|
||||
const [renamingTopicId, setRenamingTopicId] = useState<string | null>(null);
|
||||
@@ -1393,7 +1511,11 @@ export default function App() {
|
||||
// a stale local dismissal cannot hide work that still blocks final readiness;
|
||||
// completed lists collapse automatically and can then be dismissed. The
|
||||
// dismissal key is still based on stable todo content/state so history reloads
|
||||
// do not resurrect the same finished list under a different event id.
|
||||
// do not resurrect the same finished list under a different event id. The
|
||||
// batch key ignores status changes so progress within the same task list does
|
||||
// not look like a brand-new task batch. Dismissal and open state are scoped to
|
||||
// the active session/topic/tab so different projects and sessions do not hide
|
||||
// or reopen each other's todo panels.
|
||||
const todoEntry = useMemo(() => {
|
||||
for (let i = state.items.length - 1; i >= 0; i--) {
|
||||
const it = state.items[i];
|
||||
@@ -1405,9 +1527,30 @@ export default function App() {
|
||||
}, [state.items]);
|
||||
const todoItem = todoEntry?.item ?? null;
|
||||
const todos = useMemo(() => (todoItem ? parseTodos(todoItem.args) : []), [todoItem]);
|
||||
const [dismissedTodo, setDismissedTodo] = useState<string | null>(null);
|
||||
const [dismissedTodoKeys, setDismissedTodoKeys] = useState<Set<string>>(loadDismissedTodoKeys);
|
||||
const todoKey = useMemo(() => todoDismissalKey(todos), [todos]);
|
||||
const todoBatch = useMemo(() => todoBatchKey(todos), [todos]);
|
||||
const todoScope = useMemo(
|
||||
() => todoPanelScope({ activeTab, activeTabId, eventChannel: state.meta?.eventChannel }),
|
||||
[activeTab, activeTabId, state.meta?.eventChannel],
|
||||
);
|
||||
const dismissedTodo = useMemo(
|
||||
() => dismissedTodoKeyForScope(todoScope, dismissedTodoKeys, todoKey),
|
||||
[dismissedTodoKeys, todoKey, todoScope],
|
||||
);
|
||||
const scopedTodoKey = useMemo(() => scopedTodoDismissalKey(todoScope, todoKey), [todoKey, todoScope]);
|
||||
const scopedTodoBatch = useMemo(() => scopedTodoBatchKey(todoScope, todoBatch), [todoBatch, todoScope]);
|
||||
const showTodos = shouldShowTodoPanel(todoKey, dismissedTodo, todos);
|
||||
const dismissTodos = useCallback(() => {
|
||||
if (!scopedTodoKey) return;
|
||||
setDismissedTodoKeys((current) => {
|
||||
if (current.has(scopedTodoKey)) return current;
|
||||
const next = new Set(current);
|
||||
next.add(scopedTodoKey);
|
||||
saveDismissedTodoKeys(next);
|
||||
return next;
|
||||
});
|
||||
}, [scopedTodoKey]);
|
||||
|
||||
const sessionTitle = topicTitle(activeTab);
|
||||
const sessionHasContent = state.items.length > 0 || Boolean(state.live?.text || state.live?.reasoning);
|
||||
@@ -2124,10 +2267,20 @@ export default function App() {
|
||||
return transcriptItems.slice(0, rewindState.boundaryIdx).filter((it) => it.kind !== "compaction");
|
||||
}, [transcriptItems, rewindState]);
|
||||
|
||||
const latestGuidanceConsumed = useMemo(() => {
|
||||
for (let i = state.items.length - 1; i >= 0; i--) {
|
||||
const item = state.items[i];
|
||||
if (item.kind === "notice" && item.text.startsWith("↪ ")) {
|
||||
return { key: item.id, text: item.text.slice(2) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [state.items]);
|
||||
|
||||
// send wrapper: commits any pending optimistic rewind before sending.
|
||||
const commitThenSend = useCallback(async (displayText: string, submitText?: string) => {
|
||||
if (activeTab?.readOnly) throw new Error("channel session is read-only");
|
||||
if (!controllerReady) throw new Error("workspace is still starting");
|
||||
if (activeTab?.readOnly) throw new Error(t("composer.readOnlyChannel"));
|
||||
if (!controllerReady) throw new Error(t("composer.workspaceStarting"));
|
||||
const rs = rewindStateRef.current;
|
||||
if (rs) {
|
||||
rewindStateRef.current = null;
|
||||
@@ -2143,7 +2296,7 @@ export default function App() {
|
||||
// Rewind failed: the Go conversation is intact. Do not send; the
|
||||
// controller emits a notice with the reason.
|
||||
setRewindState(null);
|
||||
throw new Error("rewind failed");
|
||||
throw new Error(t("rewind.failed"));
|
||||
}
|
||||
setRewindSignal((v) => v + 1);
|
||||
if (rs.scope === "both") {
|
||||
@@ -2153,7 +2306,7 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
await send(displayText, submitText);
|
||||
}, [activeTab?.readOnly, controllerReady, send, rewind]);
|
||||
}, [activeTab?.readOnly, controllerReady, send, rewind, t]);
|
||||
|
||||
const handleTranscriptPrompt = useCallback((text: string) => {
|
||||
if (!controllerReady) return;
|
||||
@@ -2395,7 +2548,7 @@ export default function App() {
|
||||
} else {
|
||||
throw new Error(scope === "global" && !session.topicId
|
||||
? t("history.failedOpenSession")
|
||||
: (session.topicId ? "Missing workspaceRoot" : t("history.failedOpenSession")));
|
||||
: (session.topicId ? t("history.missingWorkspaceRoot") : t("history.failedOpenSession")));
|
||||
}
|
||||
if (!latest()) return;
|
||||
seedActiveTabMeta(targetTab);
|
||||
@@ -2440,6 +2593,37 @@ export default function App() {
|
||||
enqueueNavigation({ kind: "blank", scope, workspaceRoot: scope === "project" ? workspaceRoot : "" }),
|
||||
[enqueueNavigation]);
|
||||
|
||||
useEffect(() => {
|
||||
return onSessionRecovered((event) => {
|
||||
const scope = event.scope === "project" ? "project" : "global";
|
||||
const workspaceRoot = scope === "project" ? event.workspaceRoot || "" : "";
|
||||
setProjectRevision((value) => value + 1);
|
||||
void refreshTabMetas();
|
||||
showToast(t("recovery.toast", { title: event.topicTitle || t("recovery.branch") }), "warn", event.topicId
|
||||
? {
|
||||
actionLabel: t("recovery.open"),
|
||||
durationMs: 9000,
|
||||
onAction: () => {
|
||||
void enqueueNavigation({
|
||||
kind: "topic",
|
||||
scope,
|
||||
workspaceRoot,
|
||||
topicId: event.topicId || "",
|
||||
sessionPath: event.recoveryPath || "",
|
||||
});
|
||||
},
|
||||
}
|
||||
: { durationMs: 9000 });
|
||||
});
|
||||
}, [enqueueNavigation, refreshTabMetas, showToast, t]);
|
||||
|
||||
useEffect(() => {
|
||||
return onSessionRecoveryFailed((event) => {
|
||||
const key = event.reason === "lease_held" ? "recovery.failedLease" : "recovery.failedUnavailable";
|
||||
showToast(t(key), "error", { durationMs: 9000 });
|
||||
});
|
||||
}, [showToast, t]);
|
||||
|
||||
const handleNewTab = useCallback(async () => {
|
||||
closeTransientOverlays();
|
||||
setSidebarImDetailConnectionId("");
|
||||
@@ -2621,7 +2805,7 @@ export default function App() {
|
||||
setProjectRevision((value) => value + 1);
|
||||
const tabs = await refreshTabMetas();
|
||||
if (activeTabId && !tabs.some((tab) => tab.id === activeTabId)) {
|
||||
await syncActiveTab(true);
|
||||
await syncActiveTab(false);
|
||||
}
|
||||
}, [activeTabId, refreshTabMetas, syncActiveTab]);
|
||||
|
||||
@@ -2678,6 +2862,8 @@ export default function App() {
|
||||
: t("sidebar.collapse");
|
||||
const sidebarNavTooltipDisabled = !sidebarCollapsed;
|
||||
const browserPreviewChrome = typeof window !== "undefined" && !window.runtime;
|
||||
const browserMockScenario = browserPreviewChrome ? browserMockScenarioParam() : "";
|
||||
const guidanceQueueMockItems = isGuidanceMockScenario(browserMockScenario) ? GUIDANCE_QUEUE_MOCK_ITEMS : undefined;
|
||||
const workspacePanelResetWidth = rightDockDetailActive
|
||||
? RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH
|
||||
: defaultRightDockTreeWidth();
|
||||
@@ -2699,6 +2885,15 @@ export default function App() {
|
||||
const topicbarCanRename = !sidebarImDetailConnection && Boolean(activeTab?.topicId);
|
||||
const topicbarTitleEditSize = Math.min(56, Math.max(4, topicTitleDraft.length || topicbarTitle.length || 1));
|
||||
const sidebarWorkbench = desktopLayoutStyle === "workbench";
|
||||
const windowsFramelessChrome = desktopPlatform === "windows";
|
||||
const handleWindowsTitlebarDoubleClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!windowsFramelessChrome) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!target?.closest(".app-chrome, .topicbar, .workbench-dock__tools")) return;
|
||||
if (target.closest("button, input, textarea, select, a, [role='button'], [role='tab'], .windows-window-controls")) return;
|
||||
event.preventDefault();
|
||||
void app.ToggleMaximiseMainWindow();
|
||||
}, [windowsFramelessChrome]);
|
||||
// Creation keeps the classic sidebar/chat structure while gating chrome tweaks
|
||||
// behind its own style flag so classic/workbench remain unchanged.
|
||||
const appChromeHidden = sidebarWorkbench || sidebarCreation;
|
||||
@@ -2713,11 +2908,13 @@ export default function App() {
|
||||
<ShellExpandProvider>
|
||||
<ShellHotkeys />
|
||||
<TextSizeHotkeys />
|
||||
<div
|
||||
ref={appRef}
|
||||
className={[
|
||||
<div
|
||||
ref={appRef}
|
||||
onDoubleClickCapture={handleWindowsTitlebarDoubleClick}
|
||||
className={[
|
||||
"app",
|
||||
`app--${desktopPlatform}`,
|
||||
windowsFramelessChrome ? "app--windows-frameless" : "",
|
||||
browserPreviewChrome ? "app--browser-preview" : "",
|
||||
sidebarWorkbench ? "app--workbench" : "",
|
||||
sidebarCreation ? "app--creation" : "",
|
||||
@@ -3238,6 +3435,13 @@ export default function App() {
|
||||
<div className="banner banner--error">{t("topbar.startupError", { msg: state.meta.startupErr })}</div>
|
||||
)}
|
||||
|
||||
{activeTab?.recovered && !sidebarImDetailConnection && (
|
||||
<div className="banner banner--recovery">
|
||||
<span className="banner__badge">{t("recovery.branch")}</span>
|
||||
<span>{t("recovery.banner")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UpdateBanner enabled={startupUpdateChecksEnabled === true} />
|
||||
|
||||
<main className="main">
|
||||
@@ -3278,7 +3482,14 @@ export default function App() {
|
||||
|
||||
{!sidebarImDetailConnection && (
|
||||
<footer className="footer" ref={footerRef}>
|
||||
{showTodos && <TodoPanel todos={todos} onDismiss={() => setDismissedTodo(todoKey)} />}
|
||||
{showTodos && (
|
||||
<TodoPanel
|
||||
key={scopedTodoBatch}
|
||||
stateKey={scopedTodoBatch}
|
||||
todos={todos}
|
||||
onDismiss={dismissTodos}
|
||||
/>
|
||||
)}
|
||||
{rewindState && (
|
||||
<UndoRewindBanner
|
||||
meta={{
|
||||
@@ -3370,6 +3581,10 @@ export default function App() {
|
||||
retry={state.retry}
|
||||
transientDismissSignal={transientOverlayDismissSignal}
|
||||
sessionKey={composerSessionKey}
|
||||
fileRefRefreshKey={composerFileRefRefreshKey}
|
||||
guidanceConsumedKey={latestGuidanceConsumed?.key}
|
||||
guidanceConsumedText={latestGuidanceConsumed?.text}
|
||||
guidanceQueuePreviewItems={guidanceQueueMockItems}
|
||||
/>
|
||||
<StatusBar
|
||||
context={state.context}
|
||||
@@ -3385,9 +3600,9 @@ export default function App() {
|
||||
modelLabel={state.meta?.label}
|
||||
labelStyle={statusBarStyle}
|
||||
items={statusBarItems}
|
||||
workspacePath={state.meta?.workspacePath || state.meta?.workspaceRoot || state.meta?.cwd}
|
||||
workspaceName={state.meta?.workspaceName}
|
||||
gitBranch={state.meta?.gitBranch}
|
||||
workspacePath={state.meta?.workspacePath || state.meta?.workspaceRoot || state.meta?.cwd}
|
||||
workspaceName={state.meta?.workspaceName}
|
||||
gitBranch={state.meta?.gitBranch}
|
||||
/>
|
||||
</footer>
|
||||
)}
|
||||
@@ -3485,6 +3700,7 @@ export default function App() {
|
||||
onPreviewModeChange={handleWorkspacePreviewModeChange}
|
||||
onAddToChat={addWorkspaceTextToComposer}
|
||||
onRequestPanelWidth={ensureWorkspacePanelWidth}
|
||||
onFileTreeRefresh={refreshComposerFileRefs}
|
||||
refreshKey={dockRefreshKey}
|
||||
initialViewMode={rightDockMode === "changed" ? "changed" : "files"}
|
||||
showViewTabs={false}
|
||||
@@ -3519,6 +3735,7 @@ export default function App() {
|
||||
initialTab={settingsTarget}
|
||||
initialFocus={settingsFocus ?? undefined}
|
||||
agentRunning={state.running}
|
||||
desktopPlatform={desktopPlatform}
|
||||
onClose={() => {
|
||||
setSettingsFocus(null);
|
||||
setSettingsTarget(null);
|
||||
@@ -3563,6 +3780,7 @@ export default function App() {
|
||||
<HeartbeatPanel open={heartbeatOpen} onClose={() => setHeartbeatOpen(false)} onOpenTopic={(scope, workspaceRoot, topicId) => {
|
||||
void handleOpenTopic(scope, workspaceRoot, topicId);
|
||||
}} />
|
||||
{windowsFramelessChrome && <WindowsWindowControls />}
|
||||
</div>
|
||||
</ShellExpandProvider>
|
||||
);
|
||||
|
||||
@@ -69,6 +69,12 @@ ok(
|
||||
"AppChrome markup includes classic tab strip containers",
|
||||
);
|
||||
|
||||
ok(
|
||||
/const titlebarDragRail = darwinChrome \|\| platform === "windows";/.test(appChromeSource) &&
|
||||
/\{titlebarDragRail && <span className="app-chrome__drag-rail"/.test(appChromeSource),
|
||||
"AppChrome exposes the classic drag rail on macOS and Windows",
|
||||
);
|
||||
|
||||
ok(
|
||||
/const WORKSPACE_PANEL_DEFAULT_OPEN = false;/.test(layoutStoreSource) &&
|
||||
/workspacePanelOpen:\s*WORKSPACE_PANEL_DEFAULT_OPEN/.test(layoutStoreSource),
|
||||
@@ -252,6 +258,37 @@ for (const selector of [
|
||||
);
|
||||
}
|
||||
|
||||
for (const selector of [
|
||||
".app--windows-frameless .app-chrome--native-tabs",
|
||||
":root[data-theme-style] .app--windows-frameless .app-chrome--native-tabs",
|
||||
]) {
|
||||
const paddingRight = finalDeclaration(selector, "padding-right") ?? "";
|
||||
ok(
|
||||
finalDeclaration(selector, "--windows-frameless-titlebar-tools-offset") === "var(--windows-window-controls-safe)" &&
|
||||
paddingRight.includes("--windows-frameless-titlebar-tools-offset") &&
|
||||
paddingRight.includes("--chrome-panel-control-size") &&
|
||||
!paddingRight.includes("--chrome-right-toggle-offset"),
|
||||
`${selector} keeps titlebar tools fixed beside the Windows controls`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const selector of [
|
||||
".app--windows-frameless .app-chrome--native-tabs .app-chrome__panel-toggle--right",
|
||||
":root[data-theme-style] .app--windows-frameless .app-chrome--native-tabs .app-chrome__panel-toggle--right",
|
||||
]) {
|
||||
ok(
|
||||
finalDeclaration(selector, "right") === "calc(var(--windows-frameless-titlebar-tools-offset) + 8px)",
|
||||
`${selector} stays fixed outside the Windows window controls`,
|
||||
);
|
||||
}
|
||||
|
||||
ok(
|
||||
finalDeclaration(".app--windows-frameless:not(.app--workbench):not(.app--creation) .app-chrome--native-tabs .app-chrome__drag-rail", "--wails-draggable") === "drag" &&
|
||||
finalDeclaration(".app--windows-frameless:not(.app--workbench):not(.app--creation) .app-chrome--native-tabs .app-chrome__drag-rail", "right")?.includes("--windows-window-controls-safe") &&
|
||||
finalDeclaration(".app--windows .app-chrome--native-tabs .tabbar", "--wails-draggable") === "no-drag",
|
||||
"Windows classic chrome keeps a draggable rail while tabs remain clickable",
|
||||
);
|
||||
|
||||
for (const selector of [
|
||||
".layout--workbench-chrome-hidden",
|
||||
":root[data-theme-style] .layout--workbench-chrome-hidden",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import gsap from "gsap";
|
||||
import { ApprovalModal } from "../components/ApprovalModal";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
@@ -12,6 +13,17 @@ import type { WireApproval } from "../lib/types";
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
type GsapToOptions = { onComplete?: () => void };
|
||||
const gsapForTests = (typeof gsap.to === "function" ? gsap : (gsap as unknown as { default?: typeof gsap }).default) as unknown as {
|
||||
to?: (target: unknown, vars: GsapToOptions) => unknown;
|
||||
};
|
||||
if (typeof gsapForTests.to === "function") {
|
||||
gsapForTests.to = (_target: unknown, vars: GsapToOptions) => {
|
||||
vars.onComplete?.();
|
||||
return {};
|
||||
};
|
||||
}
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
@@ -42,7 +54,7 @@ async function waitFor(label: string, predicate: () => boolean, timeoutMs = 1000
|
||||
ok(false, label);
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
function installDom(language = "en-US") {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
@@ -50,6 +62,7 @@ function installDom() {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: language });
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
@@ -117,7 +130,7 @@ async function renderApproval(props: Partial<Parameters<typeof ApprovalModal>[0]
|
||||
console.log("\napproval modal file references");
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const dom = installDom("en-US");
|
||||
mockApp({
|
||||
ListDir: async () => [{ name: "src", isDir: true }, { name: "README.md", isDir: false }],
|
||||
SearchFileRefs: async () => [],
|
||||
@@ -166,6 +179,86 @@ console.log("\napproval modal file references");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom("zh-CN");
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "sandbox-escape-approval-zh",
|
||||
tool: "sandbox_escape",
|
||||
subject: "run unconfined once: go test ./...",
|
||||
reason: "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
|
||||
},
|
||||
});
|
||||
|
||||
const text = document.body.textContent ?? "";
|
||||
ok(text.includes("仅本次不进沙箱运行:go test ./..."), "sandbox escape approval localizes subject in Chinese UI");
|
||||
ok(text.includes("Windows 沙箱启动这条命令时失败"), "sandbox escape approval localizes English backend reason in Chinese UI");
|
||||
ok(text.includes("允许一次"), "sandbox escape Chinese approval shows allow once");
|
||||
ok(text.includes("本会话使用真实环境"), "sandbox escape Chinese approval shows session grant");
|
||||
ok(text.includes("拒绝"), "sandbox escape Chinese approval shows deny");
|
||||
ok(!text.includes("总是允许"), "sandbox escape Chinese approval hides persistent grant");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom("zh-CN");
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "memory-approval-zh",
|
||||
tool: "remember",
|
||||
subject: "Save/update memory \"prefers-vitest\" [user]: Preferred test framework | body: Use Vitest for frontend tests.",
|
||||
},
|
||||
});
|
||||
|
||||
const text = document.body.textContent ?? "";
|
||||
ok(text.includes("保存记忆"), "remember approval localizes tool label in Chinese UI");
|
||||
ok(text.includes("保存/更新记忆 \"prefers-vitest\" [user]"), "remember approval localizes subject prefix in Chinese UI");
|
||||
ok(text.includes("正文: Use Vitest for frontend tests."), "remember approval localizes body label in Chinese UI");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom("zh-CN");
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "plan-mode-read-only-command-zh",
|
||||
tool: "plan_mode_read_only_command",
|
||||
subject: "Trust \"gh issue view\" as a read-only command prefix while planning\nCommand: gh issue view 5867 --json title",
|
||||
reason: "This bash command is not in Reasonix's built-in read-only set. Confirm only if this exact prefix is read-only for planning and research. Auto/YOLO approval cannot answer this trust prompt.",
|
||||
},
|
||||
});
|
||||
|
||||
const text = document.body.textContent ?? "";
|
||||
ok(text.includes("计划模式只读命令"), "plan-mode read-only command approval localizes tool label in Chinese UI");
|
||||
ok(text.includes("在计划模式中信任 \"gh issue view\" 为只读命令前缀"), "plan-mode read-only command approval localizes subject in Chinese UI");
|
||||
ok(text.includes("不在 Reasonix 内置只读集合中"), "plan-mode read-only command approval localizes reason in Chinese UI");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
@@ -196,5 +289,180 @@ console.log("\napproval modal file references");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "tool-approval",
|
||||
tool: "bash",
|
||||
subject: "npm run build\n\nRun the build command to verify frontend artifacts.",
|
||||
},
|
||||
});
|
||||
|
||||
const subject = document.querySelector(".approval-subject");
|
||||
ok(subject != null, "tool approval shows its full subject by default");
|
||||
eq(
|
||||
subject?.textContent,
|
||||
"npm run build\n\nRun the build command to verify frontend artifacts.",
|
||||
"default-open tool approval keeps the complete subject visible",
|
||||
);
|
||||
ok(document.body.textContent?.includes("Hide") === true, "tool approval details start expanded");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const answers: Array<[boolean, boolean, boolean]> = [];
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "memory-approval",
|
||||
tool: "remember",
|
||||
subject: "Save/update memory \"prefers-vitest\": Preferred test framework",
|
||||
},
|
||||
onAnswer: (allow, session, persist) => answers.push([allow, session, persist]),
|
||||
});
|
||||
|
||||
const text = document.body.textContent ?? "";
|
||||
ok(text.includes("Allow once"), "fresh-human approval shows allow once");
|
||||
ok(text.includes("Deny"), "fresh-human approval shows deny");
|
||||
ok(!text.includes("Allow for session"), "fresh-human approval hides session grant");
|
||||
ok(!text.includes("Always allow"), "fresh-human approval hides persistent grant");
|
||||
eq(
|
||||
Array.from(document.querySelectorAll(".prompt-shelf__actions button")).map((button) => button.textContent).join("|"),
|
||||
"1Allow once|2Deny",
|
||||
"fresh-human approval keeps conventional allow/deny shortcut keys",
|
||||
);
|
||||
|
||||
const allowButton = Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.includes("Allow once")) as HTMLButtonElement | undefined;
|
||||
if (!allowButton) throw new Error("allow once button did not render");
|
||||
|
||||
await act(async () => {
|
||||
allowButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(JSON.stringify(answers), JSON.stringify([[true, false, false]]), "fresh-human approval allows only once");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const answers: Array<[boolean, boolean, boolean]> = [];
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "memory-approval-deny",
|
||||
tool: "remember",
|
||||
subject: "Save/update memory \"prefers-vitest\": Preferred test framework",
|
||||
},
|
||||
onAnswer: (allow, session, persist) => answers.push([allow, session, persist]),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "2", bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(JSON.stringify(answers), JSON.stringify([[false, false, false]]), "fresh-human numeric 2 denies");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const answers: Array<{ allow: boolean; session: boolean; persist: boolean }> = [];
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "sandbox-escape-approval",
|
||||
tool: "sandbox_escape",
|
||||
subject: "run unconfined once: go test ./...",
|
||||
reason: "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
|
||||
},
|
||||
onAnswer: (allow, session, persist) => answers.push({ allow, session, persist }),
|
||||
});
|
||||
|
||||
const text = document.body.textContent ?? "";
|
||||
ok(text.includes("bash sandbox escape"), "sandbox escape approval uses a clear tool label");
|
||||
ok(text.includes("Allow once"), "sandbox escape approval shows allow once");
|
||||
ok(text.includes("Use real environment for this session"), "sandbox escape approval shows session grant");
|
||||
ok(text.includes("Deny"), "sandbox escape approval shows deny");
|
||||
ok(!text.includes("Always allow"), "sandbox escape approval hides persistent grant");
|
||||
eq(
|
||||
Array.from(document.querySelectorAll(".prompt-shelf__actions button")).map((button) => button.textContent).join("|"),
|
||||
"1Allow once|2Use real environment for this session|3Deny",
|
||||
"sandbox escape approval keeps conventional allow once/session/deny shortcut keys",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(JSON.stringify(answers), JSON.stringify([{ allow: true, session: true, persist: false }]), "sandbox escape Enter on selected session action grants session");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const answers: Array<{ allow: boolean; session: boolean; persist: boolean }> = [];
|
||||
const { root } = await renderApproval({
|
||||
approval: {
|
||||
id: "sandbox-escape-deny-approval",
|
||||
tool: "sandbox_escape",
|
||||
subject: "run unconfined once: go test ./...",
|
||||
reason: "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
|
||||
},
|
||||
onAnswer: (allow, session, persist) => answers.push({ allow, session, persist }),
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "3", bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(JSON.stringify(answers), JSON.stringify([{ allow: false, session: false, persist: false }]), "sandbox escape numeric 3 denies");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,6 +1,6 @@
|
||||
// Run: tsx src/__tests__/bridge-drag-rejection.test.ts
|
||||
|
||||
import { isWailsNonFileDragError, isWailsNonFileDragErrorEvent } from "../lib/bridge";
|
||||
import { isTransientWailsIPCError, isWailsNonFileDragError, isWailsNonFileDragErrorEvent } from "../lib/bridge";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -58,6 +58,21 @@ eq(
|
||||
false,
|
||||
"keeps ErrorEvent invalid argument visible without a recent native file drag",
|
||||
);
|
||||
eq(
|
||||
isTransientWailsIPCError(new DOMException("Failed to execute 'send' on 'WebSocket': Still in CONNECTING state.", "InvalidStateError")),
|
||||
true,
|
||||
"suppresses Wails IPC calls made before the websocket is open",
|
||||
);
|
||||
eq(
|
||||
isTransientWailsIPCError(new TypeError("Cannot read properties of null (reading 'send')")),
|
||||
true,
|
||||
"suppresses Wails IPC calls made after the websocket is torn down",
|
||||
);
|
||||
eq(
|
||||
isTransientWailsIPCError(new Error("backend returned an application error")),
|
||||
false,
|
||||
"keeps ordinary bridge failures visible",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -43,7 +43,7 @@ ok(
|
||||
"App loads secondary drawers on demand",
|
||||
);
|
||||
ok(
|
||||
!/import\s+\{[^}]*\b(?:MCPServersSettingsPage|SkillsSettingsPage)\b[^}]*\}\s+from\s+["']\.\/CapabilitiesPanel["']/.test(settingsSource) &&
|
||||
!/import\s+\{[^}]*\b(?:MCPServersSettingsPage|SkillsSettingsPage|PluginsSettingsPage)\b[^}]*\}\s+from\s+["']\.\/CapabilitiesPanel["']/.test(settingsSource) &&
|
||||
!/import\s+\{[^}]*\bMemorySettingsPage\b[^}]*\}\s+from\s+["']\.\/MemoryPanel["']/.test(settingsSource),
|
||||
"SettingsPanel keeps secondary settings pages out of the first settings chunk",
|
||||
);
|
||||
|
||||
@@ -2,11 +2,11 @@ import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MCPServersSettingsPage } from "../components/CapabilitiesPanel";
|
||||
import { MCPServersSettingsPage, PluginsSettingsPage } from "../components/CapabilitiesPanel";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { mcpServerLifecycleActions, mcpServerRetryableFromAvailableList } from "../lib/mcpServerLifecycle";
|
||||
import type { Meta, ServerView, TabMeta } from "../lib/types";
|
||||
import type { Meta, PluginInstallOptions, PluginView, ServerView, TabMeta } from "../lib/types";
|
||||
|
||||
function ok(value: unknown, message: string) {
|
||||
if (!value) throw new Error(message);
|
||||
@@ -80,6 +80,7 @@ function installDom() {
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
|
||||
globalThis.HTMLInputElement = dom.window.HTMLInputElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
@@ -106,6 +107,16 @@ function findButton(label: string): HTMLButtonElement | undefined {
|
||||
return Array.from(document.querySelectorAll("button")).find((button) => button.textContent?.trim() === label) as HTMLButtonElement | undefined;
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const win = input.ownerDocument.defaultView;
|
||||
const setter = Object.getOwnPropertyDescriptor((win?.HTMLInputElement ?? HTMLInputElement).prototype, "value")?.set;
|
||||
setter?.call(input, value);
|
||||
const eventCtor = win?.Event ?? Event;
|
||||
const inputEventCtor = win?.InputEvent ?? eventCtor;
|
||||
input.dispatchEvent(new inputEventCtor("input", { bubbles: true, data: value, inputType: "insertText" } as InputEventInit));
|
||||
input.dispatchEvent(new eventCtor("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
console.log("capabilities panel MCP actions");
|
||||
|
||||
{
|
||||
@@ -233,3 +244,221 @@ console.log("capabilities panel MCP actions");
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log("capabilities panel plugin actions");
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const meta: Meta = { label: "test", ready: true, eventChannel: "plugin-channel", cwd: "/tmp/reasonix-test", workspaceRoot: "/tmp/reasonix-test" };
|
||||
const tabs: TabMeta[] = [{
|
||||
id: "tab-plugin",
|
||||
scope: "project",
|
||||
workspaceRoot: "/tmp/reasonix-test",
|
||||
workspaceName: "reasonix-test",
|
||||
topicId: "topic-plugin",
|
||||
topicTitle: "Plugins",
|
||||
label: "Plugins",
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "auto",
|
||||
active: true,
|
||||
cwd: "/tmp/reasonix-test",
|
||||
}];
|
||||
let planCalls = 0;
|
||||
let installCalls = 0;
|
||||
let toggleCalls = 0;
|
||||
let updateCalls = 0;
|
||||
let doctorCalls = 0;
|
||||
let removeCalls = 0;
|
||||
let pickFolderCalls = 0;
|
||||
const plannedSources: string[] = [];
|
||||
const installedSources: string[] = [];
|
||||
let plugins: PluginView[] = [{
|
||||
name: "superpowers",
|
||||
version: "0.1.0",
|
||||
description: "Shared agent skills and hooks.",
|
||||
source: "git:github.com/obra/superpowers",
|
||||
root: "~/.reasonix/plugins/superpowers",
|
||||
manifestKind: "reasonix",
|
||||
enabled: true,
|
||||
skills: 2,
|
||||
hooks: 1,
|
||||
mcpServers: 0,
|
||||
}];
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
Meta: async () => meta,
|
||||
ListTabs: async () => tabs,
|
||||
Plugins: async () => plugins.map((plugin) => ({ ...plugin, warnings: [...(plugin.warnings ?? [])] })),
|
||||
PlanPluginInstall: async (source: string, options: PluginInstallOptions) => {
|
||||
planCalls += 1;
|
||||
plannedSources.push(source);
|
||||
ok(options.dryRun === true, "plugin preview asks for dry-run planning");
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
status: "planned",
|
||||
name: "superpowers",
|
||||
actions: [{ kind: "plugin", action: "install_plugin_package", name: "superpowers", source, status: "planned" }],
|
||||
});
|
||||
},
|
||||
InstallPlugin: async (source: string, _options: PluginInstallOptions) => {
|
||||
installCalls += 1;
|
||||
installedSources.push(source);
|
||||
const next: PluginView = {
|
||||
name: "superpowers",
|
||||
version: "0.1.1",
|
||||
description: "Shared agent skills and hooks.",
|
||||
source,
|
||||
root: "~/.reasonix/plugins/superpowers",
|
||||
manifestKind: "reasonix",
|
||||
enabled: true,
|
||||
skills: 3,
|
||||
hooks: 1,
|
||||
mcpServers: 1,
|
||||
skillDetails: [{ name: "plan", description: "Plan work before implementation.", invocation: "/plan", runAs: "inline" }],
|
||||
hookDetails: [{ event: "SessionStart", contextFile: "CLAUDE.md", description: "Load startup context." }],
|
||||
mcpServerDetails: [{ name: "context", transport: "stdio", command: "node server.js" }],
|
||||
};
|
||||
plugins = plugins.filter((plugin) => plugin.name !== next.name).concat(next);
|
||||
return JSON.stringify({ ok: true, status: "done", actions: [{ action: "install_plugin_package", name: next.name, status: "done" }] });
|
||||
},
|
||||
SetPluginEnabled: async (name: string, enabled: boolean) => {
|
||||
toggleCalls += 1;
|
||||
plugins = plugins.map((plugin) => plugin.name === name ? { ...plugin, enabled } : plugin);
|
||||
},
|
||||
UpdatePlugin: async (name: string) => {
|
||||
updateCalls += 1;
|
||||
plugins = plugins.map((plugin) => plugin.name === name ? { ...plugin, version: "0.1.2" } : plugin);
|
||||
return JSON.stringify({ ok: true, status: "done", name });
|
||||
},
|
||||
PluginDoctor: async (name: string) => {
|
||||
doctorCalls += 1;
|
||||
return { ...(plugins.find((plugin) => plugin.name === name) ?? plugins[0]), warnings: ["manifest exports no MCP auth metadata"] };
|
||||
},
|
||||
RemovePlugin: async (name: string) => {
|
||||
removeCalls += 1;
|
||||
plugins = plugins.filter((plugin) => plugin.name !== name);
|
||||
},
|
||||
PickPluginFolder: async () => {
|
||||
pickFolderCalls += 1;
|
||||
return "/tmp/superpowers-plugin";
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(LocaleProvider, null, React.createElement(PluginsSettingsPage)));
|
||||
await flush();
|
||||
});
|
||||
await waitFor("superpowers plugin row", () => Boolean(document.querySelector(".cap-row__name")?.textContent?.includes("superpowers")));
|
||||
ok(Boolean(document.querySelector(".cap-plugin-form-grid .cap-plugin-fields--local")), "local plugin install mode uses the shared form grid");
|
||||
const localOptionTexts = Array.from(document.querySelectorAll(".cap-plugin-installer__options > .cap-plugin-option-block"))
|
||||
.map((option) => option.textContent ?? "");
|
||||
ok(localOptionTexts[0]?.includes("Overwrite same-name plugin"), "local install mode shows overwrite before link mode");
|
||||
ok(localOptionTexts[1]?.includes("Developer mode: link source folder"), "local install mode shows link mode after overwrite");
|
||||
|
||||
const chooseFolder = findButton("Choose plugin folder");
|
||||
if (!chooseFolder) throw new Error("missing plugin folder picker button");
|
||||
await act(async () => {
|
||||
chooseFolder.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("picked plugin folder source", () => document.body.textContent?.includes("/tmp/superpowers-plugin") ?? false);
|
||||
ok(pickFolderCalls === 1, "clicking Choose folder invokes the plugin folder picker once");
|
||||
|
||||
const gitMode = findButton("Git repository");
|
||||
if (!gitMode) throw new Error("missing Git repository install mode");
|
||||
await act(async () => {
|
||||
gitMode.click();
|
||||
await flush();
|
||||
});
|
||||
ok(Boolean(document.querySelector(".cap-plugin-form-grid .cap-plugin-fields--git")), "Git plugin install mode uses the shared form grid");
|
||||
const sourceInput = document.querySelector<HTMLInputElement>('input[aria-label="Git repository URL"]');
|
||||
if (!sourceInput) throw new Error("missing plugin git source input");
|
||||
await act(async () => {
|
||||
setInputValue(sourceInput, "git:github.com/obra/superpowers");
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin preview enabled", () => findButton("Preview")?.disabled === false);
|
||||
|
||||
const preview = findButton("Preview");
|
||||
if (!preview) throw new Error("missing plugin preview button");
|
||||
await act(async () => {
|
||||
preview.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin install plan", () => document.body.textContent?.includes("install_plugin_package") ?? false);
|
||||
ok(planCalls === 1, "clicking Preview invokes plugin install planning once");
|
||||
ok(plannedSources[0] === "git:github.com/obra/superpowers", "plugin preview receives the entered Git source");
|
||||
|
||||
const install = findButton("Install plugin");
|
||||
if (!install) throw new Error("missing plugin install button");
|
||||
await act(async () => {
|
||||
install.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin install result", () => installCalls === 1 && plugins[0]?.version === "0.1.1");
|
||||
ok(installedSources[0] === "git:github.com/obra/superpowers", "plugin install receives the entered Git source");
|
||||
|
||||
const disclosure = document.querySelector<HTMLButtonElement>(".cap-plugin-entry .cap-disclosure");
|
||||
if (!disclosure) throw new Error("missing plugin disclosure");
|
||||
await act(async () => {
|
||||
disclosure.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin update action", () => Boolean(findButton("Update")));
|
||||
ok(document.body.textContent?.includes("How to use") ?? false, "expanded plugin details explain how to use the plugin");
|
||||
ok(document.body.textContent?.includes("/plan") ?? false, "expanded plugin details list exported skill invocations");
|
||||
ok(document.body.textContent?.includes("SessionStart") ?? false, "expanded plugin details list exported hooks");
|
||||
ok(document.body.textContent?.includes("context") ?? false, "expanded plugin details list exported MCP servers");
|
||||
|
||||
const update = findButton("Update");
|
||||
if (!update) throw new Error("missing plugin update button");
|
||||
await act(async () => {
|
||||
update.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin update call", () => updateCalls === 1 && plugins[0]?.version === "0.1.2");
|
||||
|
||||
const doctor = findButton("Doctor");
|
||||
if (!doctor) throw new Error("missing plugin doctor button");
|
||||
await act(async () => {
|
||||
doctor.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin diagnostic warning", () => document.body.textContent?.includes("manifest exports no MCP auth metadata") ?? false);
|
||||
ok(doctorCalls === 1, "clicking Doctor invokes plugin diagnostics once");
|
||||
|
||||
const toggle = document.querySelector<HTMLInputElement>(".cap-plugin-entry .cap-switch input");
|
||||
if (!toggle) throw new Error("missing plugin enable toggle");
|
||||
await act(async () => {
|
||||
toggle.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin disabled", () => toggleCalls === 1 && plugins[0]?.enabled === false);
|
||||
|
||||
const remove = findButton("Remove plugin");
|
||||
if (!remove) throw new Error("missing plugin remove button");
|
||||
await act(async () => {
|
||||
remove.click();
|
||||
await flush();
|
||||
});
|
||||
const confirmRemove = findButton("Confirm remove");
|
||||
if (!confirmRemove) throw new Error("missing plugin confirm remove button");
|
||||
await act(async () => {
|
||||
confirmRemove.click();
|
||||
await flush();
|
||||
});
|
||||
await waitFor("plugin removed", () => removeCalls === 1 && plugins.length === 0);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { Composer, composerPickFileEntry } from "../components/Composer";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { CollaborationMode, ToolApprovalMode, TokenMode } from "../lib/types";
|
||||
import type { CollaborationMode, DirEntry, ToolApprovalMode, TokenMode } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -200,6 +200,16 @@ async function waitFor(label: string, predicate: () => boolean) {
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
type RenderedComposer = Awaited<ReturnType<typeof renderComposer>>;
|
||||
|
||||
function fileEntry(name: string): DirEntry {
|
||||
return { name, isDir: false };
|
||||
}
|
||||
|
||||
async function replaceComposerDraft(rerender: RenderedComposer["rerender"], id: number, text: string) {
|
||||
await rerender({ insertRequest: { id, text, mode: "replace" } });
|
||||
}
|
||||
|
||||
console.log("\ncomposer goal toggle");
|
||||
|
||||
{
|
||||
@@ -568,5 +578,327 @@ console.log("\ncomposer goal toggle");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, rerender } = await renderComposer({
|
||||
running: true,
|
||||
guidanceQueuePreviewItems: ["confirm the send lifecycle", "keep steer protocol unchanged", "add a hanging submit regression"],
|
||||
});
|
||||
|
||||
let guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
|
||||
eq(guidanceItems.length, 2, "running guidance preview shows a compact queue preview");
|
||||
ok(guidanceItems[0]?.textContent?.includes("confirm the send lifecycle") === true, "guidance preview shows the first seeded item");
|
||||
ok(guidanceItems[1]?.textContent?.includes("keep steer protocol unchanged") === true, "guidance preview shows the second seeded item");
|
||||
eq(document.querySelectorAll(".composer-guidance-item__guide").length, 2, "guidance preview exposes a guide action for each visible item");
|
||||
let guidanceMore = document.querySelector(".composer-guidance-more") as HTMLButtonElement | null;
|
||||
ok(guidanceMore?.textContent?.includes("1 more queued") === true, "guidance preview summarizes overflow items");
|
||||
eq(guidanceMore?.getAttribute("aria-expanded"), "false", "guidance overflow starts collapsed");
|
||||
|
||||
if (!guidanceMore) throw new Error("guidance overflow button did not render");
|
||||
await act(async () => {
|
||||
guidanceMore.click();
|
||||
await flushTimers();
|
||||
});
|
||||
guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
|
||||
eq(guidanceItems.length, 3, "guidance overflow expands the remaining queued items");
|
||||
ok(guidanceItems[2]?.textContent?.includes("add a hanging submit regression") === true, "expanded guidance preview shows the hidden item");
|
||||
guidanceMore = document.querySelector(".composer-guidance-more") as HTMLButtonElement | null;
|
||||
ok(guidanceMore?.textContent?.includes("Collapse") === true, "expanded guidance overflow can be collapsed");
|
||||
eq(guidanceMore?.getAttribute("aria-expanded"), "true", "guidance overflow reports expanded state");
|
||||
|
||||
if (!guidanceMore) throw new Error("guidance collapse button did not render");
|
||||
await act(async () => {
|
||||
guidanceMore.click();
|
||||
await flushTimers();
|
||||
});
|
||||
guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
|
||||
eq(guidanceItems.length, 2, "guidance overflow collapses back to the compact preview");
|
||||
|
||||
await rerender({ guidanceQueuePreviewItems: ["only the latest preview seed"] });
|
||||
guidanceItems = Array.from(document.querySelectorAll(".composer-guidance-item"));
|
||||
eq(guidanceItems.length, 1, "guidance preview refreshes when the seed changes");
|
||||
ok(guidanceItems[0]?.textContent?.includes("only the latest preview seed") === true, "guidance preview renders the refreshed seed");
|
||||
|
||||
await rerender({ running: false });
|
||||
ok(document.querySelector(".composer-guidance-item") === null, "guidance preview clears when the mock turn stops");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, calls, rerender } = await renderComposer({
|
||||
running: true,
|
||||
onSend: (displayText, submitText) => {
|
||||
calls.send.push(displayText);
|
||||
calls.submit.push(submitText);
|
||||
},
|
||||
});
|
||||
|
||||
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("composer textarea did not render");
|
||||
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!sendButton) throw new Error("running composer send button did not render");
|
||||
|
||||
eq(textarea.placeholder, "Add guidance to the queue...", "running composer explains queued guidance input");
|
||||
ok(sendButton.classList.contains("composer__btn--steer"), "running composer marks send button as steer");
|
||||
ok(sendButton.disabled === true, "running steer button stays disabled without input");
|
||||
|
||||
await rerender({ insertRequest: { id: 4, text: "keep the files small", mode: "replace" } });
|
||||
ok(sendButton.disabled === false, "running steer button enables after text input");
|
||||
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(calls.send.join(","), "", "running composer queues guidance without sending it immediately");
|
||||
eq(textarea.value, "", "queued running guidance clears the composer draft");
|
||||
const guidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
|
||||
if (!guidanceItem) throw new Error("running guidance chip did not render");
|
||||
ok(guidanceItem.textContent?.includes("keep the files small") === true, "running guidance chip shows queued text");
|
||||
ok(document.querySelector(".composer-guidance-head")?.textContent?.includes("Queued guidance 1") === true, "running guidance shelf shows queued count");
|
||||
|
||||
const guideButton = guidanceItem.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
|
||||
if (!guideButton) throw new Error("running guidance guide button did not render");
|
||||
await act(async () => {
|
||||
guideButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(calls.send.join(","), "keep the files small", "queued guidance sends through onSend when guided");
|
||||
ok(document.querySelector(".composer-guidance-item") === null, "queued guidance clears after being guided");
|
||||
|
||||
await rerender({ insertRequest: { id: 5, text: "prefer the smaller diff", mode: "replace" } });
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
const dismissibleGuidanceItem = document.querySelector(".composer-guidance-item") as HTMLElement | null;
|
||||
if (!dismissibleGuidanceItem) throw new Error("dismissible guidance chip did not render");
|
||||
const dismissButton = dismissibleGuidanceItem.querySelector(".composer-guidance-item__action") as HTMLButtonElement | null;
|
||||
if (!dismissButton) throw new Error("running guidance dismiss button did not render");
|
||||
await act(async () => {
|
||||
dismissButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip can be dismissed");
|
||||
|
||||
await rerender({ insertRequest: { id: 6, text: "prefer the smaller diff", mode: "replace" } });
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
ok(document.querySelector(".composer-guidance-item") !== null, "running guidance chip renders again after another queued item");
|
||||
|
||||
await rerender({ guidanceConsumedKey: "s1", guidanceConsumedText: "prefer the smaller diff" });
|
||||
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip clears when steer is consumed");
|
||||
|
||||
await rerender({ insertRequest: { id: 7, text: "then stop showing the chip", mode: "replace" } });
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
ok(document.querySelector(".composer-guidance-item") !== null, "running guidance chip renders before turn stop");
|
||||
|
||||
await rerender({ running: false });
|
||||
ok(document.querySelector(".composer-guidance-item") === null, "running guidance chip clears when the turn stops");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, calls, rerender } = await renderComposer({
|
||||
running: true,
|
||||
submitDisabled: true,
|
||||
onSend: (displayText) => {
|
||||
calls.send.push(displayText);
|
||||
},
|
||||
});
|
||||
|
||||
const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
|
||||
if (!sendButton) throw new Error("running composer send button did not render");
|
||||
|
||||
await rerender({ insertRequest: { id: 7, text: "steer while activating", mode: "replace" } });
|
||||
ok(sendButton.disabled === false, "running guidance queue ignores controller submitDisabled");
|
||||
|
||||
await act(async () => {
|
||||
sendButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(calls.send.join(","), "", "running guidance queues while controllerReady is false");
|
||||
const guideButton = document.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
|
||||
if (!guideButton) throw new Error("running guidance guide button did not render");
|
||||
await act(async () => {
|
||||
guideButton.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(calls.send.join(","), "steer while activating", "queued guidance can be guided while controllerReady is false");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let listDirCalls = 0;
|
||||
mockApp({
|
||||
ListDir: async () => {
|
||||
listDirCalls += 1;
|
||||
return listDirCalls === 1 ? [fileEntry("cached-dir.txt")] : [fileEntry("fresh-dir.txt")];
|
||||
},
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root, rerender } = await renderComposer();
|
||||
|
||||
await replaceComposerDraft(rerender, 101, "@");
|
||||
await waitFor("initial @ directory load", () => listDirCalls === 1);
|
||||
|
||||
await replaceComposerDraft(rerender, 102, "");
|
||||
await replaceComposerDraft(rerender, 103, "@");
|
||||
await waitFor("@ directory revalidation call", () => listDirCalls === 2);
|
||||
|
||||
eq(listDirCalls, 2, "@ directory cache hit still revalidates ListDir");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let listDirCalls = 0;
|
||||
mockApp({
|
||||
ListDir: async () => {
|
||||
listDirCalls += 1;
|
||||
return listDirCalls === 1 ? [fileEntry("manual-refresh-stale.txt")] : [fileEntry("manual-refresh-fresh.txt")];
|
||||
},
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root, rerender } = await renderComposer({ fileRefRefreshKey: "0" });
|
||||
|
||||
await replaceComposerDraft(rerender, 201, "@");
|
||||
await waitFor("initial @ directory load before refresh key", () => listDirCalls === 1);
|
||||
|
||||
await rerender({ fileRefRefreshKey: "1" });
|
||||
await waitFor("@ directory reload after refresh key", () => listDirCalls === 2);
|
||||
|
||||
eq(listDirCalls, 2, "fileRefRefreshKey refreshes @ directory cache while the menu is open");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const realDateNow = Date.now;
|
||||
let now = 1000;
|
||||
let searchCalls = 0;
|
||||
Date.now = () => now;
|
||||
mockApp({
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => {
|
||||
searchCalls += 1;
|
||||
return searchCalls === 1 ? [fileEntry("alpha-old.ts")] : [fileEntry("alpha-new.ts")];
|
||||
},
|
||||
});
|
||||
const { root, rerender } = await renderComposer();
|
||||
|
||||
try {
|
||||
await replaceComposerDraft(rerender, 301, "@alpha");
|
||||
await waitFor("initial @ search request", () => searchCalls === 1);
|
||||
eq(searchCalls, 1, "@ search fetches the first query");
|
||||
|
||||
await replaceComposerDraft(rerender, 302, "");
|
||||
now = 2000;
|
||||
await replaceComposerDraft(rerender, 303, "@alpha");
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
eq(searchCalls, 1, "@ search cache is reused inside the TTL");
|
||||
|
||||
await replaceComposerDraft(rerender, 304, "");
|
||||
now = 7001;
|
||||
await replaceComposerDraft(rerender, 305, "@alpha");
|
||||
await waitFor("expired @ search cache refresh", () => searchCalls === 2);
|
||||
eq(searchCalls, 2, "@ search cache revalidates after the TTL");
|
||||
} finally {
|
||||
Date.now = realDateNow;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let staleListDirResolve: ((entries: DirEntry[]) => void) | undefined;
|
||||
let thirdListDirResolve: ((entries: DirEntry[]) => void) | undefined;
|
||||
let listDirCalls = 0;
|
||||
mockApp({
|
||||
ListDir: async () => {
|
||||
listDirCalls += 1;
|
||||
if (listDirCalls === 1) {
|
||||
return new Promise<DirEntry[]>((resolve) => {
|
||||
staleListDirResolve = resolve;
|
||||
});
|
||||
}
|
||||
if (listDirCalls === 2) return [fileEntry("cache-live.txt")];
|
||||
return new Promise<DirEntry[]>((resolve) => {
|
||||
thirdListDirResolve = resolve;
|
||||
});
|
||||
},
|
||||
SearchFileRefs: async () => [],
|
||||
});
|
||||
const { root, rerender } = await renderComposer({ fileRefRefreshKey: "0" });
|
||||
|
||||
const textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
|
||||
if (!textarea) throw new Error("composer textarea did not render");
|
||||
|
||||
await replaceComposerDraft(rerender, 401, "@cache");
|
||||
await waitFor("initial stale @ directory request", () => listDirCalls === 1);
|
||||
|
||||
await rerender({ fileRefRefreshKey: "1" });
|
||||
await waitFor("fresh @ directory request after refresh key", () => listDirCalls === 2);
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
staleListDirResolve?.([fileEntry("cache-stale.txt")]);
|
||||
await act(async () => {
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
await replaceComposerDraft(rerender, 402, "");
|
||||
await replaceComposerDraft(rerender, 403, "@cache");
|
||||
await waitFor("second fresh @ directory request", () => listDirCalls === 3);
|
||||
await act(async () => {
|
||||
textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(textarea.value, "@cache-live.txt ", "stale @ directory request cannot repopulate cache after refresh");
|
||||
thirdListDirResolve?.([fileEntry("cache-later.txt")]);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -5,6 +5,7 @@ import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Composer } from "../components/Composer";
|
||||
import { UserMessage } from "../components/Message";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, TokenMode, ToolApprovalMode } from "../lib/types";
|
||||
@@ -137,7 +138,9 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<Composer {...currentProps} />
|
||||
<div className="chat-pane">
|
||||
<Composer {...currentProps} />
|
||||
</div>
|
||||
</ToastProvider>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
@@ -183,6 +186,29 @@ function imagePasteEvent(file: File): Event {
|
||||
return event;
|
||||
}
|
||||
|
||||
function imageViewerOpen(): boolean {
|
||||
return Boolean(document.querySelector(".image-viewer-backdrop .image-viewer__image"));
|
||||
}
|
||||
|
||||
function renderUserMessage(text: string, props: Partial<Parameters<typeof UserMessage>[0]> = {}) {
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const paint = async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<div className="chat-pane">
|
||||
<UserMessage text={text} {...props} />
|
||||
</div>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
};
|
||||
return { root, paint };
|
||||
}
|
||||
|
||||
console.log("\ncomposer image capability");
|
||||
|
||||
{
|
||||
@@ -255,5 +281,107 @@ console.log("\ncomposer image capability");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
installBridgeApp({
|
||||
SavePastedImage: async () => ".reasonix/attachments/mock.png",
|
||||
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
|
||||
});
|
||||
const { root } = await renderComposer({ imageInputEnabled: true });
|
||||
const file = new File(["img"], "photo.png", { type: "image/png", lastModified: 1 });
|
||||
|
||||
await act(async () => {
|
||||
textarea().dispatchEvent(imagePasteEvent(file));
|
||||
await flushTimers();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => Boolean(document.querySelector(".composer-context__thumb img")));
|
||||
const thumb = document.querySelector(".composer-context__thumb") as HTMLElement | null;
|
||||
if (!thumb) throw new Error("missing composer image thumbnail");
|
||||
await act(async () => {
|
||||
thumb.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(imageViewerOpen);
|
||||
ok(imageViewerOpen(), "composer image thumbnail opens the image viewer");
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => !document.querySelector(".image-viewer-backdrop"));
|
||||
ok(!document.querySelector(".image-viewer-backdrop"), "composer image viewer closes on Escape");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
installBridgeApp({
|
||||
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
|
||||
});
|
||||
const { root, paint } = renderUserMessage("check @[photo.png](.reasonix/attachments/mock.png)");
|
||||
await paint();
|
||||
await waitFor(() => Boolean(document.querySelector(".msg-attachment--image img")));
|
||||
const thumb = document.querySelector(".msg-attachment--image") as HTMLElement | null;
|
||||
if (!thumb) throw new Error("missing message image thumbnail");
|
||||
await act(async () => {
|
||||
thumb.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => Boolean(document.querySelector(".chat-pane > .image-viewer-backdrop")));
|
||||
ok(Boolean(document.querySelector(".chat-pane > .image-viewer-backdrop")), "sent message image preview portals into the chat pane");
|
||||
|
||||
const close = document.querySelector(".image-viewer__close") as HTMLButtonElement | null;
|
||||
if (!close) throw new Error("missing image viewer close button");
|
||||
await act(async () => {
|
||||
close.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => !document.querySelector(".image-viewer-backdrop"));
|
||||
ok(!document.querySelector(".image-viewer-backdrop"), "sent message image viewer closes from the close button");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
installBridgeApp({
|
||||
AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
|
||||
});
|
||||
const { root, paint } = renderUserMessage("check @[photo.png](.reasonix/attachments/mock.png)", {
|
||||
turn: 1,
|
||||
onEdit: () => true,
|
||||
});
|
||||
await paint();
|
||||
await waitFor(() => Boolean(document.querySelector(".msg-attachment--image img")));
|
||||
const edit = document.querySelector("button.msg-meta__btn:not(.msg-meta__copy)") as HTMLButtonElement | null;
|
||||
if (!edit) throw new Error("missing message edit button");
|
||||
await act(async () => {
|
||||
edit.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(() => Boolean(document.querySelector(".msg-edit .composer-context__thumb img")));
|
||||
const thumb = document.querySelector(".msg-edit .composer-context__thumb") as HTMLElement | null;
|
||||
if (!thumb) throw new Error("missing edit image thumbnail");
|
||||
await act(async () => {
|
||||
thumb.click();
|
||||
await flushTimers();
|
||||
});
|
||||
await waitFor(imageViewerOpen);
|
||||
ok(imageViewerOpen(), "edit message image thumbnail opens the image viewer");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,6 +1,6 @@
|
||||
// Run: tsx src/__tests__/context-panel-breakdown.test.ts
|
||||
|
||||
import { contextBreakdown, contextCostDisplay, contextSourceRows, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
|
||||
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSessionCache, contextSourceRows, contextUsageRefreshKey, contextWindowStatus, formatCacheHitRate, formatMetricTokens } from "../components/ContextPanel";
|
||||
import { currencySymbol, formatMoney, formatMoneyLocalized } from "../lib/money";
|
||||
|
||||
let passed = 0;
|
||||
@@ -49,7 +49,7 @@ eq(
|
||||
42_124,
|
||||
"legend values sum to used context tokens",
|
||||
);
|
||||
eq(Math.round(mock.otherPct), 33, "donut endpoint follows used/window percent");
|
||||
eq(Math.round(mock.otherPct), 33, "usage endpoint follows used/window percent");
|
||||
|
||||
const issue5283 = contextBreakdown(6888, 1_000_000, 6840, 48, 48);
|
||||
eq(
|
||||
@@ -90,9 +90,16 @@ eq(
|
||||
reasoningPct: 0,
|
||||
otherPct: 0,
|
||||
},
|
||||
"unknown context window keeps donut segments empty",
|
||||
"unknown context window keeps usage segments empty",
|
||||
);
|
||||
|
||||
console.log("\ncontext window status");
|
||||
|
||||
eq(contextWindowStatus(33, 80), { tone: "good", key: "context.windowStatusHealthy" }, "low usage stays healthy");
|
||||
eq(contextWindowStatus(72, 80), { tone: "notice", key: "context.windowStatusWatch" }, "usage near compact threshold warns early");
|
||||
eq(contextWindowStatus(80, 80), { tone: "warn", key: "context.windowStatusPastCompact" }, "compact threshold reached takes warning tone");
|
||||
eq(contextWindowStatus(91, 80), { tone: "warn", key: "context.windowStatusNearLimit" }, "near hard limit overrides compact status");
|
||||
|
||||
console.log("\ncontext panel cost");
|
||||
|
||||
const infoCost = contextCostDisplay({
|
||||
@@ -102,6 +109,59 @@ const infoCost = contextCostDisplay({
|
||||
usage: { cost: 0, costUsd: 0, currency: "¥" },
|
||||
});
|
||||
eq(infoCost, { amount: 0.1759, currency: "$" }, "panel cost keeps the panel currency instead of state default");
|
||||
const singleRequestOnly = contextCostDisplay({
|
||||
info: { sessionCost: 0, sessionCurrency: "", sessionCostUsd: 0 },
|
||||
sessionCost: 0,
|
||||
sessionCurrency: "¥",
|
||||
usage: { cost: 0.42, costUsd: 0.42, currency: "¥" },
|
||||
});
|
||||
eq(
|
||||
singleRequestOnly,
|
||||
{ amount: 0, currency: "¥" },
|
||||
"a single request's cost never renders under the session-cost label",
|
||||
);
|
||||
const localAccumulated = contextCostDisplay({
|
||||
info: { sessionCost: 0, sessionCurrency: "", sessionCostUsd: 0 },
|
||||
sessionCost: 1.5,
|
||||
sessionCurrency: "¥",
|
||||
usage: { cost: 0.42, costUsd: 0.42, currency: "$" },
|
||||
});
|
||||
eq(localAccumulated, { amount: 1.5, currency: "¥" }, "locally accumulated session cost still renders");
|
||||
|
||||
console.log("\ncontext panel session cache scope");
|
||||
|
||||
eq(
|
||||
contextSessionCache(
|
||||
{ sessionCacheHitTokens: 900, sessionCacheMissTokens: 100 },
|
||||
{ cacheHitTokens: 800, cacheMissTokens: 200 },
|
||||
{ sessionCacheHitTokens: 700, sessionCacheMissTokens: 300 },
|
||||
),
|
||||
{ hit: 900, miss: 100 },
|
||||
"all-sources panel telemetry beats executor-only wire counters",
|
||||
);
|
||||
eq(
|
||||
contextSessionCache(
|
||||
{ sessionCacheHitTokens: 0, sessionCacheMissTokens: 0 },
|
||||
{ cacheHitTokens: 800, cacheMissTokens: 200 },
|
||||
{ sessionCacheHitTokens: 700, sessionCacheMissTokens: 300 },
|
||||
),
|
||||
{ hit: 800, miss: 200 },
|
||||
"ContextInfo telemetry is the second all-sources choice",
|
||||
);
|
||||
eq(
|
||||
contextSessionCache(
|
||||
{ sessionCacheHitTokens: 0, sessionCacheMissTokens: 0 },
|
||||
{ cacheHitTokens: 0, cacheMissTokens: 0 },
|
||||
{ sessionCacheHitTokens: 700, sessionCacheMissTokens: 300 },
|
||||
),
|
||||
{ hit: 700, miss: 300 },
|
||||
"executor-only wire counters only bridge the pre-refresh gap",
|
||||
);
|
||||
eq(
|
||||
contextSessionCache(null, undefined, undefined),
|
||||
{ hit: 0, miss: 0 },
|
||||
"no data renders as empty, not NaN",
|
||||
);
|
||||
eq(formatMoney(infoCost.amount, infoCost.currency, "dash"), "$0.1759", "USD panel cost renders with dollar sign");
|
||||
eq(currencySymbol("楼"), "¥", "unexpected currency text does not leak into money values");
|
||||
eq(currencySymbol("aud"), "AUD ", "unknown ISO currency codes stay readable");
|
||||
@@ -118,6 +178,32 @@ console.log("\ncontext panel cache rate");
|
||||
eq(formatCacheHitRate(99_950, 50), "99.95%", "cache hit rate preserves two decimal places");
|
||||
eq(formatCacheHitRate(0, 10_000), "0.00%", "cache hit rate shows zero when usage data exists");
|
||||
eq(formatCacheHitRate(0, 0), "-", "cache hit rate stays empty before usage data exists");
|
||||
eq(cacheHitTone(8700, 1300), "good", "healthy cache hit rate uses positive tone");
|
||||
eq(cacheHitTone(6000, 4000), "notice", "mid cache hit rate uses notice tone");
|
||||
eq(cacheHitTone(5999, 4001), "warn", "low cache hit rate uses warning tone");
|
||||
eq(cacheHitTone(0, 0), undefined, "missing cache data stays uncolored");
|
||||
|
||||
console.log("\ncontext panel usage refresh key");
|
||||
|
||||
eq(contextUsageRefreshKey(undefined), "", "missing usage does not request a streaming refresh");
|
||||
ok(
|
||||
contextUsageRefreshKey({
|
||||
totalTokens: 10,
|
||||
promptTokens: 10,
|
||||
completionTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
sessionCacheHitTokens: 0,
|
||||
sessionCacheMissTokens: 0,
|
||||
}) !== contextUsageRefreshKey({
|
||||
totalTokens: 11,
|
||||
promptTokens: 10,
|
||||
completionTokens: 1,
|
||||
reasoningTokens: 0,
|
||||
sessionCacheHitTokens: 0,
|
||||
sessionCacheMissTokens: 0,
|
||||
}),
|
||||
"general token changes refresh even when cache counters stay unchanged",
|
||||
);
|
||||
|
||||
console.log("\ncontext panel source rows");
|
||||
|
||||
|
||||
@@ -133,6 +133,17 @@ check("$\\frac{a}{b}$", () => isLikelyInlineMath("\\frac{a}{b}") === true);
|
||||
check("$f(x)$", () => isLikelyInlineMath("f(x)") === true);
|
||||
check("$x+1$", () => isLikelyInlineMath("x+1") === true);
|
||||
|
||||
console.log("\nisLikelyInlineMath — classifier gaps from PR #4543");
|
||||
check("$\\tfrac12$", () => isLikelyInlineMath("\\tfrac12") === true);
|
||||
check("$\\sqrt2$", () => isLikelyInlineMath("\\sqrt2") === true);
|
||||
check("$SO(3,1)$", () => isLikelyInlineMath("SO(3,1)") === true);
|
||||
check("$SU(2)$", () => isLikelyInlineMath("SU(2)") === true);
|
||||
check("$GL(n)$", () => isLikelyInlineMath("GL(n)") === true);
|
||||
check("$K = -iJ$", () => isLikelyInlineMath("K = -iJ") === true);
|
||||
check("$p = +\\alpha$", () => isLikelyInlineMath("p = +\\alpha") === true);
|
||||
check("$+$", () => isLikelyInlineMath("+") === true);
|
||||
check("$=$", () => isLikelyInlineMath("=") === true);
|
||||
|
||||
console.log("\nisLikelyInlineMath — currency/link (NOT math)");
|
||||
check("$5", () => isLikelyInlineMath("5") === false);
|
||||
check("$10", () => isLikelyInlineMath("10") === false);
|
||||
@@ -153,6 +164,15 @@ check("uppercase $I$ → math (math name in non-English prose)", () => isLikelyI
|
||||
check("uppercase $A$ → math", () => isLikelyInlineMath("A") === true);
|
||||
check("uppercase $V$ → math", () => isLikelyInlineMath("V") === true);
|
||||
|
||||
console.log("\nisLikelyInlineMath — primed letters and bracketed labels");
|
||||
check("$S'$ → math (primed letter)", () => isLikelyInlineMath("S'") === true);
|
||||
check("$y''$ → math (double prime)", () => isLikelyInlineMath("y''") === true);
|
||||
check("$f'(x)$ → math (primed function)", () => isLikelyInlineMath("f'(x)") === true);
|
||||
check("$\\psi'$ → math (Greek with prime)", () => isLikelyInlineMath("\\psi'") === true);
|
||||
check("$[56]$ → math (irrep label)", () => isLikelyInlineMath("[56]") === true);
|
||||
check("$[56,0^+]$ → math", () => isLikelyInlineMath("[56,0^+]") === true);
|
||||
check("$[\\mathbf{56}]$ → math", () => isLikelyInlineMath("[\\mathbf{56}]") === true);
|
||||
|
||||
console.log("\nisLikelyInlineMath — minimal LaTeX patterns (regression)");
|
||||
// LLMs frequently emit minimal LaTeX in math contexts that the older
|
||||
// classifier rejected as currency / word tokens. These tests pin down the
|
||||
@@ -170,6 +190,8 @@ check("comma-separated $A, B$ → math (ordered pair)", () => isLikelyInlineMath
|
||||
check("comma-separated $1, 2, 3$ → math (sequence)", () => isLikelyInlineMath("1, 2, 3") === true);
|
||||
check("comma-separated $\\alpha, \\beta$ → math (Greek pair)", () => isLikelyInlineMath("\\alpha, \\beta") === true);
|
||||
check("parens-wrapped $(A, B)$ inner → math", () => isLikelyInlineMath("(A, B)") === true);
|
||||
check("cycle notation $(12)$ → math", () => isLikelyInlineMath("(12)") === true);
|
||||
check("cycle notation $(12)(34)$ → math", () => isLikelyInlineMath("(12)(34)") === true);
|
||||
check("$S$ (set name) → math", () => isLikelyInlineMath("S") === true);
|
||||
check("$S$ with surrounding prose (regression)", () => {
|
||||
return normalizeMath("$S$ 非空\n$S$ 有上界") === "$S$ 非空\n$S$ 有上界";
|
||||
@@ -217,7 +239,7 @@ check("\\|x\\| renders as double bars", () => {
|
||||
|
||||
console.log("\nnormalizeMath — LLM delimiter conversion");
|
||||
eq(normalizeMath("\\(x^2\\)"), "$x^2$", "\\(…\\) → $…$");
|
||||
eq(normalizeMath("\\[E=mc^2\\]"), "$$E=mc^2$$", "\\[…\\] → $$…$$");
|
||||
eq(normalizeMath("\\[E=mc^2\\]"), "$$\nE=mc^2\n$$", "\\[…\\] → $$…$$");
|
||||
eq(normalizeMath("\\\\[4pt]"), "\\\\[4pt]", "\\\\[ line-break spacing protected");
|
||||
|
||||
console.log("\nnormalizeMath — \\slashed conversion (regression)");
|
||||
@@ -235,19 +257,17 @@ console.log("\nnormalizeMath — inline $$ glued to prose (regression)");
|
||||
// line before any $$ preceded by a letter/closing bracket/etc.
|
||||
check("inline $$ after prose", () => {
|
||||
const out = normalizeMath("decomposes as$$\n\\mathbf{6}.$$");
|
||||
return /^decomposes as\n\n\$\$/.test(out) && out.includes("\\mathbf{6}");
|
||||
return /^decomposes as\n\$\$/.test(out) && out.includes("\\mathbf{6}");
|
||||
});
|
||||
check("inline $$ after closing bracket", () => {
|
||||
const out = normalizeMath("(octet)$$ \\mathbf{56}.$$");
|
||||
return out.startsWith("(octet)\n\n$$");
|
||||
return out.startsWith("(octet)\n$$");
|
||||
});
|
||||
check("inline $$ after closing brace (\\end{...}$$)", () => {
|
||||
// A model that writes `\end{array}$$` or `\frac{a}{b}$$` on one line
|
||||
// has the same micromark-fence problem as the comma case. The
|
||||
// closing brace is the most common end-of-content marker in LaTeX
|
||||
// math, so the repair-regex character class includes it.
|
||||
// A display equation ending with }$$ must be extracted as a unit.
|
||||
// The closing $$ must not be split off, or the equation body is emptied.
|
||||
const out = normalizeMath("$$\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$$");
|
||||
return out.includes("\\end{pmatrix},\n\n$$") || out.includes("\\end{pmatrix}\n\n$$");
|
||||
return out.includes("\\end{pmatrix},\n$$") || out.includes("\\end{pmatrix}\n$$");
|
||||
});
|
||||
check("inline $$ after comma on same line as content", () => {
|
||||
// User-reported (2026-06-12, soft-pion chat): the model wrote the
|
||||
@@ -261,25 +281,31 @@ check("inline $$ after comma on same line as content", () => {
|
||||
// as math, which then fails to render with "Can't use function '$'
|
||||
// in math mode" on the stray $ inside the equation body.
|
||||
const out = normalizeMath("…D(q^2),$$\nwith $P=…$");
|
||||
return out.includes("D(q^2),\n\n$$");
|
||||
return out.includes("D(q^2),\n$$");
|
||||
});
|
||||
check("well-formed $$ already on own line is normalised consistently", () => {
|
||||
// Whether the model writes `decomposes as$$\n\mathbf{6}.$$` or
|
||||
// `decomposes as\n\n$$\n\mathbf{6}.$$`, both must produce the same
|
||||
// remark-math-parseable form: opening $$ on its own line, body, blank
|
||||
// line, closing $$ on its own line.
|
||||
// `decomposes as\n\n$$\n\mathbf{6}.$$`, both must produce valid
|
||||
// remark-math-parseable form: opening $$ on its own line, body, closing
|
||||
// $$ on its own line.
|
||||
const inline = normalizeMath("decomposes as$$\n\\mathbf{6}.$$");
|
||||
const block = normalizeMath("decomposes as\n\n$$\n\\mathbf{6}.$$");
|
||||
const expected = "decomposes as\n\n$$\n\\mathbf{6}.\n\n$$";
|
||||
return inline === expected && block === expected;
|
||||
const valid = (s: string) => /\n\$\$\n/.test(s) && /\n\$\$/.test(s) && s.includes("\\mathbf{6}");
|
||||
return valid(inline) && valid(block);
|
||||
});
|
||||
check("\\[…\\] → $$…$$ still works (no spurious blank line)", () => {
|
||||
return normalizeMath("\\[E=mc^2\\]") === "$$E=mc^2$$";
|
||||
return normalizeMath("\\[E=mc^2\\]") === "$$\nE=mc^2\n$$";
|
||||
});
|
||||
check("digit before $$ is NOT a prose boundary (preserves c^2$$)", () => {
|
||||
const out = normalizeMath("c^2$$ x $$");
|
||||
return out === "c^2$$ x $$";
|
||||
});
|
||||
eq(normalizeMath("intro$$x+1"), "intro\n$$\nx+1", "orphan opening $$ is not duplicated");
|
||||
eq(
|
||||
normalizeMath("first$$a$$ middle $$b$$ end"),
|
||||
"first\n$$\na\n$$\n middle \n$$\nb\n$$\n end",
|
||||
"multiple display blocks on one line are all normalised",
|
||||
);
|
||||
|
||||
console.log("\nnormalizeMath — non-math dollar filtering");
|
||||
eq(normalizeMath("costs $1$ today"), "costs $1$ today", "$1$ not math");
|
||||
@@ -409,6 +435,10 @@ const e2e: Array<[string, string]> = [
|
||||
["$\\frac{1}{\\sqrt{2}}\\|uud\\rangle$", "ket in fraction with \\|"],
|
||||
["$\\|x\\|$", "norm \\|x\\| → double bar (regression)"],
|
||||
["$\\langle\\psi\\|$", "bra closer \\| → single bar (regression)"],
|
||||
["$S'$", "primed letter S'"],
|
||||
["$f'(x)$", "primed function call"],
|
||||
["$[56]$", "bracketed irrep label"],
|
||||
["$[56,0^+]$", "bracketed irrep label with charge"],
|
||||
];
|
||||
for (const [src, label] of e2e) {
|
||||
check(`${label}: ${src}`, () => katexOf(normalizeMath(src), false));
|
||||
@@ -458,6 +488,19 @@ check("real inline math $x^2$ still renders as KaTeX", () => {
|
||||
const html = renderHtml("the value $x^2$ here");
|
||||
return html.includes("katex");
|
||||
});
|
||||
check("blockquote display math does not swallow following inline math", () => {
|
||||
const html = renderHtml("> theorem\n> $$E=mc^2$$\n> after $x$");
|
||||
return html.includes("katex-display")
|
||||
&& !html.includes("katex-error")
|
||||
&& html.includes(">x</mi>");
|
||||
});
|
||||
check("multi-line blockquote display math strips quote markers from the formula", () => {
|
||||
const html = renderHtml("> theorem\n> $$\n> E=mc^2\n> $$\n> after $x$");
|
||||
return html.includes("katex-display")
|
||||
&& !html.includes("katex-error")
|
||||
&& !html.includes("> E")
|
||||
&& html.includes(">x</mi>");
|
||||
});
|
||||
|
||||
// ── Young diagram / tableau macros ─────────────────────────────────────────────
|
||||
// `\yng` (ytableau) and `\young` (youngtab) are common in physics —
|
||||
@@ -486,7 +529,7 @@ check("\\yng inside \\(...\\) does not get double-wrapped", () => {
|
||||
});
|
||||
check("\\yng inside \\[...\\] stays display math without triple dollars", () => {
|
||||
const out = normalizeMath("\\[\\yng(2,1)\\]");
|
||||
return out.startsWith("$$\\begin{array}{l}")
|
||||
return out.startsWith("$$\n\\begin{array}{l}")
|
||||
&& out.endsWith("$$")
|
||||
&& !out.includes("$$$");
|
||||
});
|
||||
@@ -505,7 +548,7 @@ check("digit-starting inline math with \\young does not get nested wrappers", ()
|
||||
});
|
||||
check("display math ending in digit closes before following bare \\yng", () => {
|
||||
const out = normalizeMath("$$x^2$$ \\yng(1)");
|
||||
return out === "$$x^2$$ $\\begin{array}{l}\\square\\end{array}$";
|
||||
return out === "$$\nx^2\n$$\n $\\begin{array}{l}\\square\\end{array}$";
|
||||
});
|
||||
check("bare \\yng after inline math is separated from adjacent dollars", () => {
|
||||
const out = normalizeMath("$x$\\yng(1)");
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Run: tsx src/__tests__/message-pasted-blocks.test.ts
|
||||
|
||||
import { parsePastedBlocks } from "../components/Message";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (JSON.stringify(a) === JSON.stringify(b)) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function wrapped(label: string, content: string): string {
|
||||
return `${label}\n\n--- Begin ${label} ---\n${content}\n--- End ${label} ---`;
|
||||
}
|
||||
|
||||
console.log("\nmessage pasted blocks");
|
||||
|
||||
for (const [label, name] of [
|
||||
["[已粘贴文本 #2 · 31 行]", "Simplified Chinese"],
|
||||
["[已貼上文字 #2 · 31 行]", "Traditional Chinese"],
|
||||
["[Pasted text #2 · 31 lines]", "English"],
|
||||
] as const) {
|
||||
eq(
|
||||
parsePastedBlocks(`before\n${label}\nafter`, wrapped(label, "line 1\nline 2")),
|
||||
[{ label, content: "line 1\nline 2\n" }],
|
||||
`${name} pasted text labels are parsed from submit text`,
|
||||
);
|
||||
}
|
||||
|
||||
eq(parsePastedBlocks("[unknown paste #1]", "--- Begin [unknown paste #1] ---\nnope\n--- End [unknown paste #1] ---"), [], "unknown labels are ignored");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -74,7 +74,7 @@ function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
|
||||
};
|
||||
}
|
||||
|
||||
function meta(): Meta {
|
||||
function meta(overrides: Partial<Meta> = {}): Meta {
|
||||
return {
|
||||
label: "model",
|
||||
ready: true,
|
||||
@@ -91,6 +91,7 @@ function meta(): Meta {
|
||||
tokenMode: "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,10 +212,14 @@ await act(async () => {
|
||||
});
|
||||
|
||||
const guardedStartupTabs = deferred<TabMeta[]>();
|
||||
let ensureBlankSurfaceCalls = 0;
|
||||
const staleProjectA = "/repo/project-a";
|
||||
const targetProjectB = "/repo/project-b";
|
||||
const ensureBlankSurfaceCalls: Array<{ scope: string; workspaceRoot: string }> = [];
|
||||
window.go.main.App = {
|
||||
ListTabs: async () => guardedStartupTabs.promise,
|
||||
MetaForTab: async () => meta(),
|
||||
MetaForTab: async (tabID: string) => tabID === "tab-new"
|
||||
? meta({ cwd: targetProjectB, workspaceRoot: targetProjectB, workspaceName: "project-b", workspacePath: targetProjectB })
|
||||
: meta({ cwd: staleProjectA, workspaceRoot: staleProjectA, workspaceName: "project-a", workspacePath: staleProjectA }),
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => balance,
|
||||
@@ -224,9 +229,17 @@ window.go.main.App = {
|
||||
HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
EnsureBlankSurface: async () => {
|
||||
ensureBlankSurfaceCalls += 1;
|
||||
return tabMeta({ id: "tab-new", topicId: "topic-new", topicTitle: "New session" });
|
||||
EnsureBlankSurface: async (scope: string, workspaceRoot: string) => {
|
||||
ensureBlankSurfaceCalls.push({ scope, workspaceRoot });
|
||||
return tabMeta({
|
||||
id: "tab-new",
|
||||
topicId: "topic-new",
|
||||
topicTitle: "New session",
|
||||
workspaceRoot: targetProjectB,
|
||||
workspaceName: "project-b",
|
||||
workspacePath: targetProjectB,
|
||||
cwd: targetProjectB,
|
||||
});
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings;
|
||||
|
||||
@@ -239,20 +252,31 @@ await act(async () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await controller?.ensureBlankSurface("project", "/repo");
|
||||
await controller?.ensureBlankSurface("project", targetProjectB);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
eq(ensureBlankSurfaceCalls, 1, "EnsureBlankSurface is called once");
|
||||
eq(ensureBlankSurfaceCalls.length, 1, "EnsureBlankSurface is called once");
|
||||
eq(ensureBlankSurfaceCalls[0]?.workspaceRoot, targetProjectB, "EnsureBlankSurface keeps the requested project root");
|
||||
eq(controller?.activeTabId, "tab-new", "blank surface becomes active before startup sync resolves");
|
||||
eq(controller?.state.meta?.workspaceRoot, targetProjectB, "blank surface exposes the new project root");
|
||||
|
||||
await act(async () => {
|
||||
guardedStartupTabs.resolve([tabMeta({ id: "tab-old", topicId: "topic-old", topicTitle: "Old session" })]);
|
||||
guardedStartupTabs.resolve([tabMeta({
|
||||
id: "tab-old",
|
||||
topicId: "topic-old",
|
||||
topicTitle: "Old session",
|
||||
workspaceRoot: staleProjectA,
|
||||
workspaceName: "project-a",
|
||||
workspacePath: staleProjectA,
|
||||
cwd: staleProjectA,
|
||||
})]);
|
||||
await guardedStartupTabs.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
eq(controller?.activeTabId, "tab-new", "guarded startup sync cannot restore an older active tab");
|
||||
eq(controller?.state.meta?.workspaceRoot, targetProjectB, "guarded startup sync cannot restore the old project root");
|
||||
|
||||
await act(async () => {
|
||||
guardRoot.unmount();
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
activeSessionAncestorKeys,
|
||||
projectTreeTopicOpenRequest,
|
||||
projectTreeShouldSuppressOpenForRename,
|
||||
projectTreeReadActivityKey,
|
||||
projectTreeTopicHasUnreadActivity,
|
||||
projectTreeShouldRenderTopicActions,
|
||||
projectTreeTopicMetaLine,
|
||||
} from "../components/ProjectTree";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
|
||||
@@ -24,6 +28,12 @@ function eq(a: unknown, b: unknown, label: string) {
|
||||
|
||||
console.log("\nproject tree runtime sessions");
|
||||
|
||||
const testT = (key: string, vars?: Record<string, string | number>) => {
|
||||
if (key === "history.turnOne") return `${vars?.n ?? 1} turn`;
|
||||
if (key === "history.turnOther") return `${vars?.n ?? 0} turns`;
|
||||
return key;
|
||||
};
|
||||
|
||||
const tree: ProjectNode[] = [
|
||||
{
|
||||
key: "global_folder",
|
||||
@@ -104,6 +114,81 @@ eq(
|
||||
"regular project topic still opens by topic",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeTopicMetaLine({
|
||||
key: "global_topic_missing_time",
|
||||
kind: "global_topic",
|
||||
label: "Old empty topic",
|
||||
topicId: "missing-time",
|
||||
}, testT),
|
||||
"projectTree.previously",
|
||||
"topic with no turns and no timestamps renders previous-time fallback meta",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeTopicMetaLine({
|
||||
key: "global_topic_recent",
|
||||
kind: "global_topic",
|
||||
label: "Recent blank topic",
|
||||
topicId: "recent",
|
||||
createdAt: Date.now(),
|
||||
}, testT),
|
||||
"projectTree.justNow",
|
||||
"topic with a real recent timestamp still renders just-now meta",
|
||||
);
|
||||
|
||||
const completedTopic: ProjectNode = {
|
||||
key: "topic_complete",
|
||||
kind: "topic",
|
||||
label: "Completed",
|
||||
root: "/repo",
|
||||
topicId: "topic-complete",
|
||||
lastActivityAt: 2000,
|
||||
};
|
||||
const completedTopicKey = projectTreeReadActivityKey(completedTopic) ?? "";
|
||||
|
||||
eq(
|
||||
projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 1000 }, "project", "/repo", "other-topic"),
|
||||
true,
|
||||
"completed inactive topic with newer activity shows unread attention",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 2000 }, "project", "/repo", "other-topic"),
|
||||
false,
|
||||
"completed topic stops showing unread attention once opened at its latest activity",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeTopicHasUnreadActivity(completedTopic, { [completedTopicKey]: 1000 }, "project", "/repo", "topic-complete"),
|
||||
false,
|
||||
"active topic does not show unread attention",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeTopicHasUnreadActivity({ ...completedTopic, status: "streaming", running: true }, { [completedTopicKey]: 1000 }, "project", "/repo", "other-topic"),
|
||||
false,
|
||||
"running topic keeps runtime status instead of completed-unread attention",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeShouldRenderTopicActions(false, true, false),
|
||||
true,
|
||||
"read workbench topic renders hover actions",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeShouldRenderTopicActions(false, true, true),
|
||||
false,
|
||||
"unread workbench topic omits hover actions from the keyboard tab order",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeShouldRenderTopicActions(true, true, false),
|
||||
false,
|
||||
"runtime session rows do not render topic hover actions",
|
||||
);
|
||||
|
||||
eq(
|
||||
projectTreeShouldSuppressOpenForRename(
|
||||
{ rowKey: "topic-a", canRename: true },
|
||||
|
||||
@@ -47,14 +47,14 @@ eq(
|
||||
);
|
||||
|
||||
eq(
|
||||
mergedFetchedProviderModels(["mimo-v2.5-pro"], ["mimo-v2-flash", "mimo-v2-omni", "mimo-v2.5-pro"], { preserveCurated: true }),
|
||||
mergedFetchedProviderModels(["mimo-v2.5-pro"], ["mimo-v2.5", "mimo-v2.5-pro"], { preserveCurated: true }),
|
||||
["mimo-v2.5-pro"],
|
||||
"manual access refresh preserves selected MiMo model instead of importing provider catalog",
|
||||
);
|
||||
|
||||
eq(
|
||||
providerModelCandidates(["mimo-v2.5-pro"], ["mimo-v2-flash", "mimo-v2-omni", "mimo-v2.5-pro"]),
|
||||
["mimo-v2.5-pro", "mimo-v2-flash", "mimo-v2-omni"],
|
||||
providerModelCandidates(["mimo-v2.5-pro"], ["mimo-v2.5", "mimo-v2.5-pro"]),
|
||||
["mimo-v2.5-pro", "mimo-v2.5"],
|
||||
"manual access refresh can show provider catalog as unsaved candidates",
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// Run: tsx src/__tests__/ready-event-meta-sync.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { useController } from "../lib/useController";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) {
|
||||
ok(true, label);
|
||||
} else {
|
||||
ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
function tabMeta(ready: boolean): TabMeta {
|
||||
return {
|
||||
id: "tab-ready",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
gitBranch: "main",
|
||||
topicId: "topic-ready",
|
||||
topicTitle: "Ready race",
|
||||
sessionPath: "/repo/sessions/ready.jsonl",
|
||||
label: "model",
|
||||
ready,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: true,
|
||||
cwd: "/repo",
|
||||
};
|
||||
}
|
||||
|
||||
function meta(ready: boolean): Meta {
|
||||
return {
|
||||
label: "model",
|
||||
ready,
|
||||
eventChannel: "agent:event",
|
||||
cwd: "/repo",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
gitBranch: "main",
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
};
|
||||
}
|
||||
|
||||
console.log("\nready event meta sync");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
|
||||
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
|
||||
const balance: BalanceInfo = { available: false, display: "" };
|
||||
const jobs: JobView[] = [];
|
||||
const checkpoints: CheckpointMeta[] = [];
|
||||
const readyHandlers: Array<(tabId?: string) => void> = [];
|
||||
const historyGate = deferred<HistoryMessage[]>();
|
||||
let backendReady = false;
|
||||
let listTabsCalls = 0;
|
||||
let historyCalls = 0;
|
||||
let metaCalls = 0;
|
||||
|
||||
window.runtime = {
|
||||
EventsOn: (name: string, cb: (...data: unknown[]) => void) => {
|
||||
if (name === "agent:ready") readyHandlers.push(cb as (tabId?: string) => void);
|
||||
return () => {};
|
||||
},
|
||||
BrowserOpenURL: () => {},
|
||||
};
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => {
|
||||
listTabsCalls += 1;
|
||||
return [tabMeta(backendReady)];
|
||||
},
|
||||
MetaForTab: async () => {
|
||||
metaCalls += 1;
|
||||
return meta(backendReady);
|
||||
},
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => balance,
|
||||
JobsForTab: async () => jobs,
|
||||
CheckpointsForTab: async () => checkpoints,
|
||||
HistoryForTab: async () => historyGate.promise,
|
||||
HistoryPageForTab: async () => {
|
||||
historyCalls += 1;
|
||||
const messages = await historyGate.promise;
|
||||
return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false };
|
||||
},
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
type Controller = ReturnType<typeof useController>;
|
||||
let controller: Controller | undefined;
|
||||
|
||||
function Probe() {
|
||||
controller = useController();
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Probe />);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await waitFor("initial not-ready metadata", () => controller?.activeTabId === "tab-ready" && controller.state.meta?.ready === false);
|
||||
eq(historyCalls, 1, "startup begins one history hydration");
|
||||
eq(metaCalls, 0, "startup history remains in flight before ancillary meta");
|
||||
|
||||
backendReady = true;
|
||||
await act(async () => {
|
||||
for (const handler of readyHandlers) handler("tab-ready");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("ready metadata refreshed before history settles", () => controller?.state.meta?.ready === true);
|
||||
|
||||
eq(listTabsCalls >= 2, true, "ready event refreshes active tab metadata from ListTabs");
|
||||
eq(historyCalls, 1, "ready event joins the in-flight hydrate instead of reloading history");
|
||||
eq(metaCalls, 0, "ready event does not wait for ancillary MetaForTab before unlocking send");
|
||||
|
||||
await act(async () => {
|
||||
historyGate.resolve([{ role: "user", content: "hello" }]);
|
||||
await historyGate.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("history finishes", () => controller?.state.hydrating === false);
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello") ?? false, "history still hydrates after the ready metadata sync");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,220 @@
|
||||
// Run: tsx src/__tests__/ready-meta-reconcile.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { useController } from "../lib/useController";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) {
|
||||
ok(true, label);
|
||||
} else {
|
||||
ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function flushPromises(ms = 0): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||||
await act(async () => {
|
||||
await flushPromises(25);
|
||||
});
|
||||
if (predicate()) return;
|
||||
}
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
function tabMeta(id: string, ready: boolean, active: boolean): TabMeta {
|
||||
return {
|
||||
id,
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
gitBranch: "main",
|
||||
topicId: `topic-${id}`,
|
||||
topicTitle: id,
|
||||
sessionPath: `/repo/sessions/${id}.jsonl`,
|
||||
label: "model",
|
||||
ready,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active,
|
||||
cwd: "/repo",
|
||||
};
|
||||
}
|
||||
|
||||
function meta(tabId: string, ready: boolean): Meta {
|
||||
return {
|
||||
label: "model",
|
||||
ready,
|
||||
eventChannel: "agent:event",
|
||||
cwd: "/repo",
|
||||
workspaceRoot: "/repo",
|
||||
workspaceName: "repo",
|
||||
workspacePath: "/repo",
|
||||
sessionPath: `/repo/sessions/${tabId}.jsonl`,
|
||||
gitBranch: "main",
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
};
|
||||
}
|
||||
|
||||
console.log("\nready meta reconcile");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
|
||||
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
|
||||
const balance: BalanceInfo = { available: false, display: "" };
|
||||
const jobs: JobView[] = [];
|
||||
const checkpoints: CheckpointMeta[] = [];
|
||||
const historyGate = deferred<HistoryMessage[]>();
|
||||
let backendReady = false;
|
||||
let listTabsCalls = 0;
|
||||
let historyCalls = 0;
|
||||
let metaCalls = 0;
|
||||
let approvalModeCalls = 0;
|
||||
const metaTabIds: string[] = [];
|
||||
|
||||
window.runtime = {
|
||||
EventsOn: () => () => {},
|
||||
BrowserOpenURL: () => {},
|
||||
};
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => {
|
||||
listTabsCalls += 1;
|
||||
return [
|
||||
tabMeta("tab-ready", backendReady, true),
|
||||
tabMeta("tab-inactive", true, false),
|
||||
];
|
||||
},
|
||||
MetaForTab: async (tabId: string) => {
|
||||
metaCalls += 1;
|
||||
metaTabIds.push(tabId);
|
||||
return meta(tabId, tabId === "tab-ready" ? backendReady : true);
|
||||
},
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => balance,
|
||||
JobsForTab: async () => jobs,
|
||||
CheckpointsForTab: async () => checkpoints,
|
||||
HistoryForTab: async () => historyGate.promise,
|
||||
HistoryPageForTab: async (tabId: string) => {
|
||||
historyCalls += 1;
|
||||
const messages = await historyGate.promise;
|
||||
return {
|
||||
messages,
|
||||
startTurn: 0,
|
||||
endTurn: messages.filter((message) => message.role === "user").length,
|
||||
totalTurns: messages.filter((message) => message.role === "user").length,
|
||||
hasOlder: false,
|
||||
};
|
||||
},
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
SetToolApprovalModeForTab: async () => {
|
||||
approvalModeCalls += 1;
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
type Controller = ReturnType<typeof useController>;
|
||||
let controller: Controller | undefined;
|
||||
|
||||
function Probe() {
|
||||
controller = useController();
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Probe />);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await waitFor("initial not-ready metadata", () => controller?.activeTabId === "tab-ready" && controller.state.meta?.ready === false);
|
||||
eq(historyCalls, 1, "startup begins one active-tab history hydration");
|
||||
eq(listTabsCalls, 1, "startup fetches the active tab once");
|
||||
|
||||
backendReady = true;
|
||||
await waitFor("ready metadata is reconciled without a ready event", () => controller?.state.meta?.ready === true);
|
||||
|
||||
ok(metaCalls >= 1, "active tab metadata is polled after a missed ready event");
|
||||
ok(metaTabIds.length > 0 && metaTabIds.every((tabId) => tabId === "tab-ready"), "ready polling is limited to the active tab");
|
||||
eq(listTabsCalls, 1, "ready polling does not re-list or activate tabs");
|
||||
eq(historyCalls, 1, "ready polling does not start another history hydration");
|
||||
eq(approvalModeCalls, 0, "ready polling does not rely on approval-mode changes");
|
||||
|
||||
await act(async () => {
|
||||
historyGate.resolve([{ role: "user", content: "hello" }]);
|
||||
await historyGate.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("history finishes", () => controller?.state.hydrating === false);
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello") ?? false, "history still hydrates after ready reconciliation");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,35 @@
|
||||
// Run: tsx src/__tests__/recovery-banner-privacy.test.ts
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(cond: boolean, label: string) {
|
||||
if (cond) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
|
||||
|
||||
console.log("\nrecovery banner privacy");
|
||||
|
||||
const recoveryBannerMatch = /<div className="banner banner--recovery"[\s\S]*?<\/div>/.exec(appSource);
|
||||
const recoveryBanner = recoveryBannerMatch?.[0] ?? "";
|
||||
|
||||
ok(Boolean(recoveryBannerMatch), "App renders a recovery banner");
|
||||
ok(!recoveryBanner.includes("title="), "recovery banner does not expose internals through a tooltip");
|
||||
ok(!recoveryBanner.includes("recoveryDigest"), "recovery banner does not expose recovery digest");
|
||||
ok(!recoveryBanner.includes("recoveryParentId"), "recovery banner does not expose parent session id");
|
||||
ok(!appSource.includes("recoveryBannerTitle"), "App no longer builds a detailed recovery tooltip");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -6,6 +6,9 @@ import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import {
|
||||
SettingsPanel,
|
||||
formatProviderExtraBody,
|
||||
parseProviderExtraBody,
|
||||
providerExtraBodyParseError,
|
||||
providerBaseURLFromChatURL,
|
||||
providerChatURLPreview,
|
||||
providerEditorEffectiveKind,
|
||||
@@ -58,18 +61,52 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
|
||||
autoPlan: "off",
|
||||
providers: [],
|
||||
officialProviders: [],
|
||||
providerPresets: [],
|
||||
permissions: { mode: "ask", allow: [], ask: [], deny: [] },
|
||||
sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], shell: "auto" },
|
||||
sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: "/work", effectiveWriteRoots: ["/work"], shell: "auto" },
|
||||
network: { proxyMode: "auto", proxyUrl: "", noProxy: "", proxy: { type: "socks5", server: "", port: 0, username: "", password: "" } },
|
||||
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" },
|
||||
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" },
|
||||
bot: {
|
||||
enabled: false,
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
maxSteps: 0,
|
||||
debounceMs: 0,
|
||||
allowlist: { enabled: false, allowAll: false, qqUsers: [], feishuUsers: [], weixinUsers: [], qqGroups: [], feishuGroups: [], weixinGroups: [] },
|
||||
qq: { enabled: false, appId: "", appSecretEnv: "", secretSet: false, sandbox: false },
|
||||
toolApprovalMode: "",
|
||||
maxSteps: 0,
|
||||
debounceMs: 0,
|
||||
queueMode: "steer",
|
||||
queueCap: 20,
|
||||
queueDrop: "summarize",
|
||||
ignoreSelfMessages: true,
|
||||
selfUserIds: { qq: [], feishu: [], weixin: [] },
|
||||
control: { enabled: false, addr: "127.0.0.1:37913", tokenEnv: "REASONIX_BOT_CONTROL_TOKEN" },
|
||||
pairing: { enabled: true, requestTtlMinutes: 60, maxPendingPerPlatform: 3 },
|
||||
routes: [],
|
||||
allowlist: {
|
||||
enabled: false,
|
||||
allowAll: false,
|
||||
qqUsers: [],
|
||||
feishuUsers: [],
|
||||
weixinUsers: [],
|
||||
qqApprovers: [],
|
||||
feishuApprovers: [],
|
||||
weixinApprovers: [],
|
||||
qqAdmins: [],
|
||||
feishuAdmins: [],
|
||||
weixinAdmins: [],
|
||||
qqGroups: [],
|
||||
feishuGroups: [],
|
||||
weixinGroups: [],
|
||||
},
|
||||
qq: {
|
||||
enabled: false,
|
||||
appId: "",
|
||||
appSecretEnv: "",
|
||||
secretSet: false,
|
||||
sandbox: false,
|
||||
model: "",
|
||||
toolApprovalMode: "ask",
|
||||
workspaceRoot: "",
|
||||
access: { enabled: true, allowAll: false, pairingEnabled: true, users: [], groups: [], approvers: [], admins: [] },
|
||||
},
|
||||
feishu: { enabled: false, domain: "feishu", appId: "", appSecretEnv: "", secretSet: false, verificationToken: "", mode: "webhook", webhookPort: 0, requireMention: false },
|
||||
weixin: { enabled: false, accountId: "", tokenEnv: "", tokenSet: false, apiBase: "" },
|
||||
connections: [],
|
||||
@@ -96,11 +133,40 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
|
||||
|
||||
console.log("\nsettings refresh snapshot");
|
||||
|
||||
eq(providerEditorEffectiveKind(true, "anthropic", ["anthropic", "openai"]), "openai", "new custom providers ignore sorted providerKinds and default to OpenAI");
|
||||
eq(providerEditorEffectiveKind(true, "anthropic", ["anthropic", "openai"]), "anthropic", "new custom providers keep the selected Anthropic-compatible kind");
|
||||
eq(providerEditorEffectiveKind(false, "anthropic", ["anthropic", "openai"]), "anthropic", "existing providers preserve their stored kind");
|
||||
eq(providerChatURLPreview("https://proxy.example.com/v1", "", false), "https://proxy.example.com/v1/chat/completions", "base URL mode previews chat completions URL");
|
||||
eq(providerChatURLPreview("", "https://proxy.example.com/custom/chat", true), "https://proxy.example.com/custom/chat", "full URL mode previews configured URL");
|
||||
eq(providerBaseURLFromChatURL("https://proxy.example.com/v1/chat/completions"), "https://proxy.example.com/v1", "chat URL derives base URL for model discovery");
|
||||
eq(formatProviderExtraBody({ top_p: 0.7, enable_thinking: true }), "{\n \"enable_thinking\": true,\n \"top_p\": 0.7\n}", "extra body editor formats stable JSON");
|
||||
eq(JSON.stringify(parseProviderExtraBody('{ "enable_thinking": true, "top_p": 0.7 }')), "{\"enable_thinking\":true,\"top_p\":0.7}", "extra body editor parses JSON object");
|
||||
let extraBodyRejected = false;
|
||||
try {
|
||||
parseProviderExtraBody("[true]");
|
||||
} catch {
|
||||
extraBodyRejected = true;
|
||||
}
|
||||
ok(extraBodyRejected, "extra body editor rejects non-object JSON");
|
||||
const extraBodyTestT = ((key: string, vars?: Record<string, string | number>) => {
|
||||
if (key === "settings.providerExtraBodyError") return "localized extra body fallback";
|
||||
if (key === "settings.providerExtraBodyNull") return `${vars?.path} localized null`;
|
||||
return key;
|
||||
}) as any;
|
||||
eq(
|
||||
providerExtraBodyParseError(new SyntaxError("Unexpected token } in JSON"), extraBodyTestT),
|
||||
"localized extra body fallback",
|
||||
"extra body editor localizes JSON syntax errors",
|
||||
);
|
||||
try {
|
||||
parseProviderExtraBody('{ "nested": { "value": null } }', extraBodyTestT);
|
||||
ok(false, "extra body editor rejects localized null validation errors");
|
||||
} catch (e) {
|
||||
eq(
|
||||
providerExtraBodyParseError(e, extraBodyTestT),
|
||||
"extra_body.nested.value localized null",
|
||||
"extra body editor keeps localized structured validation errors",
|
||||
);
|
||||
}
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
@@ -120,6 +186,7 @@ globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
window.scrollTo = () => {};
|
||||
localStorage.clear();
|
||||
|
||||
const settingsSnapshots = [baseSettings("standard"), baseSettings("compact")];
|
||||
let settingsCalls = 0;
|
||||
@@ -146,6 +213,7 @@ await act(async () => {
|
||||
<LocaleProvider>
|
||||
<SettingsPanel
|
||||
initialTab="general"
|
||||
desktopPlatform="linux"
|
||||
onClose={() => {}}
|
||||
onChanged={(settings?: SettingsView) => {
|
||||
onChangedSettings = settings;
|
||||
@@ -193,6 +261,7 @@ await act(async () => {
|
||||
<LocaleProvider>
|
||||
<SettingsPanel
|
||||
initialTab="general"
|
||||
desktopPlatform="linux"
|
||||
onClose={() => {}}
|
||||
onChanged={() => {}}
|
||||
/>
|
||||
@@ -220,6 +289,137 @@ ok(document.body.textContent?.includes("Settings could not be loaded.") === fals
|
||||
await act(async () => {
|
||||
retryRoot.unmount();
|
||||
});
|
||||
|
||||
const zoomRootEl = document.createElement("div");
|
||||
document.body.appendChild(zoomRootEl);
|
||||
const zoomRoot = createRoot(zoomRootEl);
|
||||
let persistedZoom = 0.5;
|
||||
const savedZoomFactors: number[] = [];
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
Settings: async () => baseSettings("standard"),
|
||||
GetDesktopZoomFactor: async () => persistedZoom,
|
||||
SetDesktopZoomFactor: async (factor: number) => {
|
||||
persistedZoom = factor;
|
||||
savedZoomFactors.push(factor);
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
localStorage.setItem("reasonix-zoom-restart", "1");
|
||||
await act(async () => {
|
||||
zoomRoot.render(
|
||||
<LocaleProvider>
|
||||
<SettingsPanel
|
||||
initialTab="appearance"
|
||||
desktopPlatform="windows"
|
||||
onClose={() => {}}
|
||||
onChanged={() => {}}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("persisted display zoom sync", () => document.querySelector(".zoom-slider__value")?.textContent?.trim() === "50%");
|
||||
|
||||
const resetZoomButton = document.querySelector("button[aria-label='Reset display zoom to 100%']") as HTMLButtonElement | null;
|
||||
if (!resetZoomButton) throw new Error("display zoom reset button did not render");
|
||||
await act(async () => {
|
||||
resetZoomButton.click();
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("display zoom reset", () => document.querySelector(".zoom-slider__value")?.textContent?.trim() === "100%");
|
||||
|
||||
eq(savedZoomFactors.at(-1), 1, "display zoom reset writes the default zoom factor");
|
||||
eq(localStorage.getItem("reasonix-zoom-restart"), "1", "display zoom reset updates the local restart zoom cache");
|
||||
|
||||
await act(async () => {
|
||||
zoomRoot.unmount();
|
||||
});
|
||||
|
||||
// Bots tab: direct four-channel bot manager.
|
||||
const botsRootEl = document.createElement("div");
|
||||
document.body.appendChild(botsRootEl);
|
||||
const botsRoot = createRoot(botsRootEl);
|
||||
const botsSettings = baseSettings("standard");
|
||||
botsSettings.bot.connections = [
|
||||
{
|
||||
id: "conn-feishu-1",
|
||||
provider: "feishu",
|
||||
domain: "feishu",
|
||||
label: "kun",
|
||||
enabled: true,
|
||||
status: "connected",
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
credential: { appId: "cli_mock", appSecretEnv: "FEISHU_BOT_APP_SECRET", accountId: "", tokenEnv: "", secretSet: true },
|
||||
sessionMappings: [],
|
||||
lastError: "",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
access: { enabled: true, allowAll: false, pairingEnabled: true, users: ["ou_mock_user_001"], groups: [], approvers: [], admins: [] },
|
||||
},
|
||||
];
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
Settings: async () => botsSettings,
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
botsRoot.render(
|
||||
<LocaleProvider>
|
||||
<SettingsPanel initialTab="bots" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("bot channel manager", () => Boolean(document.querySelector(".bot-channel-manager")));
|
||||
|
||||
ok(!document.querySelector(".bot-overview-grid"), "bots tab does not render the removed entry overview");
|
||||
ok(!document.getElementById("bot-mobile-remote"), "bots tab no longer renders the mobile remote entry card");
|
||||
ok(!document.querySelector(".bot-channel-entry"), "bots tab no longer renders the Bot Channel entry panel");
|
||||
ok(!document.getElementById("bot-step-access"), "bots tab omits the old global access step card");
|
||||
ok(!document.getElementById("bot-step-behavior"), "bots tab omits global default behavior card");
|
||||
eq(document.querySelectorAll(".bot-step-chip").length, 0, "hero no longer shows the old two-step chips");
|
||||
|
||||
eq(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]").length, 4, "bot manager uses four fixed channel tabs on the left");
|
||||
ok(document.querySelector(".bot-channel-setup-card")?.textContent?.includes("Configure QQ") === true, "unconfigured QQ tab shows key setup on the right");
|
||||
ok(document.body.textContent?.includes("Back to entry") === false, "bot manager does not show a return-to-entry action");
|
||||
|
||||
const feishuTab = Array.from(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]")).find((button) => button.textContent?.includes("Feishu")) as HTMLButtonElement | undefined;
|
||||
if (!feishuTab) throw new Error("Feishu channel tab did not render");
|
||||
await act(async () => {
|
||||
feishuTab.click();
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("selected Feishu detail", () => Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-card")));
|
||||
|
||||
ok(Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-card")), "configured channel renders selected bot detail on the right");
|
||||
ok(Boolean(document.querySelector(".bot-channel-manager__detail .bot-detail-section--access")), "selected bot detail owns its access control");
|
||||
ok(document.body.textContent?.includes("Access control") === true, "selected bot detail labels per-bot access control");
|
||||
const selectedBotDetailText = document.querySelector(".bot-channel-manager__detail .bot-detail-card")?.textContent ?? "";
|
||||
const connectionSummaryIndex = selectedBotDetailText.indexOf("Connection summary");
|
||||
const enableBotIndex = selectedBotDetailText.indexOf("Enable bot");
|
||||
const toolApprovalIndex = selectedBotDetailText.indexOf("Tool approval");
|
||||
const modelIndex = selectedBotDetailText.indexOf("Model");
|
||||
const accessControlIndex = selectedBotDetailText.indexOf("Access control");
|
||||
ok(
|
||||
connectionSummaryIndex >= 0 && enableBotIndex > connectionSummaryIndex && toolApprovalIndex > enableBotIndex && modelIndex > toolApprovalIndex && accessControlIndex > modelIndex,
|
||||
"selected bot detail places enable, approval, and model controls between summary and access control",
|
||||
);
|
||||
ok(document.body.textContent?.includes("ou_mock_user_001") === true, "selected bot detail shows its trusted user");
|
||||
ok(document.body.textContent?.includes("Legacy global allowlist") === true, "advanced area keeps the legacy global allowlist");
|
||||
ok(document.querySelector(".bot-simple-advanced")?.textContent?.includes("local control API") === false, "advanced area no longer owns mobile/control API setup");
|
||||
|
||||
await act(async () => {
|
||||
botsRoot.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
|
||||
@@ -84,5 +84,10 @@ console.log("\nstatus bar workspace");
|
||||
ok(!html.includes("后台作业") && !html.includes("Background jobs"), "status bar omits non-configurable job indicators");
|
||||
}
|
||||
|
||||
{
|
||||
const defaultItems = DEFAULT_STATUS_BAR_ITEMS as readonly string[];
|
||||
ok(!defaultItems.includes("autoresearch"), "autoresearch is not a configurable status bar UI item");
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -130,23 +130,40 @@ const tabA = tabMeta("tab-a", { active: true });
|
||||
const tabB = tabMeta("tab-b");
|
||||
const tabC = tabMeta("tab-c");
|
||||
const tabD = tabMeta("tab-d");
|
||||
const tabE = tabMeta("tab-e");
|
||||
const tabF = tabMeta("tab-f");
|
||||
const tabG = tabMeta("tab-g");
|
||||
const tabH = tabMeta("tab-h");
|
||||
let backendActiveId = "tab-a";
|
||||
const historyB = deferred<HistoryMessage[]>();
|
||||
const historyD = deferred<HistoryMessage[]>();
|
||||
let metaH = deferred<Meta>();
|
||||
let historyH = deferred<HistoryMessage[]>();
|
||||
const contextDGate = deferred<ContextInfo>();
|
||||
const setActiveBGate = deferred<void>();
|
||||
const setActiveEGate = deferred<void>();
|
||||
const setActiveFGate = deferred<void>();
|
||||
const submitTabCGate = deferred<void>();
|
||||
const historyCalls: string[] = [];
|
||||
const cancelCalls: string[] = [];
|
||||
let contextDCalls = 0;
|
||||
let metaHCalls = 0;
|
||||
let holdNextContextForD = false;
|
||||
let holdNextMetaForH = false;
|
||||
let holdNextHistoryForH = false;
|
||||
let setActiveCalls = 0;
|
||||
let newSessionCalls = 0;
|
||||
let failSetActiveFor = "";
|
||||
const runningTabs = new Set<string>();
|
||||
const tabsById = new Map([tabA, tabB, tabC, tabD].map((tab) => [tab.id, tab]));
|
||||
const tabsById = new Map([tabA, tabB, tabC, tabD, tabE, tabF, tabG, tabH].map((tab) => [tab.id, tab]));
|
||||
const eventHandlers: Array<(e: WireEvent) => void> = [];
|
||||
const readyHandlers: Array<(tabId?: string) => void> = [];
|
||||
|
||||
function currentTabs(): TabMeta[] {
|
||||
return [tabA, tabB, tabC, tabD].map((tab) => ({ ...tab, active: tab.id === backendActiveId, running: runningTabs.has(tab.id) }));
|
||||
return Array.from(tabsById.values()).map((tab) => {
|
||||
const running = runningTabs.has(tab.id);
|
||||
return { ...tab, active: tab.id === backendActiveId, running, cancellable: running };
|
||||
});
|
||||
}
|
||||
|
||||
window.runtime = {
|
||||
@@ -161,7 +178,14 @@ window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => currentTabs(),
|
||||
MetaForTab: async (tabID: string) => metaFor(tabsById.get(tabID) ?? tabA),
|
||||
MetaForTab: async (tabID: string) => {
|
||||
if (tabID === "tab-h" && holdNextMetaForH) {
|
||||
metaHCalls += 1;
|
||||
holdNextMetaForH = false;
|
||||
return metaH.promise;
|
||||
}
|
||||
return metaFor(tabsById.get(tabID) ?? tabA);
|
||||
},
|
||||
ContextUsageForTab: async (tabID: string) => {
|
||||
if (tabID === "tab-d" && holdNextContextForD) {
|
||||
contextDCalls += 1;
|
||||
@@ -179,6 +203,13 @@ window.go = {
|
||||
historyCalls.push(tabID);
|
||||
if (tabID === "tab-b") return historyB.promise;
|
||||
if (tabID === "tab-d") return historyD.promise;
|
||||
if (tabID === "tab-e") return [userMessage("fork E")];
|
||||
if (tabID === "tab-g") return [userMessage("history G")];
|
||||
if (tabID === "tab-h" && holdNextHistoryForH) {
|
||||
holdNextHistoryForH = false;
|
||||
return historyH.promise;
|
||||
}
|
||||
if (tabID === "tab-h") return [userMessage("history H")];
|
||||
return [userMessage("cached A")];
|
||||
},
|
||||
HistoryPageForTab: async (tabID: string) => {
|
||||
@@ -186,21 +217,41 @@ window.go = {
|
||||
return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false };
|
||||
},
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
OpenProjectTab: async () => {
|
||||
backendActiveId = "tab-d";
|
||||
return { ...(tabsById.get("tab-d") ?? tabD), active: true };
|
||||
OpenProjectTab: async (workspaceRoot: string, topicId: string) => {
|
||||
const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === workspaceRoot && tab.topicId === topicId) ?? tabD;
|
||||
backendActiveId = target.id;
|
||||
return { ...target, active: true };
|
||||
},
|
||||
ActivateTopic: async (_scope: string, workspaceRoot: string, topicId: string) => {
|
||||
const target = Array.from(tabsById.values()).find((tab) => tab.workspaceRoot === workspaceRoot && tab.topicId === topicId) ?? tabG;
|
||||
backendActiveId = target.id;
|
||||
return { ...target, active: true };
|
||||
},
|
||||
NewSession: async () => {
|
||||
newSessionCalls += 1;
|
||||
},
|
||||
Fork: async () => {
|
||||
tabsById.set("tab-e", tabE);
|
||||
backendActiveId = "tab-e";
|
||||
runningTabs.add("tab-e");
|
||||
return { ...tabE, active: true, running: true };
|
||||
},
|
||||
ReplayPendingPrompts: async () => {},
|
||||
SetActiveTab: async (tabID: string) => {
|
||||
setActiveCalls += 1;
|
||||
if (tabID === "tab-b") await setActiveBGate.promise;
|
||||
if (tabID === "tab-e") await setActiveEGate.promise;
|
||||
if (tabID === "tab-f") await setActiveFGate.promise;
|
||||
if (tabID === failSetActiveFor) throw new Error("persist failed");
|
||||
backendActiveId = tabID;
|
||||
},
|
||||
CancelTab: async (tabID: string) => {
|
||||
cancelCalls.push(tabID);
|
||||
runningTabs.delete(tabID);
|
||||
},
|
||||
SubmitToTab: async (tabID: string) => {
|
||||
runningTabs.add(tabID);
|
||||
if (tabID === "tab-c") await submitTabCGate.promise;
|
||||
},
|
||||
SubmitDisplayToTab: async (tabID: string) => {
|
||||
runningTabs.add(tabID);
|
||||
@@ -275,6 +326,93 @@ eq(controller?.activeTabId, "tab-a", "late history for another tab does not chan
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "cached A") ?? false, "late history for another tab does not overwrite the active transcript");
|
||||
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false), "late history stays scoped to its tab state");
|
||||
|
||||
const historyCallsBeforeFallbackSync = historyCalls.length;
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) handler({ kind: "approval_request", tabId: "tab-b", approval: { id: "stale-fallback-approval", tool: "bash", subject: "stale fallback approval" } });
|
||||
await flushPromises();
|
||||
});
|
||||
backendActiveId = "tab-b";
|
||||
await act(async () => {
|
||||
await controller?.syncActiveTab(false);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-b", "backend fallback sync activates the backend-selected cached tab");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "late B") ?? false, "backend fallback sync keeps the cached transcript");
|
||||
eq(historyCalls.length, historyCallsBeforeFallbackSync, "backend fallback sync preserves cached history instead of reloading it");
|
||||
eq(controller?.state.approval?.id, undefined, "backend fallback sync reconciles stale approval state");
|
||||
eq(controller?.state.running, false, "backend fallback sync reconciles stale running state");
|
||||
await act(async () => {
|
||||
await controller?.switchTab("tab-a", tabA);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("tab-a restored after fallback sync", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
|
||||
|
||||
runningTabs.add("tab-e");
|
||||
let switchToE: Promise<TabMeta[] | undefined> | undefined;
|
||||
await act(async () => {
|
||||
switchToE = controller?.switchTab("tab-e", { ...tabE, running: true, cancellable: true });
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-e", "switching to a backend-running tab updates the active tab immediately");
|
||||
eq(controller?.state.running, true, "backend-running tab restores the stop state before backend activation settles");
|
||||
eq(controller?.state.cancellable, true, "backend-running tab remains cancellable before backend activation settles");
|
||||
await act(async () => {
|
||||
controller?.cancel();
|
||||
await flushPromises();
|
||||
});
|
||||
eq(cancelCalls.join(","), "tab-e", "cancel targets the backend-running tab while activation is pending");
|
||||
await act(async () => {
|
||||
setActiveEGate.resolve();
|
||||
await switchToE;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.running, false, "cancelled backend-running tab reconciles to idle after activation");
|
||||
await act(async () => {
|
||||
await controller?.switchTab("tab-a", tabA);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("tab-a restored after backend-running switch", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
|
||||
|
||||
let switchToF: Promise<TabMeta[] | undefined> | undefined;
|
||||
await act(async () => {
|
||||
switchToF = controller?.switchTab("tab-f", tabF);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-f", "first rapid switch activates the slow target optimistically");
|
||||
let switchToG: Promise<TabMeta[] | undefined> | undefined;
|
||||
await act(async () => {
|
||||
switchToG = controller?.switchTab("tab-g", tabG);
|
||||
await switchToG;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-g", "second rapid switch wins immediately");
|
||||
eq(backendActiveId, "tab-g", "second rapid switch activates the backend");
|
||||
await act(async () => {
|
||||
setActiveFGate.resolve();
|
||||
await switchToF;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-g", "late completion from the first rapid switch does not replace the visible tab");
|
||||
eq(backendActiveId, "tab-g", "late completion from the first rapid switch reasserts the last-clicked backend tab");
|
||||
ok(!historyCalls.includes("tab-f"), "late completion from the first rapid switch does not hydrate the stale target");
|
||||
|
||||
await act(async () => {
|
||||
await controller?.switchTab("tab-a", tabA);
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("tab-a restored after rapid switch", () => controller?.activeTabId === "tab-a" && controller.state.items.some((item) => item.kind === "user" && item.text === "cached A"));
|
||||
|
||||
failSetActiveFor = "tab-b";
|
||||
const historyCallsBeforeFailedSwitch = historyCalls.length;
|
||||
await act(async () => {
|
||||
await controller?.switchTab("tab-b", tabB);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-a", "failed backend tab switch reverts to the previous active tab");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "cached A") ?? false, "failed backend tab switch keeps the previous transcript visible");
|
||||
eq(historyCalls.length, historyCallsBeforeFailedSwitch, "failed backend tab switch does not hydrate the rejected target");
|
||||
failSetActiveFor = "";
|
||||
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) handler({ kind: "phase", text: "Planner is thinking", tabId: "tab-a" });
|
||||
for (const handler of eventHandlers) handler({ kind: "message", text: "Planner kept", reasoning: "Planner notes", tabId: "tab-a" });
|
||||
@@ -293,10 +431,15 @@ eq(historyCalls.length, historyCallsBeforeReady, "agent ready with cached transc
|
||||
ok(controller?.state.items.some((item) => item.kind === "phase" && item.text === "Planner is thinking") ?? false, "agent ready keeps cached planner phase");
|
||||
ok(controller?.state.items.some((item) => item.kind === "assistant" && item.text === "Planner kept" && item.reasoning === "Planner notes") ?? false, "agent ready keeps cached planner answer");
|
||||
|
||||
let tabCSendResolved = false;
|
||||
await act(async () => {
|
||||
controller?.sendToTab("tab-c", "streaming C");
|
||||
const sendPromise = controller?.sendToTab("tab-c", "streaming C");
|
||||
sendPromise?.then(() => {
|
||||
tabCSendResolved = true;
|
||||
});
|
||||
await flushPromises();
|
||||
});
|
||||
eq(tabCSendResolved, true, "sendToTab resolves after optimistic dispatch before backend submit completes");
|
||||
await act(async () => {
|
||||
await controller?.switchTab("tab-c", tabC);
|
||||
await flushPromises();
|
||||
@@ -304,6 +447,11 @@ await act(async () => {
|
||||
eq(controller?.activeTabId, "tab-c", "switching to a cached running tab still updates the active tab");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "streaming C") ?? false, "cached running tab keeps its optimistic transcript");
|
||||
ok(!historyCalls.includes("tab-c"), "cached running tab skips history hydration");
|
||||
await act(async () => {
|
||||
submitTabCGate.resolve();
|
||||
await submitTabCGate.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
holdNextContextForD = true;
|
||||
await act(async () => {
|
||||
@@ -342,6 +490,11 @@ ok(controller?.state.items.some((item) => item.kind === "user" && item.text ===
|
||||
eq(controller?.state.hydratePlaceholderItems?.length ?? 0, 0, "topic history clears the hydration placeholder");
|
||||
|
||||
const historyCallsBeforeReopenD = historyCalls.length;
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) handler({ kind: "approval_request", tabId: "tab-d", approval: { id: "stale-approval", tool: "bash", subject: "stale approval" } });
|
||||
await controller?.switchTab("tab-a", tabA);
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabD.workspaceRoot, tabD.topicId || "");
|
||||
await flushPromises();
|
||||
@@ -349,6 +502,17 @@ await act(async () => {
|
||||
eq(controller?.activeTabId, "tab-d", "reopening an already hydrated topic keeps it active");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "history D") ?? false, "reopened cached topic keeps its transcript");
|
||||
eq(historyCalls.length, historyCallsBeforeReopenD, "reopening an already hydrated topic skips history hydration");
|
||||
eq(controller?.state.approval?.id, undefined, "reopening a topic reconciles stale approval state");
|
||||
eq(controller?.state.running, false, "reopening a topic reconciles stale running state");
|
||||
|
||||
await act(async () => {
|
||||
await controller?.rewind(0, "fork");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-e", "fork activates the forked tab");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "fork E") ?? false, "fork loads the forked transcript");
|
||||
eq(controller?.state.running, true, "fork reconciles backend running state after reset hydration");
|
||||
runningTabs.delete("tab-e");
|
||||
|
||||
const contextCallsBeforeInactiveD = contextDCalls;
|
||||
await act(async () => {
|
||||
@@ -370,6 +534,72 @@ await act(async () => {
|
||||
});
|
||||
eq(historyCalls.length, historyCallsBeforeReboundD + 1, "rebound topic reloads history when session path changes");
|
||||
|
||||
metaH = deferred<Meta>();
|
||||
holdNextMetaForH = true;
|
||||
const historyCallsBeforeSlowMeta = historyCalls.length;
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("slow meta tab hydrates history", () =>
|
||||
controller?.activeTabId === "tab-h" &&
|
||||
controller.state.hydrating === false &&
|
||||
(controller.state.items.some((item) => item.kind === "user" && item.text === "history H") ?? false) &&
|
||||
metaHCalls === 1
|
||||
);
|
||||
eq(historyCalls.length, historyCallsBeforeSlowMeta + 1, "slow MetaForTab does not delay the history request");
|
||||
eq(controller?.state.meta?.label, "model-tab-h", "slow MetaForTab leaves optimistic metadata visible while history hydrates");
|
||||
await act(async () => {
|
||||
metaH.resolve({ ...metaFor(tabH), label: "fresh-model-tab-h" });
|
||||
await metaH.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("slow meta refresh applies", () => controller?.state.meta?.label === "fresh-model-tab-h");
|
||||
|
||||
metaH = deferred<Meta>();
|
||||
const metaHCallsBeforeStale = metaHCalls;
|
||||
holdNextMetaForH = true;
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("slow meta pending before single-surface navigation", () => metaHCalls === metaHCallsBeforeStale + 1);
|
||||
await act(async () => {
|
||||
await controller?.activateTopic("project", tabG.workspaceRoot, tabG.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("single-surface activation replaces visible tab", () =>
|
||||
controller?.activeTabId === "tab-g" &&
|
||||
(controller.state.items.some((item) => item.kind === "user" && item.text === "history G") ?? false)
|
||||
);
|
||||
await act(async () => {
|
||||
metaH.resolve({ ...metaFor(tabH), label: "stale-model-tab-h" });
|
||||
await metaH.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, "tab-g", "late meta from a replaced tab does not switch the visible tab");
|
||||
ok(controller?.state.meta?.label !== "stale-model-tab-h", "late meta from a replaced tab does not overwrite visible metadata");
|
||||
historyH = deferred<HistoryMessage[]>();
|
||||
holdNextHistoryForH = true;
|
||||
await act(async () => {
|
||||
await controller?.openProjectTab(tabH.workspaceRoot, tabH.topicId || "");
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("reopened tab-h treats stale late meta as discarded", () =>
|
||||
controller?.activeTabId === "tab-h" &&
|
||||
controller.state.hydrating === true &&
|
||||
(controller.state.hydratePlaceholderItems?.some((item) => item.kind === "user" && item.text === "history G") ?? false)
|
||||
);
|
||||
await act(async () => {
|
||||
historyH.resolve([userMessage("history H after stale meta")]);
|
||||
await historyH.promise;
|
||||
await flushPromises();
|
||||
});
|
||||
await waitFor("reopened tab-h finishes after stale meta discard", () =>
|
||||
controller?.state.hydrating === false &&
|
||||
(controller.state.items.some((item) => item.kind === "user" && item.text === "history H after stale meta") ?? false)
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { buildStepGroups, buildTurnGroups, createWarmLayerState, questionTurnsById, warmColdPageForTurn, warmLayerForSession, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination } from "../lib/transcriptGrouping";
|
||||
import { buildStepGroups, buildTurnGroups, createWarmLayerState, lastQuestionTurn, questionTurnsById, warmColdPageForTurn, warmLayerForSession, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination } from "../lib/transcriptGrouping";
|
||||
import type { Item } from "../lib/useController";
|
||||
|
||||
let passed = 0;
|
||||
@@ -105,6 +105,22 @@ console.log("\ntranscript grouping contract");
|
||||
eq(backendTurns.get("u0"), 0, "uses backend checkpoint turn zero when present");
|
||||
eq(backendTurns.get("u2"), 3, "uses non-contiguous backend checkpoint turn");
|
||||
ok(!backendTurns.has("u1"), "does not mix visible ordinal fallback into authoritative checkpoint sessions");
|
||||
eq(lastQuestionTurn([
|
||||
{ id: "u0", text: "first", turn: 0 },
|
||||
{ id: "u1", text: "second", turn: 1 },
|
||||
], visibleTurns), 1, "last question turn follows visible ordinal fallback");
|
||||
eq(lastQuestionTurn([
|
||||
{ id: "u0", text: "first", turn: 0, checkpointTurn: 0 },
|
||||
{ id: "u1", text: "live without server stamp yet", turn: 1 },
|
||||
{ id: "u2", text: "after hidden synthetic", turn: 2, checkpointTurn: 3 },
|
||||
], backendTurns), 3, "last question turn follows non-contiguous backend turn");
|
||||
|
||||
const pagedTurns = questionTurnsById([
|
||||
{ id: "u-recent", text: "recent prompt", turn: 0, checkpointTurn: 1060 },
|
||||
]);
|
||||
eq(lastQuestionTurn([
|
||||
{ id: "u-recent", text: "recent prompt", turn: 0, checkpointTurn: 1060 },
|
||||
], pagedTurns), 1060, "last question turn supports paged history windows");
|
||||
}
|
||||
|
||||
{
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Run: tsx src/__tests__/turn-action-copy.test.ts
|
||||
|
||||
import { appendTurnActionCopyText } from "../lib/turnActionCopy";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nturn action copy");
|
||||
|
||||
eq(
|
||||
appendTurnActionCopyText("", "final answer"),
|
||||
"final answer",
|
||||
"keeps single assistant output unchanged",
|
||||
);
|
||||
|
||||
eq(
|
||||
appendTurnActionCopyText("first assistant block", "second assistant block"),
|
||||
"first assistant block\n\nsecond assistant block",
|
||||
"accumulates multiple assistant outputs in one turn",
|
||||
);
|
||||
|
||||
eq(
|
||||
appendTurnActionCopyText("first assistant block\n", "second assistant block"),
|
||||
"first assistant block\nsecond assistant block",
|
||||
"does not add an extra blank line when the previous block already ends with newline",
|
||||
);
|
||||
|
||||
eq(
|
||||
appendTurnActionCopyText("first assistant block", "\nsecond assistant block"),
|
||||
"first assistant block\nsecond assistant block",
|
||||
"does not add an extra blank line when the next block starts with newline",
|
||||
);
|
||||
|
||||
eq(
|
||||
appendTurnActionCopyText("final answer", " \n\t"),
|
||||
"final answer",
|
||||
"ignores whitespace-only assistant fragments",
|
||||
);
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -113,6 +113,10 @@ eq(
|
||||
"var(--statusbar-dock-height)",
|
||||
"status bar min-height follows the scaled dock token",
|
||||
);
|
||||
eq(finalDeclaration(".provider-template-grid", "grid-auto-rows"), "92px", "provider preset cards use compact equal-height grid rows");
|
||||
eq(finalDeclaration(".provider-template-card", "height"), "100%", "provider preset cards stretch to the grid row height");
|
||||
eq(finalDeclaration(".provider-template-card strong", "-webkit-line-clamp"), "1", "provider preset card titles clamp to one line");
|
||||
eq(finalDeclaration(".provider-template-card span", "-webkit-line-clamp"), "2", "provider preset card descriptions clamp to two lines");
|
||||
|
||||
eq(finalDeclaration(".statusbar", "white-space"), "nowrap", "status bar keeps metrics on one row");
|
||||
eq(finalDeclaration(".statusbar", "overflow"), "hidden", "status bar clips instead of overflowing");
|
||||
@@ -160,6 +164,16 @@ ok(
|
||||
);
|
||||
|
||||
eq(finalDeclaration(".composer-modebar", "overflow"), "hidden", "chat mode switcher contains enlarged labels");
|
||||
eq(
|
||||
finalDeclaration(".app--creation .tool:not(.tool--open) > .tool__body", "height"),
|
||||
"0 !important",
|
||||
"collapsed creation tool bodies keep mounted content clipped",
|
||||
);
|
||||
eq(
|
||||
finalDeclaration(".app--creation .tool:not(.tool--open) > .tool__body", "visibility"),
|
||||
"hidden",
|
||||
"collapsed creation tool bodies do not paint hidden tool text",
|
||||
);
|
||||
ok(
|
||||
/@container\s*\(max-width:\s*760px\)[\s\S]*?\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(240px,\s*42vw\)[\s\S]*?\.composer-meta--has-intent-chip\s+\.composer-meta__control--model\s*\{[\s\S]*?flex\s*:\s*0 1 auto[\s\S]*?width\s*:\s*fit-content[\s\S]*?max-width\s*:\s*min\(220px,\s*38vw\)[\s\S]*?\.composer-meta__control--effort\s*\{[\s\S]*?display\s*:\s*none[\s\S]*?\.composer-meta__control--more\s*\{[\s\S]*?display\s*:\s*inline-flex/.test(styles),
|
||||
"composer compact controls activate at the capped theme width",
|
||||
|
||||
@@ -102,6 +102,7 @@ globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.windo
|
||||
const eventHandlers: Array<(e: WireEvent) => void> = [];
|
||||
let backendRunning = false;
|
||||
let cancelCalls = 0;
|
||||
let effortCalls = 0;
|
||||
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
|
||||
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
|
||||
|
||||
@@ -119,6 +120,10 @@ window.go = {
|
||||
MetaForTab: async () => meta(),
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
SetEffortForTab: async () => {
|
||||
effortCalls += 1;
|
||||
throw new Error("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing effort");
|
||||
},
|
||||
BalanceForTab: async () => ({ available: false, display: "" }),
|
||||
JobsForTab: async () => [],
|
||||
CheckpointsForTab: async () => [],
|
||||
@@ -178,6 +183,15 @@ eq(controller?.state.running, false, "cancel reconciliation clears the running s
|
||||
eq(cancelCalls, 1, "CancelTab is called once");
|
||||
eq(controller?.state.cancelRequested, false, "cancel reconciliation clears cancelRequested");
|
||||
|
||||
await act(async () => {
|
||||
await controller?.setEffort("max");
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
const effortNotice = controller?.state.items.find((item) => item.kind === "notice" && item.text.includes("cannot change yet"));
|
||||
eq(effortCalls, 1, "SetEffortForTab is called once");
|
||||
ok(Boolean(effortNotice), "busy effort switch surfaces a non-failure warning notice");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Run: tsx src/__tests__/use-controller-meta.test.ts
|
||||
|
||||
import { foregroundRunningFromRuntimeMeta, initialState, metaFromTab, reducer, sameMeta, shouldReconcileStaleTurn } from "../lib/useController";
|
||||
import type { Meta, TabMeta, WireUsage } from "../lib/types";
|
||||
import { effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, historyMessagesToItems, initialState, localizedBackendNoticeText, metaFromTab, modelSwitchNoticeText, reducer, sameMeta, shouldReconcileStaleTurn, tokenModeSwitchNoticeText } from "../lib/useController";
|
||||
import type { HistoryMessage, Meta, TabMeta, WireUsage } from "../lib/types";
|
||||
|
||||
type LooseTabMeta = Omit<TabMeta, "toolApprovalMode"> & { toolApprovalMode?: TabMeta["toolApprovalMode"] | "" };
|
||||
|
||||
@@ -82,6 +82,161 @@ function usage(source: string): WireUsage {
|
||||
|
||||
console.log("\nuse controller meta");
|
||||
|
||||
{
|
||||
eq(
|
||||
modelSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing model"),
|
||||
"The model cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
|
||||
"model busy guard is localized",
|
||||
);
|
||||
eq(
|
||||
modelSwitchNoticeText("this session is already open in another Reasonix window or still running in the background; close the other window or open a copy before changing model"),
|
||||
"This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing models.",
|
||||
"model lease conflict explains the safe path",
|
||||
);
|
||||
eq(
|
||||
modelSwitchNoticeText("workspace is still starting"),
|
||||
"This session is still starting. Try changing models again in a moment.",
|
||||
"model startup race asks the user to retry later",
|
||||
);
|
||||
eq(
|
||||
modelSwitchNoticeText('tab "tab-a" changed while switching model; retry'),
|
||||
"The current session changed while switching models. Try once more.",
|
||||
"model tab race asks the user to retry",
|
||||
);
|
||||
eq(
|
||||
modelSwitchNoticeText('unknown model "missing"'),
|
||||
'Unknown model "missing".',
|
||||
"model unknown error is localized",
|
||||
);
|
||||
eq(
|
||||
modelSwitchNoticeText('model "other/other-model" is not available because provider "other" is not added'),
|
||||
'Model "other/other-model" is unavailable because provider "other" is not added.',
|
||||
"model provider access error is localized",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
eq(
|
||||
effortSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing effort"),
|
||||
"Reasoning effort cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
|
||||
"effort busy guard is worded as temporary",
|
||||
);
|
||||
eq(
|
||||
effortSwitchNoticeText("this session is already open in another Reasonix window or still running in the background; close the other window or open a copy before changing effort"),
|
||||
"This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing effort.",
|
||||
"effort lease conflict explains the safe path",
|
||||
);
|
||||
eq(
|
||||
effortSwitchNoticeText("workspace is still starting"),
|
||||
"This session is still starting. Try changing reasoning effort again in a moment.",
|
||||
"effort startup race asks the user to retry later",
|
||||
);
|
||||
eq(
|
||||
effortSwitchNoticeText('tab "tab-a" changed while switching effort; retry'),
|
||||
"The current session changed while switching reasoning effort. Try once more.",
|
||||
"effort tab race asks the user to retry",
|
||||
);
|
||||
eq(
|
||||
effortSwitchNoticeText("unknown model \"missing\""),
|
||||
"Reasoning effort switch failed: unknown model \"missing\"",
|
||||
"effort true failure keeps the underlying error",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
eq(
|
||||
tokenModeSwitchNoticeText("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing token mode"),
|
||||
"Token saver cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
|
||||
"token mode busy guard is localized",
|
||||
);
|
||||
eq(
|
||||
tokenModeSwitchNoticeText('tab "tab-a" changed while switching token mode; retry'),
|
||||
"The current session changed while switching token saver. Try once more.",
|
||||
"token mode tab race asks the user to retry",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
eq(
|
||||
localizedBackendNoticeText("Session autosave failed: disk full"),
|
||||
"Session autosave failed: disk full",
|
||||
"backend autosave notice is localized through the active dictionary",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("Session save failed before changing model: disk full"),
|
||||
"Session save failed before changing models: disk full",
|
||||
"backend save-before-action notice localizes the action",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText('model "old/model" is no longer available; switched to new/model'),
|
||||
'Model "old/model" is no longer available; switched to new/model.',
|
||||
"backend model fallback notice is localized",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as recovery branch 20260706-152144.863947300-longcat-openai-LongCat-2.0-119b7259f151-recovery-693ce51bcbcbaa9"),
|
||||
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
|
||||
"legacy recovery branch notice hides internal branch id",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session changed on disk; unsaved local transcript was saved as a conflict copy"),
|
||||
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
|
||||
"recovery copy notice is user-facing",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session conflicts kept recurring; kept the transcript on the current recovery branch"),
|
||||
"Repeated save conflicts were detected, so the current conflict copy was saved in place.",
|
||||
"legacy repeated recovery conflict notice is user-facing",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("repeated save conflicts were detected; saved the current conflict copy in place"),
|
||||
"Repeated save conflicts were detected, so the current conflict copy was saved in place.",
|
||||
"repeated recovery conflict notice is user-facing",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session changed on disk; adopted the newer transcript"),
|
||||
"The session changed on disk, so Reasonix adopted the newer transcript.",
|
||||
"adopted transcript notice is user-facing",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session changed on disk; adopted the newer transcript (local changes already covered)"),
|
||||
"The session changed on disk, so Reasonix adopted the newer transcript; the local changes were already covered.",
|
||||
"covered adopted transcript notice is user-facing",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let s = reducer(initialState, {
|
||||
type: "event",
|
||||
e: { kind: "notice", level: "warn", text: "session conflicts kept recurring; kept the transcript on the current recovery branch" },
|
||||
});
|
||||
s = reducer(s, {
|
||||
type: "event",
|
||||
e: { kind: "notice", level: "warn", text: "repeated save conflicts were detected; saved the current conflict copy in place" },
|
||||
});
|
||||
const recoveryNotices = s.items.filter((item) => item.kind === "notice" && item.text.includes("current conflict copy"));
|
||||
eq(recoveryNotices.length, 1, "repeated recovery conflict notices are collapsed in the live transcript");
|
||||
eq(s.seq, 1, "collapsed recovery notice does not consume a sequence id");
|
||||
|
||||
s = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "runtime notice" } });
|
||||
s = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "runtime notice" } });
|
||||
const ordinaryNotices = s.items.filter((item) => item.kind === "notice" && item.text === "runtime notice");
|
||||
eq(ordinaryNotices.length, 2, "ordinary repeated notices remain visible");
|
||||
}
|
||||
|
||||
{
|
||||
const history: HistoryMessage[] = [
|
||||
{ role: "notice", level: "warn", content: "session conflicts kept recurring; kept the transcript on the current recovery branch" },
|
||||
{ role: "notice", level: "warn", content: "repeated save conflicts were detected; saved the current conflict copy in place" },
|
||||
{ role: "user", content: "continue" },
|
||||
];
|
||||
const hydrated = historyMessagesToItems(history, "h");
|
||||
const recoveryNotices = hydrated.items.filter((item) => item.kind === "notice" && item.text.includes("current conflict copy"));
|
||||
const users = hydrated.items.filter((item) => item.kind === "user");
|
||||
eq(recoveryNotices.length, 1, "repeated recovery conflict notices are collapsed when hydrating history");
|
||||
eq(users[0]?.kind === "user" && users[0].id, "h1", "collapsed history notice keeps later item ids compact");
|
||||
eq(hydrated.seq, 2, "collapsed history notice does not inflate the hydrated sequence");
|
||||
}
|
||||
|
||||
{
|
||||
eq(sameMeta(meta(), meta()), true, "identical meta is unchanged");
|
||||
eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "plan" })), false, "collaboration mode changes invalidate meta equality");
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// Run: tsx src/__tests__/use-controller-send-fallback.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import { useController } from "../lib/useController";
|
||||
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, JobView, Meta, TabMeta } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
|
||||
}
|
||||
|
||||
function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
|
||||
return {
|
||||
id: "tab-send",
|
||||
scope: "project",
|
||||
workspaceRoot: "/repo/send",
|
||||
workspaceName: "send",
|
||||
workspacePath: "/repo/send",
|
||||
gitBranch: "main",
|
||||
topicId: "topic-send",
|
||||
topicTitle: "Send",
|
||||
label: "model-send",
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: true,
|
||||
cwd: "/repo/send",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function metaFor(tab: TabMeta): Meta {
|
||||
return {
|
||||
label: tab.label,
|
||||
ready: tab.ready,
|
||||
startupErr: tab.startupErr,
|
||||
eventChannel: "agent:event",
|
||||
cwd: tab.cwd || tab.workspaceRoot,
|
||||
workspaceRoot: tab.workspaceRoot,
|
||||
workspaceName: tab.workspaceName,
|
||||
workspacePath: tab.workspacePath,
|
||||
gitBranch: tab.gitBranch,
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
collaborationMode: tab.collaborationMode ?? "normal",
|
||||
toolApprovalMode: tab.toolApprovalMode ?? "ask",
|
||||
tokenMode: tab.tokenMode ?? "full",
|
||||
goal: "",
|
||||
goalStatus: "stopped",
|
||||
};
|
||||
}
|
||||
|
||||
console.log("\nuse controller send fallback");
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
const backendTab = tabMeta({ backgroundJobs: 2 });
|
||||
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
|
||||
const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
|
||||
const balance: BalanceInfo = { available: false, display: "" };
|
||||
const jobs: JobView[] = [];
|
||||
const checkpoints: CheckpointMeta[] = [];
|
||||
let tabsAvailable = false;
|
||||
let submitCalls = 0;
|
||||
|
||||
window.runtime = {
|
||||
EventsOn: () => () => {},
|
||||
BrowserOpenURL: () => {},
|
||||
};
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListTabs: async () => (tabsAvailable ? [backendTab] : []),
|
||||
MetaForTab: async () => metaFor(backendTab),
|
||||
ContextUsageForTab: async () => context,
|
||||
EffortForTab: async () => effort,
|
||||
BalanceForTab: async () => balance,
|
||||
JobsForTab: async () => jobs,
|
||||
CheckpointsForTab: async () => checkpoints,
|
||||
HistoryForTab: async (): Promise<HistoryMessage[]> => [],
|
||||
HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
|
||||
HistoryCheckpointTurnsForTab: async () => [],
|
||||
ReplayPendingPrompts: async () => {},
|
||||
SubmitToTab: async (tabId: string) => {
|
||||
submitCalls += tabId === "tab-send" ? 1 : 0;
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
type Controller = ReturnType<typeof useController>;
|
||||
let controller: Controller | undefined;
|
||||
|
||||
function Probe() {
|
||||
controller = useController();
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Probe />);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.activeTabId, undefined, "startup has no active tab when backend has no tabs");
|
||||
|
||||
tabsAvailable = true;
|
||||
await act(async () => {
|
||||
await controller?.send("hello from fallback");
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
eq(controller?.activeTabId, "tab-send", "send fallback activates the backend-selected tab");
|
||||
eq(controller?.state.backgroundJobs, 2, "send fallback reconciles backend runtime metadata");
|
||||
ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello from fallback") ?? false, "send fallback keeps the optimistic user turn");
|
||||
eq(submitCalls, 1, "send fallback submits to the activated tab");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,124 @@
|
||||
// Run: tsx src/__tests__/wails-resize-fix.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { useWailsResizeFix } from "../lib/useWailsResizeFix";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function eq(actual: unknown, expected: unknown, label: string) {
|
||||
if (actual === expected) {
|
||||
ok(true, label);
|
||||
} else {
|
||||
ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function installDom(): JSDOM {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 100 });
|
||||
Object.defineProperty(window, "innerHeight", { configurable: true, value: 80 });
|
||||
return dom;
|
||||
}
|
||||
|
||||
function installWailsFlags(flags?: Partial<NonNullable<Window["wails"]>["flags"]>) {
|
||||
window.wails = {
|
||||
Callback: () => undefined,
|
||||
EventsNotify: () => undefined,
|
||||
flags: {
|
||||
enableResize: true,
|
||||
resizeEdge: undefined,
|
||||
borderThickness: 6,
|
||||
defaultCursor: null,
|
||||
cssDragProperty: "--wails-draggable",
|
||||
cssDragValue: "drag",
|
||||
cssDropProperty: "--wails-drop-target",
|
||||
cssDropValue: "drop",
|
||||
shouldDrag: false,
|
||||
deferDragToMouseMove: false,
|
||||
disableScrollbarDrag: false,
|
||||
disableDefaultContextMenu: false,
|
||||
enableWailsDragAndDrop: false,
|
||||
...flags,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function Harness({ enabled }: { enabled: boolean }) {
|
||||
useWailsResizeFix(enabled);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function renderHarness(root: Root, enabled: boolean) {
|
||||
await act(async () => {
|
||||
root.render(<Harness enabled={enabled} />);
|
||||
});
|
||||
}
|
||||
|
||||
async function unmount(root: Root) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
|
||||
console.log("\nwails resize fix");
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
installWailsFlags({ enableResize: true, resizeEdge: "n-resize" });
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
|
||||
await renderHarness(root, false);
|
||||
window.dispatchEvent(new MouseEvent("mousemove", { clientX: 97, clientY: 40 }));
|
||||
eq(window.wails?.flags.enableResize, true, "disabled hook leaves Wails resize enabled");
|
||||
eq(window.wails?.flags.resizeEdge, "n-resize", "disabled hook leaves resize edge unchanged");
|
||||
|
||||
await unmount(root);
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
document.documentElement.style.cursor = "grab";
|
||||
installWailsFlags({ enableResize: true, resizeEdge: "n-resize" });
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
|
||||
await renderHarness(root, true);
|
||||
eq(window.wails?.flags.enableResize, false, "enabled hook disables Wails built-in mousemove resize");
|
||||
|
||||
window.dispatchEvent(new MouseEvent("mousemove", { clientX: 97, clientY: 40 }));
|
||||
eq(window.wails?.flags.resizeEdge, "e-resize", "enabled hook detects right edge using innerWidth CSS pixels");
|
||||
eq(document.documentElement.style.cursor, "e-resize", "enabled hook mirrors detected edge cursor");
|
||||
|
||||
await unmount(root);
|
||||
eq(window.wails?.flags.enableResize, true, "cleanup restores previous enableResize");
|
||||
eq(window.wails?.flags.resizeEdge, "n-resize", "cleanup restores previous resizeEdge");
|
||||
eq(document.documentElement.style.cursor, "grab", "cleanup restores previous cursor");
|
||||
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -7,7 +7,7 @@ import { createRoot } from "react-dom/client";
|
||||
import { WorkspacePanel } from "../components/WorkspacePanel";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { WorkspaceChangesView } from "../lib/types";
|
||||
import type { DirEntry, WorkspaceChangesView } from "../lib/types";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -101,6 +101,47 @@ async function renderWorkspace(changes: WorkspaceChangesView) {
|
||||
return { dom, root };
|
||||
}
|
||||
|
||||
async function renderFilesWorkspace(methods: Partial<AppBindings>, props: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) {
|
||||
const dom = installDom();
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
ListDir: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
WorkspaceGitHistory: async () => [],
|
||||
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
|
||||
...methods,
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
let currentProps: Parameters<typeof WorkspacePanel>[0] = {
|
||||
open: true,
|
||||
tabId: "tab-a",
|
||||
cwd: "/repo",
|
||||
maximized: false,
|
||||
initialViewMode: "files",
|
||||
onClose: () => {},
|
||||
onToggleMaximized: () => {},
|
||||
...props,
|
||||
};
|
||||
const rerender = async (nextProps: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) => {
|
||||
currentProps = { ...currentProps, ...nextProps };
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<WorkspacePanel {...currentProps} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
};
|
||||
await rerender();
|
||||
return { dom, root, rerender };
|
||||
}
|
||||
|
||||
console.log("\nworkspace changes git errors");
|
||||
|
||||
{
|
||||
@@ -151,5 +192,28 @@ console.log("\nworkspace changes git errors");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const calls: string[] = [];
|
||||
const listDir = async (dir: string): Promise<DirEntry[]> => {
|
||||
calls.push(dir);
|
||||
return [];
|
||||
};
|
||||
const { dom, root, rerender } = await renderFilesWorkspace(
|
||||
{ ListDir: listDir },
|
||||
{ fileListRequest: { id: 1, paths: ["src/app.ts"] } },
|
||||
);
|
||||
|
||||
await waitFor("initial referenced file dirs", () => calls.filter((dir) => dir === "src/").length === 1);
|
||||
await rerender({ fileListRequest: { id: 2, paths: ["src/app.ts"] } });
|
||||
await waitFor("referenced file dirs revalidated", () => calls.filter((dir) => dir === "src/").length === 2);
|
||||
|
||||
ok(calls.filter((dir) => dir === "src/").length === 2, "workspace file tree revalidates cached directories for repeated file-list requests");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -70,6 +70,9 @@ eq(
|
||||
);
|
||||
eq(finalDeclaration(".workspace-preview__body--code .workspace-note", "flex"), "0 0 auto", "code truncation note keeps its own row");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code-block", "display"), "flex", "code block fills the preview viewport");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "display"), "flex", "code wrapper keeps the preview viewport height");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "flex"), "1 1 auto", "code wrapper participates in the preview flex height");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code-block__wrap", "min-height"), "0", "code wrapper can shrink around the scrollable code viewport");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code", "overflow"), "auto", "code viewport owns horizontal and vertical scrolling");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code", "min-height"), "0", "code viewport can shrink inside the preview pane");
|
||||
eq(finalDeclaration(".workspace-preview__body--code .code", "margin"), "0", "code viewport scrollbar sits at the visible pane bottom");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Minus, PanelLeft, PanelRight, Search, Square, X } from "lucide-react";
|
||||
import { PanelLeft, PanelRight, Search } from "lucide-react";
|
||||
import { TabBar } from "./TabBar";
|
||||
import type { TabMeta } from "../lib/types";
|
||||
import { useT } from "../lib/i18n";
|
||||
@@ -58,14 +58,13 @@ export function AppChrome({
|
||||
}: AppChromeProps) {
|
||||
const t = useT();
|
||||
const darwinChrome = platform === "darwin";
|
||||
const showWindowsPreviewControls = browserPreviewChrome && platform === "windows";
|
||||
const titlebarDragRail = darwinChrome || platform === "windows";
|
||||
const chromeClassName = [
|
||||
"app-chrome",
|
||||
"app-chrome--tabs",
|
||||
darwinChrome ? "app-chrome--darwin-tabs" : "app-chrome--native-tabs",
|
||||
workbenchChrome ? "app-chrome--workbench" : "",
|
||||
!darwinChrome ? "app-chrome--identityless" : "",
|
||||
showWindowsPreviewControls ? "app-chrome--preview-window-controls" : "",
|
||||
`app-chrome--platform-${platform}`,
|
||||
].filter(Boolean).join(" ");
|
||||
const tabBar = (
|
||||
@@ -92,7 +91,7 @@ export function AppChrome({
|
||||
<span />
|
||||
</div>
|
||||
)}
|
||||
{darwinChrome && <span className="app-chrome__drag-rail" aria-hidden="true" />}
|
||||
{titlebarDragRail && <span className="app-chrome__drag-rail" aria-hidden="true" />}
|
||||
<button
|
||||
className={[
|
||||
"app-chrome__panel-toggle",
|
||||
@@ -194,19 +193,6 @@ export function AppChrome({
|
||||
<PanelRight size={16} />
|
||||
</button>
|
||||
)}
|
||||
{showWindowsPreviewControls && (
|
||||
<div className="app-chrome__window-controls app-chrome__window-controls--windows" aria-hidden="true">
|
||||
<span className="app-chrome__window-control app-chrome__window-control--minimize">
|
||||
<Minus size={12} strokeWidth={1.9} />
|
||||
</span>
|
||||
<span className="app-chrome__window-control app-chrome__window-control--maximize">
|
||||
<Square size={10} strokeWidth={1.8} />
|
||||
</span>
|
||||
<span className="app-chrome__window-control app-chrome__window-control--close">
|
||||
<X size={12} strokeWidth={1.9} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import gsap from "gsap";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { useT, type Translator } from "../lib/i18n";
|
||||
import type { ComposerInsertRequest, DirEntry, WireApproval } from "../lib/types";
|
||||
import { PromptAction, PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf";
|
||||
import { playAttentionChime } from "../lib/sound";
|
||||
@@ -27,6 +27,92 @@ function animateShelfExit(
|
||||
options.onComplete();
|
||||
}
|
||||
|
||||
function requiresFreshHumanApproval(tool: string): boolean {
|
||||
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode" || tool === "sandbox_escape";
|
||||
}
|
||||
|
||||
function approvalToolLabel(tool: string, t: Translator): string {
|
||||
switch (tool) {
|
||||
case "bash":
|
||||
return t("approval.toolLabelBash");
|
||||
case "edit_file":
|
||||
return t("approval.toolLabelEditFile");
|
||||
case "write_file":
|
||||
return t("approval.toolLabelWriteFile");
|
||||
case "multi_edit":
|
||||
return t("approval.toolLabelMultiEdit");
|
||||
case "move_file":
|
||||
return t("approval.toolLabelMoveFile");
|
||||
case "web_fetch":
|
||||
return t("approval.toolLabelWebFetch");
|
||||
case "run_skill":
|
||||
return t("approval.toolLabelRunSkill");
|
||||
case "remember":
|
||||
return t("approval.toolLabelRemember");
|
||||
case "forget":
|
||||
return t("approval.toolLabelForget");
|
||||
case "sandbox_escape":
|
||||
return t("approval.toolLabelSandboxEscape");
|
||||
case "plan_mode_read_only_command":
|
||||
return t("approval.toolLabelPlanModeReadOnly");
|
||||
default:
|
||||
return tool;
|
||||
}
|
||||
}
|
||||
|
||||
const sandboxEscapeEnglishSubjectFallback = "run shell command unconfined once";
|
||||
const sandboxEscapeEnglishSubjectPrefix = "run unconfined once: ";
|
||||
const planModeMcpEnglishSubject = /^MCP (.+) as read-only for planning and research$/;
|
||||
const planModeBashEnglishSubject = /^Trust (.+) as a read-only command prefix while planning\r?\nCommand: ([\s\S]+)$/;
|
||||
|
||||
function localizeApprovalSubject(tool: string, subject: string, t: Translator): string {
|
||||
const trimmed = subject.trim();
|
||||
if (tool === "sandbox_escape") {
|
||||
if (!trimmed || trimmed === sandboxEscapeEnglishSubjectFallback) return t("approval.sandboxEscapeSubjectFallback");
|
||||
if (trimmed.startsWith(sandboxEscapeEnglishSubjectPrefix)) {
|
||||
return `${t("approval.sandboxEscapeSubjectPrefix")}${trimmed.slice(sandboxEscapeEnglishSubjectPrefix.length)}`;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
if (tool === "remember") {
|
||||
return trimmed
|
||||
.replace(/^Save\/update memory/, t("approval.memorySaveUpdate"))
|
||||
.replace(/\bbody: /g, `${t("approval.memoryBodyLabel")}: `);
|
||||
}
|
||||
if (tool === "forget" && trimmed.startsWith("Archive memory ")) {
|
||||
return `${t("approval.memoryArchivePrefix")}${trimmed.slice("Archive memory ".length)}`;
|
||||
}
|
||||
const mcpTrust = trimmed.match(planModeMcpEnglishSubject);
|
||||
if (mcpTrust) {
|
||||
return t("approval.planModeMcpTrustSubject", { target: mcpTrust[1] ?? "" });
|
||||
}
|
||||
const bashTrust = trimmed.match(planModeBashEnglishSubject);
|
||||
if (bashTrust) {
|
||||
return t("approval.planModeBashTrustSubject", { prefix: bashTrust[1] ?? "", command: bashTrust[2] ?? "" });
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function localizeApprovalReason(tool: string, reason: string | undefined, t: Translator): string {
|
||||
const trimmed = reason?.trim() ?? "";
|
||||
if (tool !== "sandbox_escape") return trimmed;
|
||||
if (trimmed.includes("could not wrap this command")) return t("approval.sandboxEscapeWrapReason");
|
||||
if (trimmed.includes("failed while starting this command") || trimmed.includes("Run this command unconfined once?")) {
|
||||
return t("approval.sandboxEscapeRuntimeReason");
|
||||
}
|
||||
return trimmed || t("approval.sandboxEscapeRuntimeReason");
|
||||
}
|
||||
|
||||
function localizePlanModeApprovalReason(tool: string, reason: string, t: Translator): string {
|
||||
if (tool === "plan_mode_read_only_command" && reason.includes("built-in read-only set")) {
|
||||
return t("approval.planModeBashTrustReason");
|
||||
}
|
||||
if (reason.includes("external read-only hints need your confirmation")) {
|
||||
return t("approval.planModeMcpTrustReason");
|
||||
}
|
||||
return reason;
|
||||
}
|
||||
|
||||
export function ApprovalModal({
|
||||
approval,
|
||||
onAnswer,
|
||||
@@ -48,8 +134,18 @@ export function ApprovalModal({
|
||||
}) {
|
||||
const t = useT();
|
||||
const isPlanApproval = approval.tool === "exit_plan_mode";
|
||||
const toolLabel = approvalToolLabel(approval.tool, t);
|
||||
const isFreshHumanApproval = requiresFreshHumanApproval(approval.tool);
|
||||
const hasFreshSessionGrant = approval.tool === "sandbox_escape";
|
||||
const subject = localizeApprovalSubject(approval.tool, approval.subject, t);
|
||||
const reason = localizePlanModeApprovalReason(approval.tool, localizeApprovalReason(approval.tool, approval.reason, t), t);
|
||||
const subjectSummary = subject.split(/\r?\n/).find((line) => line.trim())?.trim() ?? "";
|
||||
const toolMeta = reason || subjectSummary || approval.tool;
|
||||
const hasToolDetails = Boolean(reason || subject);
|
||||
const showToolDetailsByDefault = !isPlanApproval && hasToolDetails;
|
||||
const [revisionOpen, setRevisionOpen] = useState(false);
|
||||
const [revisionText, setRevisionText] = useState("");
|
||||
const [detailsOpen, setDetailsOpen] = useState(() => showToolDetailsByDefault);
|
||||
const [selectedIndex, setSelectedIndex] = useState(() => (isPlanApproval ? 1 : 0));
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const shelfRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -59,7 +155,6 @@ export function ApprovalModal({
|
||||
// the new one slides in. GSAP fromTo on the shelf wrapper avoids the
|
||||
// jarring pop when the API cycles through 4+ pending approvals.
|
||||
const closingRef = useRef(false);
|
||||
const subject = approval.subject.trim();
|
||||
const fileMenu = useFileReferenceMenu(revisionText, cwd);
|
||||
|
||||
const answerWithExit = (fn: () => void) => {
|
||||
@@ -88,21 +183,32 @@ export function ApprovalModal({
|
||||
|
||||
const chooseToolAction = (key: string) => {
|
||||
if (key === "1") answerWithExit(() => onAnswer(true, false, false));
|
||||
else if (key === "2") answerWithExit(() => onAnswer(true, true, false));
|
||||
else if (key === "3") answerWithExit(() => onAnswer(true, true, true));
|
||||
else if (key === "4") answerWithExit(() => onAnswer(false, false, false));
|
||||
else if (hasFreshSessionGrant && key === "2") answerWithExit(() => onAnswer(true, true, false));
|
||||
else if (hasFreshSessionGrant && key === "3") answerWithExit(() => onAnswer(false, false, false));
|
||||
else if (isFreshHumanApproval && key === "2") answerWithExit(() => onAnswer(false, false, false));
|
||||
else if (isFreshHumanApproval && key === "4") answerWithExit(() => onAnswer(false, false, false));
|
||||
else if (!isFreshHumanApproval && key === "2") answerWithExit(() => onAnswer(true, true, false));
|
||||
else if (!isFreshHumanApproval && key === "3") answerWithExit(() => onAnswer(true, true, true));
|
||||
else if (!isFreshHumanApproval && key === "4") answerWithExit(() => onAnswer(false, false, false));
|
||||
else if (key === "Escape") answerWithExit(onStop);
|
||||
};
|
||||
|
||||
const selectedToolActionKey = (index: number) => {
|
||||
if (!isFreshHumanApproval) return String(index + 1);
|
||||
if (hasFreshSessionGrant) return index === 0 ? "1" : index === 1 ? "2" : "3";
|
||||
return index === 0 ? "1" : "2";
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
cardRef.current?.focus();
|
||||
setRevisionOpen(false);
|
||||
setRevisionText("");
|
||||
setDetailsOpen(showToolDetailsByDefault);
|
||||
setSelectedIndex(isPlanApproval ? 1 : 0);
|
||||
playAttentionChime();
|
||||
}, [approval.id, isPlanApproval]);
|
||||
}, [approval.id, isPlanApproval, showToolDetailsByDefault]);
|
||||
|
||||
const actionCount = isPlanApproval ? 3 : 4;
|
||||
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? (hasFreshSessionGrant ? 3 : 2) : 4;
|
||||
const selectedIndexRef = useRef(selectedIndex);
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
|
||||
@@ -123,7 +229,7 @@ export function ApprovalModal({
|
||||
event.preventDefault();
|
||||
const key = String(selectedIndexRef.current + 1);
|
||||
if (isPlanApproval) choosePlanAction(key);
|
||||
else chooseToolAction(key);
|
||||
else chooseToolAction(selectedToolActionKey(selectedIndexRef.current));
|
||||
} else if (event.key === "1" || event.key === "2" || event.key === "3" || event.key === "4" || event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
if (isPlanApproval) choosePlanAction(event.key);
|
||||
@@ -132,7 +238,7 @@ export function ApprovalModal({
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [isPlanApproval, onAnswer, onExitPlan, onStop, actionCount]);
|
||||
}, [isPlanApproval, isFreshHumanApproval, hasFreshSessionGrant, onAnswer, onExitPlan, onStop, actionCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (revisionOpen) {
|
||||
@@ -219,6 +325,7 @@ export function ApprovalModal({
|
||||
return (
|
||||
<div ref={shelfRef}>
|
||||
<PromptShelf
|
||||
className="prompt-shelf--compact prompt-shelf--plan-approval"
|
||||
barRef={cardRef}
|
||||
titleId="plan-approval-title"
|
||||
title={t("approval.planReady")}
|
||||
@@ -280,27 +387,53 @@ export function ApprovalModal({
|
||||
return (
|
||||
<div ref={shelfRef}>
|
||||
<PromptShelf
|
||||
className="prompt-shelf--compact prompt-shelf--tool-approval"
|
||||
barRef={cardRef}
|
||||
titleId="tool-approval-title"
|
||||
title={t("approval.toolPending")}
|
||||
badges={<PromptBadge>{approval.tool}</PromptBadge>}
|
||||
badges={<PromptBadge>{toolLabel}</PromptBadge>}
|
||||
meta={toolMeta}
|
||||
headerActions={
|
||||
<PromptHeaderAction onClick={() => answerWithExit(onStop)} ariaLabel={t("composer.stopShort")}>
|
||||
Esc
|
||||
</PromptHeaderAction>
|
||||
<>
|
||||
{hasToolDetails && (
|
||||
<PromptHeaderAction onClick={() => setDetailsOpen((open) => !open)}>
|
||||
{t(detailsOpen ? "approval.hideDetails" : "approval.details")}
|
||||
</PromptHeaderAction>
|
||||
)}
|
||||
<PromptHeaderAction onClick={() => answerWithExit(onStop)} ariaLabel={t("composer.stopShort")}>
|
||||
Esc
|
||||
</PromptHeaderAction>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<PromptAction keyLabel="1" label={t("approval.allowOnce")} onClick={() => answerWithExit(() => onAnswer(true, false, false))} selected={selectedIndex === 0} />
|
||||
<PromptAction keyLabel="2" label={t("approval.allowRuleSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
|
||||
<PromptAction keyLabel="3" label={t("approval.allowRulePersistent")} onClick={() => answerWithExit(() => onAnswer(true, true, true))} selected={selectedIndex === 2} />
|
||||
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 3} />
|
||||
{isFreshHumanApproval ? (
|
||||
hasFreshSessionGrant ? (
|
||||
<>
|
||||
<PromptAction keyLabel="2" label={t("approval.allowSandboxEscapeSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
|
||||
<PromptAction keyLabel="3" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 2} />
|
||||
</>
|
||||
) : (
|
||||
<PromptAction keyLabel="2" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 1} />
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<PromptAction keyLabel="2" label={t("approval.allowRuleSession")} onClick={() => answerWithExit(() => onAnswer(true, true, false))} selected={selectedIndex === 1} />
|
||||
<PromptAction keyLabel="3" label={t("approval.allowRulePersistent")} onClick={() => answerWithExit(() => onAnswer(true, true, true))} selected={selectedIndex === 2} />
|
||||
<PromptAction keyLabel="4" label={t("approval.deny")} onClick={() => answerWithExit(() => onAnswer(false, false, false))} selected={selectedIndex === 3} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{approval.reason && <div className="approval-reason">{approval.reason}</div>}
|
||||
{subject && (
|
||||
<pre className="approval-subject">{subject}</pre>
|
||||
{detailsOpen && (
|
||||
<div className="approval-details">
|
||||
{reason && <div className="approval-reason">{reason}</div>}
|
||||
{subject && (
|
||||
<pre className="approval-subject">{subject}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</PromptShelf>
|
||||
</div>
|
||||
|
||||
@@ -22,8 +22,10 @@ export function AskCard({
|
||||
// Per-question state: selected option labels, and an optional typed answer.
|
||||
const [sel, setSel] = useState<Record<string, string[]>>({});
|
||||
const [custom, setCustom] = useState<Record<string, string>>({});
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
const shelfRef = useRef<HTMLDivElement | null>(null);
|
||||
const customInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const advanceTimer = useRef<number | null>(null);
|
||||
|
||||
const questions = ask.questions;
|
||||
@@ -36,6 +38,7 @@ export function AskCard({
|
||||
shelfRef.current?.focus();
|
||||
setSel({});
|
||||
setCustom({});
|
||||
setCustomOpen(false);
|
||||
setActive(0);
|
||||
if (advanceTimer.current != null) window.clearTimeout(advanceTimer.current);
|
||||
playAttentionChime();
|
||||
@@ -66,7 +69,15 @@ export function AskCard({
|
||||
(sel[question.id]?.length ?? 0) > 0 || (custom[question.id]?.trim() ?? "") !== "";
|
||||
|
||||
const currentAnswered = q ? answered(q) : false;
|
||||
const showSubmitAction = q ? q.multi || Boolean(custom[q.id]?.trim()) : false;
|
||||
const showSubmitAction = q ? q.multi || customOpen || Boolean(custom[q.id]?.trim()) : false;
|
||||
|
||||
useEffect(() => {
|
||||
setCustomOpen(false);
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
if (customOpen) customInputRef.current?.focus();
|
||||
}, [customOpen]);
|
||||
|
||||
const finishOrAdvance = (nextSel = sel, nextCustom = custom) => {
|
||||
if (advanceTimer.current != null) {
|
||||
@@ -89,6 +100,7 @@ export function AskCard({
|
||||
|
||||
setCustom(nextCustom);
|
||||
setSel(nextSel);
|
||||
setCustomOpen(false);
|
||||
|
||||
if (!question.multi) {
|
||||
if (advanceTimer.current != null) window.clearTimeout(advanceTimer.current);
|
||||
@@ -148,6 +160,7 @@ export function AskCard({
|
||||
|
||||
return (
|
||||
<PromptShelf
|
||||
className="prompt-shelf--compact prompt-shelf--ask"
|
||||
barRef={shelfRef}
|
||||
titleId="ask-shelf-title"
|
||||
title={t("ask.title")}
|
||||
@@ -165,6 +178,9 @@ export function AskCard({
|
||||
headerActions={
|
||||
<>
|
||||
<PromptHeaderAction onClick={onDismiss}>{t("ask.justChat")}</PromptHeaderAction>
|
||||
{!customOpen && (
|
||||
<PromptHeaderAction onClick={() => setCustomOpen(true)}>{t("ask.customAnswer")}</PromptHeaderAction>
|
||||
)}
|
||||
<PromptHeaderAction onClick={onStop} ariaLabel={t("composer.stopShort")}>Esc</PromptHeaderAction>
|
||||
</>
|
||||
}
|
||||
@@ -213,8 +229,10 @@ export function AskCard({
|
||||
)
|
||||
}
|
||||
>
|
||||
{customOpen && (
|
||||
<div className="ask-shelf__custom-row">
|
||||
<input
|
||||
ref={customInputRef}
|
||||
className="ask-shelf__custom"
|
||||
placeholder={t("ask.customPlaceholder")}
|
||||
value={custom[q.id] ?? ""}
|
||||
@@ -225,6 +243,7 @@ export function AskCard({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PromptShelf>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { asArray } from "../lib/array";
|
||||
import { app, openExternal } from "../lib/bridge";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { mcpServerLifecycleActions, mcpServerRetryableFromAvailableList } from "../lib/mcpServerLifecycle";
|
||||
import type { CapabilitiesView, MCPServerInput, ServerView, SkillRootSkillView, SkillRootView, SkillsSettingsView, SkillView, TabMeta } from "../lib/types";
|
||||
import type { CapabilitiesView, MCPServerInput, PluginHookView, PluginInstallOptions, PluginMCPServerView, PluginSkillView, PluginView, ServerView, SkillRootSkillView, SkillRootView, SkillsSettingsView, SkillView, TabMeta } from "../lib/types";
|
||||
import { InlineConfirmButton } from "./InlineConfirmButton";
|
||||
import { ResizableDrawer } from "./ResizableDrawer";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
@@ -20,6 +20,7 @@ type SettingsSnapshot<T> = { key: string; value: T };
|
||||
|
||||
let mcpSettingsSnapshot: SettingsSnapshot<ServerView[]> | null = null;
|
||||
let skillsSettingsSnapshot: SettingsSnapshot<SkillsSettingsView> | null = null;
|
||||
let pluginsSettingsSnapshot: SettingsSnapshot<PluginView[]> | null = null;
|
||||
|
||||
function settingsSnapshotKey(meta: Awaited<ReturnType<typeof app.Meta>> | null | undefined, tabs: TabMeta[] | null | undefined): string {
|
||||
const active = tabs?.find((tab) => tab.active);
|
||||
@@ -50,7 +51,7 @@ export function CapabilitiesPanel({
|
||||
const [expandedServerTools, setExpandedServerTools] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [] }))));
|
||||
setView(normalizeCapabilitiesView(await app.Capabilities().catch(() => ({ servers: [], skills: [], skillRoots: [], plugins: [] }))));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
@@ -314,6 +315,7 @@ export function CapabilitiesPanel({
|
||||
function normalizeCapabilitiesView(view: CapabilitiesView | null | undefined): CapabilitiesView {
|
||||
return {
|
||||
servers: normalizeServerViews(view?.servers),
|
||||
plugins: asArray(view?.plugins),
|
||||
...normalizeSkillsSettingsView(view),
|
||||
};
|
||||
}
|
||||
@@ -1590,6 +1592,619 @@ function AddServerForm({
|
||||
);
|
||||
}
|
||||
|
||||
type PluginInstallPlanAction = {
|
||||
action?: string;
|
||||
kind?: string;
|
||||
name?: string;
|
||||
source?: string;
|
||||
status?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type PluginInstallPlanView = {
|
||||
raw: string;
|
||||
ok?: boolean;
|
||||
status?: string;
|
||||
name?: string;
|
||||
actions: PluginInstallPlanAction[];
|
||||
warnings: string[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type PluginInstallMode = "local" | "git";
|
||||
|
||||
// PluginsSettingsPage is the desktop plugin package manager embedded inside
|
||||
// Settings. It mirrors the MCP/Skills density: install planning on top, package
|
||||
// rows below, and diagnostics/details only when a row is expanded.
|
||||
export function PluginsSettingsPage() {
|
||||
const t = useT();
|
||||
const [snapshotKey, setSnapshotKey] = useState("");
|
||||
const [plugins, setPlugins] = useState<PluginView[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [installMode, setInstallMode] = useState<PluginInstallMode>("local");
|
||||
const [localSource, setLocalSource] = useState("");
|
||||
const [gitSource, setGitSource] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [link, setLink] = useState(false);
|
||||
const [replace, setReplace] = useState(false);
|
||||
const [plan, setPlan] = useState<PluginInstallPlanView | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const [diagnostics, setDiagnostics] = useState<Record<string, PluginView>>({});
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const [meta, tabs] = await Promise.all([
|
||||
app.Meta().catch(() => null),
|
||||
app.ListTabs().catch(() => []),
|
||||
]);
|
||||
const key = settingsSnapshotKey(meta, tabs);
|
||||
setSnapshotKey(key);
|
||||
const cached = key ? pluginsSettingsSnapshot : null;
|
||||
if (cached?.key === key) {
|
||||
setPlugins(cached.value);
|
||||
} else {
|
||||
setPlugins(null);
|
||||
}
|
||||
const next = normalizePluginViews(await app.Plugins().catch(() => []));
|
||||
pluginsSettingsSnapshot = { key, value: next };
|
||||
setPlugins(next);
|
||||
}, []);
|
||||
useEffect(() => { void reload(); }, [reload]);
|
||||
|
||||
const run = async (fn: () => Promise<unknown>, reloadAfter = true) => {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await fn();
|
||||
if (typeof result === "string" && result.trim()) {
|
||||
const parsed = parsePluginInstallPlan(result);
|
||||
setNotice(pluginPlanNotice(parsed, t));
|
||||
}
|
||||
if (reloadAfter) await reload();
|
||||
return true;
|
||||
} catch (e) {
|
||||
setErr(String((e as Error)?.message ?? e));
|
||||
if (reloadAfter) await reload();
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sourceValue = (installMode === "local" ? localSource : gitSource).trim();
|
||||
const installOptions = (): PluginInstallOptions => ({
|
||||
dryRun: false,
|
||||
link: installMode === "local" ? link : false,
|
||||
replace,
|
||||
name: installMode === "git" ? name.trim() || undefined : undefined,
|
||||
});
|
||||
const actionBusy = busy || !snapshotKey || !plugins;
|
||||
const canPlan = sourceValue.length > 0 && !actionBusy;
|
||||
const summary = plugins ? pluginListSummary(plugins, t) : "";
|
||||
const togglePlugin = useCallback((pluginName: string) => {
|
||||
setExpanded((prev) => { const next = new Set(prev); if (next.has(pluginName)) next.delete(pluginName); else next.add(pluginName); return next; });
|
||||
}, []);
|
||||
const setMode = (mode: PluginInstallMode) => {
|
||||
setInstallMode(mode);
|
||||
setPlan(null);
|
||||
};
|
||||
const previewInstall = () => {
|
||||
if (!sourceValue) return;
|
||||
void run(async () => {
|
||||
const raw = await app.PlanPluginInstall(sourceValue, { ...installOptions(), dryRun: true });
|
||||
setPlan(parsePluginInstallPlan(raw));
|
||||
}, false);
|
||||
};
|
||||
const install = () => {
|
||||
if (!sourceValue) return;
|
||||
void run(async () => {
|
||||
const raw = await app.InstallPlugin(sourceValue, installOptions());
|
||||
setPlan(parsePluginInstallPlan(raw));
|
||||
return raw;
|
||||
});
|
||||
};
|
||||
const runDoctor = (pluginName: string) => {
|
||||
void run(async () => {
|
||||
const view = normalizePluginView(await app.PluginDoctor(pluginName));
|
||||
setDiagnostics((prev) => ({ ...prev, [pluginName]: view }));
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(pluginName);
|
||||
return next;
|
||||
});
|
||||
}, false);
|
||||
};
|
||||
const updateLocalSource = (value: string) => {
|
||||
setLocalSource(value);
|
||||
setPlan(null);
|
||||
};
|
||||
const updateGitSource = (value: string) => {
|
||||
setGitSource(value);
|
||||
setPlan(null);
|
||||
};
|
||||
const pickPluginFolder = () => {
|
||||
void run(async () => {
|
||||
const path = await app.PickPluginFolder();
|
||||
if (path) {
|
||||
setInstallMode("local");
|
||||
updateLocalSource(path);
|
||||
}
|
||||
}, false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mem-section">
|
||||
{err && <div className="banner banner--error">{err}</div>}
|
||||
{notice && !err && <div className="banner banner--success">{notice}</div>}
|
||||
<div className="cap-plugin-installer">
|
||||
<div className="cap-plugin-installer__head">
|
||||
<div className="cap-plugin-installer__copy">
|
||||
<div className="cap-plugin-installer__title">{t("caps.pluginInstallTitle")}</div>
|
||||
<div className="cap-plugin-installer__hint">{t("caps.pluginInstallHint")}</div>
|
||||
</div>
|
||||
<div className="cap-tabs cap-plugin-installer__mode" role="group" aria-label={t("caps.pluginInstallMethod")}>
|
||||
<button
|
||||
className={`cap-tab${installMode === "local" ? " cap-tab--active" : ""}`}
|
||||
type="button"
|
||||
aria-pressed={installMode === "local"}
|
||||
onClick={() => setMode("local")}
|
||||
>
|
||||
{t("caps.pluginInstallLocal")}
|
||||
</button>
|
||||
<button
|
||||
className={`cap-tab${installMode === "git" ? " cap-tab--active" : ""}`}
|
||||
type="button"
|
||||
aria-pressed={installMode === "git"}
|
||||
onClick={() => setMode("git")}
|
||||
>
|
||||
{t("caps.pluginInstallGit")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cap-plugin-form-grid">
|
||||
{installMode === "local" ? (
|
||||
<div className="cap-plugin-fields cap-plugin-fields--local">
|
||||
<div className="cap-plugin-folder-field">
|
||||
<button className="btn btn--small" disabled={actionBusy} type="button" onClick={pickPluginFolder}>
|
||||
{t("caps.pluginChooseLocalFolder")}
|
||||
</button>
|
||||
<div
|
||||
className={`cap-plugin-path${localSource ? "" : " cap-plugin-path--empty"}`}
|
||||
aria-label={t("caps.pluginLocalFolder")}
|
||||
>
|
||||
{localSource || t("caps.pluginNoLocalFolder")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cap-plugin-fields cap-plugin-fields--git">
|
||||
<input
|
||||
className="mem-input"
|
||||
aria-label={t("caps.pluginGitSource")}
|
||||
placeholder={t("caps.pluginSourcePlaceholder")}
|
||||
value={gitSource}
|
||||
onInput={(e) => updateGitSource(e.currentTarget.value)}
|
||||
onChange={(e) => updateGitSource(e.target.value)}
|
||||
/>
|
||||
<div className="cap-plugin-field">
|
||||
<input
|
||||
className="mem-input"
|
||||
aria-label={t("caps.pluginInstallName")}
|
||||
placeholder={t("caps.pluginInstallNamePlaceholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="cap-plugin-installer__options">
|
||||
<div className="cap-plugin-option-block">
|
||||
<label className="cap-plugin-option">
|
||||
<input type="checkbox" checked={replace} disabled={actionBusy} onChange={(e) => setReplace(e.target.checked)} />
|
||||
<span>{t("caps.pluginReplace")}</span>
|
||||
</label>
|
||||
<div className="cap-plugin-option-hint">{t("caps.pluginReplaceHint")}</div>
|
||||
</div>
|
||||
{installMode === "local" && (
|
||||
<div className="cap-plugin-option-block">
|
||||
<label className="cap-plugin-option">
|
||||
<input type="checkbox" checked={link} disabled={actionBusy} onChange={(e) => setLink(e.target.checked)} />
|
||||
<span>{t("caps.pluginLink")}</span>
|
||||
</label>
|
||||
<div className="cap-plugin-option-hint">{t("caps.pluginLinkHint")}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="cap-plugin-installer__actions">
|
||||
<button className="btn btn--small" type="button" disabled={!canPlan} onClick={previewInstall}>
|
||||
{t("caps.pluginPreview")}
|
||||
</button>
|
||||
<button className="btn btn--primary btn--small" type="button" disabled={!canPlan} onClick={install}>
|
||||
{t("caps.pluginInstall")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{plan && <PluginPlanPreview plan={plan} />}
|
||||
<div className="cap-server-section cap-plugin-section">
|
||||
<div className="cap-server-section__head">
|
||||
<div className="cap-server-section__copy">
|
||||
<div className="cap-server-section__title">{t("caps.installedPlugins")}</div>
|
||||
{plugins && plugins.length > 0 && <div className="drawer__summary">{summary}</div>}
|
||||
</div>
|
||||
<button className="btn btn--small" disabled={actionBusy} type="button" onClick={() => void reload()}>
|
||||
{t("caps.pluginRefresh")}
|
||||
</button>
|
||||
</div>
|
||||
{!plugins ? (
|
||||
<div className="mem-empty">{t("caps.loading")}</div>
|
||||
) : plugins.length === 0 ? (
|
||||
<div className="mem-empty mem-empty--cta">
|
||||
<strong>{t("caps.noPluginsTitle")}</strong>
|
||||
<span>{t("caps.noPluginsHint")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cap-server-group">
|
||||
{plugins.map((plugin) => (
|
||||
<PluginRow
|
||||
key={plugin.name}
|
||||
plugin={plugin}
|
||||
diagnostic={diagnostics[plugin.name]}
|
||||
busy={actionBusy}
|
||||
expanded={expanded.has(plugin.name)}
|
||||
onToggleDetails={() => togglePlugin(plugin.name)}
|
||||
onToggleEnabled={(enabled) => void run(() => app.SetPluginEnabled(plugin.name, enabled))}
|
||||
onUpdate={() => void run(() => app.UpdatePlugin(plugin.name))}
|
||||
onDoctor={() => runDoctor(plugin.name)}
|
||||
onRemove={() => void run(() => app.RemovePlugin(plugin.name))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginPlanPreview({ plan }: { plan: PluginInstallPlanView }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className={`cap-plugin-plan${plan.error ? " cap-plugin-plan--error" : ""}`}>
|
||||
<div className="cap-plugin-plan__head">
|
||||
<div className="cap-plugin-plan__title">{plan.error ? t("caps.pluginPlanError") : t("caps.pluginPlanReady")}</div>
|
||||
{plan.status && <span className="cap-source-badge">{plan.status}</span>}
|
||||
</div>
|
||||
{plan.name && <div className="cap-plugin-plan__meta">{plan.name}</div>}
|
||||
{plan.error && <div className="cap-plugin-plan__warning">{plan.error}</div>}
|
||||
{plan.warnings.map((warning, idx) => (
|
||||
<div className="cap-plugin-plan__warning" key={`${warning}-${idx}`}>{warning}</div>
|
||||
))}
|
||||
{plan.actions.length > 0 ? (
|
||||
<div className="cap-plugin-actions">
|
||||
{plan.actions.map((action, idx) => (
|
||||
<div className="cap-plugin-action" key={`${action.action || action.kind || "action"}-${idx}`}>
|
||||
<span className="cap-plugin-action__name">{pluginPlanActionLabel(action, t)}</span>
|
||||
{action.status && <span className="cap-source-badge">{action.status}</span>}
|
||||
{action.source && <span className="cap-plugin-action__source">{action.source}</span>}
|
||||
{action.message && <span className="cap-plugin-action__source">{action.message}</span>}
|
||||
{action.error && <span className="cap-plugin-plan__warning">{action.error}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="cap-plugin-plan__raw">{plan.raw}</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginRow({
|
||||
plugin,
|
||||
diagnostic,
|
||||
busy,
|
||||
expanded,
|
||||
onToggleDetails,
|
||||
onToggleEnabled,
|
||||
onUpdate,
|
||||
onDoctor,
|
||||
onRemove,
|
||||
}: {
|
||||
plugin: PluginView;
|
||||
diagnostic?: PluginView;
|
||||
busy: boolean;
|
||||
expanded: boolean;
|
||||
onToggleDetails: () => void;
|
||||
onToggleEnabled: (enabled: boolean) => void;
|
||||
onUpdate: () => void;
|
||||
onDoctor: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const status = plugin.error ? "failed" : plugin.enabled ? "connected" : "disabled";
|
||||
const warnings = pluginWarnings(plugin, diagnostic);
|
||||
const sub = plugin.error || pluginCapabilitiesSummary(plugin, t);
|
||||
return (
|
||||
<div className={`cap-server-entry cap-plugin-entry${plugin.enabled ? "" : " cap-server-entry--disabled"}`}>
|
||||
<Tooltip label={plugin.error} disabled={!plugin.error} fill block>
|
||||
<div className={`cap-row${plugin.enabled ? "" : " cap-row--disabled"}`}>
|
||||
<Tooltip label={expanded ? t("caps.collapseDetails") : t("caps.expandDetails")}>
|
||||
<button
|
||||
className="cap-disclosure"
|
||||
aria-expanded={expanded}
|
||||
type="button"
|
||||
onClick={onToggleDetails}
|
||||
>
|
||||
{expanded ? "⌄" : "›"}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<span className={`cap-dot cap-dot--${status}`} />
|
||||
<div className="cap-row__text">
|
||||
<div className="cap-row__head">
|
||||
<span className="cap-row__name">{plugin.name}</span>
|
||||
{plugin.manifestKind && <span className="cap-row__transport">{plugin.manifestKind}</span>}
|
||||
{plugin.version && <span className="cap-source-badge">{plugin.version}</span>}
|
||||
{warnings.length > 0 && <span className="cap-row__update cap-row__update--error">{t("caps.pluginWarnings", { count: warnings.length })}</span>}
|
||||
</div>
|
||||
<div className="cap-row__sub">{sub}</div>
|
||||
</div>
|
||||
<div className="cap-row__actions">
|
||||
<Tooltip label={plugin.enabled ? t("caps.pluginDisable") : t("caps.pluginEnable")}>
|
||||
<label className="cap-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={plugin.enabled}
|
||||
disabled={busy}
|
||||
onChange={(e) => onToggleEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="cap-switch__track" />
|
||||
</label>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{expanded && (
|
||||
<div className="cap-server-details">
|
||||
<div className="cap-detail-grid">
|
||||
<div className="cap-detail">
|
||||
<span className="cap-detail__label">{t("caps.status")}</span>
|
||||
<span className="cap-detail__value">{plugin.enabled ? t("caps.pluginEnabled") : t("caps.pluginDisabled")}</span>
|
||||
</div>
|
||||
{plugin.version && (
|
||||
<div className="cap-detail">
|
||||
<span className="cap-detail__label">{t("caps.pluginVersion")}</span>
|
||||
<span className="cap-detail__value">{plugin.version}</span>
|
||||
</div>
|
||||
)}
|
||||
{plugin.source && (
|
||||
<div className="cap-detail cap-detail--wide">
|
||||
<span className="cap-detail__label">{t("caps.pluginSource")}</span>
|
||||
<span className="cap-detail__code">{plugin.source}</span>
|
||||
</div>
|
||||
)}
|
||||
{plugin.root && (
|
||||
<div className="cap-detail cap-detail--wide">
|
||||
<span className="cap-detail__label">{t("caps.pluginRoot")}</span>
|
||||
<span className="cap-detail__code">{plugin.root}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{plugin.description && <div className="cap-plugin-description">{plugin.description}</div>}
|
||||
<PluginUsageDetails plugin={plugin} />
|
||||
{diagnostic?.error && <div className="cap-source__warning">{diagnostic.error}</div>}
|
||||
{warnings.map((warning, idx) => (
|
||||
<div className="cap-source__warning" key={`${plugin.name}-warning-${idx}`}>{warning}</div>
|
||||
))}
|
||||
<div className="cap-detail-actions">
|
||||
<button className="btn btn--small" disabled={busy} type="button" onClick={onUpdate}>
|
||||
{t("caps.pluginUpdate")}
|
||||
</button>
|
||||
<button className="btn btn--small" disabled={busy} type="button" onClick={onDoctor}>
|
||||
{t("caps.pluginDoctor")}
|
||||
</button>
|
||||
<InlineConfirmButton
|
||||
label={t("caps.pluginRemove")}
|
||||
confirmLabel={t("caps.pluginConfirmRemove")}
|
||||
cancelLabel={t("common.cancel")}
|
||||
disabled={busy}
|
||||
danger
|
||||
onConfirm={onRemove}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginUsageDetails({ plugin }: { plugin: PluginView }) {
|
||||
const t = useT();
|
||||
const skills = asArray(plugin.skillDetails);
|
||||
const hooks = asArray(plugin.hookDetails);
|
||||
const mcps = asArray(plugin.mcpServerDetails);
|
||||
const hasDetails = skills.length > 0 || hooks.length > 0 || mcps.length > 0;
|
||||
return (
|
||||
<div className="cap-plugin-usage">
|
||||
<div className="cap-plugin-usage__title">{t("caps.pluginUsageTitle")}</div>
|
||||
<div className="cap-plugin-usage__hint">
|
||||
{plugin.enabled ? t("caps.pluginUsageEnabledHint") : t("caps.pluginUsageDisabledHint")}
|
||||
</div>
|
||||
{hasDetails ? (
|
||||
<div className="cap-plugin-capabilities">
|
||||
{skills.length > 0 && <PluginSkillList skills={skills} />}
|
||||
{hooks.length > 0 && <PluginHookList hooks={hooks} />}
|
||||
{mcps.length > 0 && <PluginMCPList servers={mcps} />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="cap-plugin-usage__empty">{t("caps.pluginNoCapabilityDetails")}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginSkillList({ skills }: { skills: PluginSkillView[] }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="cap-plugin-capability">
|
||||
<div className="cap-plugin-capability__head">{t("caps.pluginSkillsTitle")}</div>
|
||||
<div className="cap-plugin-capability__hint">{t("caps.pluginSkillsHint")}</div>
|
||||
<div className="cap-plugin-capability__list">
|
||||
{skills.map((skill) => (
|
||||
<div className="cap-plugin-capability__item" key={`${skill.name}-${skill.path || skill.invocation || ""}`}>
|
||||
<div className="cap-plugin-capability__line">
|
||||
<span className="cap-plugin-capability__name">{skill.invocation || `/${skill.name}`}</span>
|
||||
{skill.runAs && <span className="cap-source-badge">{skill.runAs}</span>}
|
||||
</div>
|
||||
<div className="cap-plugin-capability__desc">{skill.description || t("caps.pluginNoDescription")}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginHookList({ hooks }: { hooks: PluginHookView[] }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="cap-plugin-capability">
|
||||
<div className="cap-plugin-capability__head">{t("caps.pluginHooksTitle")}</div>
|
||||
<div className="cap-plugin-capability__hint">{t("caps.pluginHooksHint")}</div>
|
||||
<div className="cap-plugin-capability__list">
|
||||
{hooks.map((hook, idx) => {
|
||||
const target = hook.command || hook.contextFile || t("caps.pluginHookNoTarget");
|
||||
return (
|
||||
<div className="cap-plugin-capability__item" key={`${hook.event}-${hook.match || "*"}-${target}-${idx}`}>
|
||||
<div className="cap-plugin-capability__line">
|
||||
<span className="cap-plugin-capability__name">{hook.event}</span>
|
||||
<span className="cap-source-badge">{hook.match || "*"}</span>
|
||||
</div>
|
||||
<div className="cap-plugin-capability__desc">{hook.description || target}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginMCPList({ servers }: { servers: PluginMCPServerView[] }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="cap-plugin-capability">
|
||||
<div className="cap-plugin-capability__head">{t("caps.pluginMCPTitle")}</div>
|
||||
<div className="cap-plugin-capability__hint">{t("caps.pluginMCPHint")}</div>
|
||||
<div className="cap-plugin-capability__list">
|
||||
{servers.map((server) => (
|
||||
<div className="cap-plugin-capability__item" key={server.name}>
|
||||
<div className="cap-plugin-capability__line">
|
||||
<span className="cap-plugin-capability__name">{server.name}</span>
|
||||
{server.transport && <span className="cap-source-badge">{server.transport}</span>}
|
||||
</div>
|
||||
<div className="cap-plugin-capability__desc">{server.command || server.url || t("caps.pluginMCPNoTarget")}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePluginViews(plugins: PluginView[] | null | undefined): PluginView[] {
|
||||
return sortPluginsForDisplay(asArray(plugins).map(normalizePluginView));
|
||||
}
|
||||
|
||||
function normalizePluginView(plugin: PluginView): PluginView {
|
||||
return {
|
||||
...plugin,
|
||||
name: plugin.name || "plugin",
|
||||
root: plugin.root || "",
|
||||
enabled: Boolean(plugin.enabled),
|
||||
skills: Number.isFinite(plugin.skills) ? plugin.skills : 0,
|
||||
hooks: Number.isFinite(plugin.hooks) ? plugin.hooks : 0,
|
||||
mcpServers: Number.isFinite(plugin.mcpServers) ? plugin.mcpServers : 0,
|
||||
skillDetails: asArray(plugin.skillDetails),
|
||||
hookDetails: asArray(plugin.hookDetails),
|
||||
mcpServerDetails: asArray(plugin.mcpServerDetails),
|
||||
warnings: asArray(plugin.warnings),
|
||||
};
|
||||
}
|
||||
|
||||
function sortPluginsForDisplay(plugins: PluginView[]): PluginView[] {
|
||||
return [...plugins].sort((a, b) => {
|
||||
const priority = pluginDisplayPriority(a) - pluginDisplayPriority(b);
|
||||
if (priority !== 0) return priority;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
|
||||
});
|
||||
}
|
||||
|
||||
function pluginDisplayPriority(plugin: PluginView): number {
|
||||
if (plugin.error) return 0;
|
||||
if (plugin.enabled) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function pluginListSummary(plugins: PluginView[], t: ReturnType<typeof useT>): string {
|
||||
const enabled = plugins.filter((plugin) => plugin.enabled && !plugin.error).length;
|
||||
const issues = plugins.filter((plugin) => Boolean(plugin.error) || asArray(plugin.warnings).length > 0).length;
|
||||
return t("caps.pluginsSummary", { enabled, total: plugins.length, issues });
|
||||
}
|
||||
|
||||
function pluginCapabilitiesSummary(plugin: PluginView, t: ReturnType<typeof useT>): string {
|
||||
if (plugin.skills === 0 && plugin.hooks === 0 && plugin.mcpServers === 0) return t("caps.pluginNoCapabilities");
|
||||
return t("caps.pluginCounts", { skills: plugin.skills, hooks: plugin.hooks, mcps: plugin.mcpServers });
|
||||
}
|
||||
|
||||
function pluginWarnings(plugin: PluginView, diagnostic?: PluginView): string[] {
|
||||
const warnings = [...asArray(plugin.warnings), ...asArray(diagnostic?.warnings)];
|
||||
return Array.from(new Set(warnings.filter((warning) => warning.trim().length > 0)));
|
||||
}
|
||||
|
||||
function parsePluginInstallPlan(raw: string): PluginInstallPlanView {
|
||||
try {
|
||||
const value = JSON.parse(raw) as Record<string, unknown>;
|
||||
const actions = (Array.isArray(value.actions) ? value.actions : []).flatMap((action) => {
|
||||
if (!action || typeof action !== "object") return [];
|
||||
const item = action as Record<string, unknown>;
|
||||
return [{
|
||||
action: stringValue(item.action),
|
||||
kind: stringValue(item.kind),
|
||||
name: stringValue(item.name),
|
||||
source: stringValue(item.source),
|
||||
status: stringValue(item.status),
|
||||
message: stringValue(item.message),
|
||||
error: stringValue(item.error),
|
||||
}];
|
||||
});
|
||||
return {
|
||||
raw,
|
||||
ok: typeof value.ok === "boolean" ? value.ok : undefined,
|
||||
status: stringValue(value.status),
|
||||
name: stringValue(value.name),
|
||||
actions,
|
||||
warnings: (Array.isArray(value.warnings) ? value.warnings : []).flatMap((warning) => typeof warning === "string" ? [warning] : []),
|
||||
error: stringValue(value.error),
|
||||
};
|
||||
} catch {
|
||||
return { raw, actions: [], warnings: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function pluginPlanActionLabel(action: PluginInstallPlanAction, t: ReturnType<typeof useT>): string {
|
||||
const verb = action.action || action.kind || t("caps.pluginAction");
|
||||
return [verb, action.name].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
function pluginPlanNotice(plan: PluginInstallPlanView, t: ReturnType<typeof useT>): string {
|
||||
if (plan.error) return plan.error;
|
||||
if (plan.status === "done" || plan.status === "applied" || plan.status === "complete") return t("caps.pluginPlanInstalled");
|
||||
return plan.status ? t("caps.pluginPlanStatus", { status: plan.status }) : t("caps.pluginPlanComplete");
|
||||
}
|
||||
|
||||
// MCPServersSettingsPage is a self-contained MCP servers management page
|
||||
// embedded inside the settings centre.
|
||||
export function MCPServersSettingsPage() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||
import { ArrowUp, Check, ChevronsUpDown, Eye, FileText, Folder, Gauge, List, MessageSquare, Search, Shield, ShieldAlert, ShieldCheck, SlidersHorizontal, Square, Target, Trash2, X } from "lucide-react";
|
||||
import { ArrowUp, Check, ChevronDown, ChevronUp, ChevronsUpDown, CornerDownRight, Eye, FileText, Folder, Gauge, List, MessageSquare, Search, Shield, ShieldAlert, ShieldCheck, SlidersHorizontal, Square, Target, Trash2, X } from "lucide-react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { filterAtMatches } from "../lib/atMatches";
|
||||
import { DedupIndex, sha256 } from "../lib/attachDedup";
|
||||
@@ -26,6 +26,7 @@ import { EffortSwitcher } from "./EffortSwitcher";
|
||||
import { ModelSwitcher } from "./ModelSwitcher";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { ComposerContextCard } from "./ComposerContextCard";
|
||||
import { ImageViewer } from "./ImageViewer";
|
||||
import { VirtualMenu } from "./VirtualMenu";
|
||||
import { dirEntryMenuLabel, dirEntrySubmitPath } from "./FileReferenceMenu";
|
||||
|
||||
@@ -57,12 +58,24 @@ const PROMPT_HISTORY_PREFETCH_REMAINING = 3;
|
||||
// it; the real gap is a few ms, so keep it short or a deliberate quick second
|
||||
// Enter (submit) gets eaten too.
|
||||
const IME_CONFIRM_GRACE_MS = 100;
|
||||
const FILE_REF_SEARCH_CACHE_TTL_MS = 5000;
|
||||
|
||||
type PastedBlock = {
|
||||
label: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type PendingGuidance = {
|
||||
id: number;
|
||||
text: string;
|
||||
submitText: string;
|
||||
};
|
||||
|
||||
type FileRefSearchCacheEntry = {
|
||||
entries: DirEntry[];
|
||||
cachedAt: number;
|
||||
};
|
||||
|
||||
type ComposerDraft = {
|
||||
text: string;
|
||||
attachments: Attachment[];
|
||||
@@ -163,6 +176,13 @@ function emptyComposerDraft(): ComposerDraft {
|
||||
};
|
||||
}
|
||||
|
||||
function guidanceTextMatches(queued: string, consumed: string): boolean {
|
||||
const left = queued.trim();
|
||||
const right = consumed.trim();
|
||||
if (!left || !right) return false;
|
||||
return left === right || left.includes(right) || right.includes(left);
|
||||
}
|
||||
|
||||
function cloneComposerDraft(draft: ComposerDraft): ComposerDraft {
|
||||
return {
|
||||
text: draft.text,
|
||||
@@ -431,6 +451,10 @@ export function Composer({
|
||||
retry,
|
||||
transientDismissSignal,
|
||||
sessionKey,
|
||||
fileRefRefreshKey,
|
||||
guidanceConsumedKey,
|
||||
guidanceConsumedText,
|
||||
guidanceQueuePreviewItems,
|
||||
}: {
|
||||
running: boolean;
|
||||
collaborationMode: CollaborationMode;
|
||||
@@ -470,6 +494,10 @@ export function Composer({
|
||||
retry?: { attempt: number; max: number };
|
||||
transientDismissSignal?: number;
|
||||
sessionKey?: string;
|
||||
fileRefRefreshKey?: number | string;
|
||||
guidanceConsumedKey?: string;
|
||||
guidanceConsumedText?: string;
|
||||
guidanceQueuePreviewItems?: readonly string[];
|
||||
}) {
|
||||
const { t, locale } = useI18n();
|
||||
const { showToast } = useToast();
|
||||
@@ -477,6 +505,15 @@ export function Composer({
|
||||
const now = useTick(running);
|
||||
const [text, setText] = useState("");
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [imageViewer, setImageViewer] = useState<{ open: boolean; url: string; name: string }>({ open: false, url: "", name: "" });
|
||||
const openComposerImageViewer = useCallback((url: string, name: string) => {
|
||||
setImageViewer({ open: true, url, name });
|
||||
}, []);
|
||||
|
||||
const closeComposerImageViewer = useCallback(() => {
|
||||
setImageViewer((prev) => (prev.open ? { ...prev, open: false } : prev));
|
||||
}, []);
|
||||
|
||||
const [workspaceRefs, setWorkspaceRefs] = useState<WorkspaceReference[]>([]);
|
||||
const [pastedBlocks, setPastedBlocks] = useState<PastedBlock[]>([]);
|
||||
const [openPastedLabels, setOpenPastedLabels] = useState<string[]>([]);
|
||||
@@ -498,6 +535,10 @@ export function Composer({
|
||||
const [pastChats, setPastChats] = useState<SessionMeta[]>([]);
|
||||
const [pastChatQuery, setPastChatQuery] = useState("");
|
||||
const [sessionRefs, setSessionRefs] = useState<SessionReference[]>([]);
|
||||
const [pendingGuidance, setPendingGuidance] = useState<PendingGuidance[]>([]);
|
||||
const [guidanceExpanded, setGuidanceExpanded] = useState(false);
|
||||
const [guidanceSendingId, setGuidanceSendingId] = useState<number | null>(null);
|
||||
const nextGuidanceId = useRef(1);
|
||||
const [loadingPastChats, setLoadingPastChats] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [composerPrompt, setComposerPrompt] = useState<string | null>(null);
|
||||
@@ -524,6 +565,8 @@ export function Composer({
|
||||
const lastSelectionRef = useRef({ start: 0, end: 0 });
|
||||
const consumedInsertIdRef = useRef(0);
|
||||
const lastTransientDismissSignal = useRef(transientDismissSignal);
|
||||
const lastGuidanceConsumedKey = useRef(guidanceConsumedKey);
|
||||
const selfDispatchedGuidanceRef = useRef<string[]>([]);
|
||||
const submittingRef = useRef(false);
|
||||
const nativeClipboardPasteTimerRef = useRef<number | null>(null);
|
||||
// Snapshot of the current cwd so async callbacks (openPastChats) can detect
|
||||
@@ -533,6 +576,7 @@ export function Composer({
|
||||
const attachmentDedupRef = useRef(new DedupIndex());
|
||||
const attachmentDedupKeysRef = useRef<Record<string, AttachmentDedupKey>>({});
|
||||
const draftKey = sessionKey || tabId || DEFAULT_COMPOSER_DRAFT_KEY;
|
||||
const guidanceQueuePreviewKey = (guidanceQueuePreviewItems ?? []).map((item) => item.trim()).filter(Boolean).join("\n");
|
||||
const draftsBySessionRef = useRef<Record<string, ComposerDraft>>({});
|
||||
const activeDraftKeyRef = useRef(draftKey);
|
||||
const textRef = useRef(text);
|
||||
@@ -609,14 +653,37 @@ export function Composer({
|
||||
useEffect(() => () => clearNativeClipboardPasteTimer(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasRunning.current && !running && text.trim() === "") {
|
||||
pastedBlocksRef.current = [];
|
||||
setPastedBlocks([]);
|
||||
setOpenPastedLabels([]);
|
||||
if (wasRunning.current && !running) {
|
||||
setPendingGuidance([]);
|
||||
setGuidanceExpanded(false);
|
||||
if (text.trim() === "") {
|
||||
pastedBlocksRef.current = [];
|
||||
setPastedBlocks([]);
|
||||
setOpenPastedLabels([]);
|
||||
}
|
||||
}
|
||||
wasRunning.current = running;
|
||||
}, [running, text]);
|
||||
|
||||
useEffect(() => {
|
||||
setPendingGuidance([]);
|
||||
setGuidanceExpanded(false);
|
||||
}, [draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!running || !guidanceQueuePreviewKey) return;
|
||||
setGuidanceExpanded(false);
|
||||
setPendingGuidance(
|
||||
guidanceQueuePreviewKey
|
||||
.split("\n")
|
||||
.map((text) => ({ id: nextGuidanceId.current++, text, submitText: text })),
|
||||
);
|
||||
}, [guidanceQueuePreviewKey, running]);
|
||||
|
||||
useEffect(() => {
|
||||
if (guidanceExpanded && pendingGuidance.length <= 2) setGuidanceExpanded(false);
|
||||
}, [guidanceExpanded, pendingGuidance.length]);
|
||||
|
||||
// --- slash commands (whole-input "/token") ---
|
||||
const [commands, setCommands] = useState<CommandInfo[]>([]);
|
||||
useEffect(() => {
|
||||
@@ -694,16 +761,9 @@ export function Composer({
|
||||
const [entries, setEntries] = useState<DirEntry[]>([]);
|
||||
const [searchEntries, setSearchEntries] = useState<DirEntry[]>([]);
|
||||
const dirCache = useRef<Record<string, DirEntry[]>>({});
|
||||
const searchCache = useRef<Record<string, DirEntry[]>>({});
|
||||
const searchCache = useRef<Record<string, FileRefSearchCacheEntry>>({});
|
||||
|
||||
// When the workspace/project changes (cwd prop), invalidate all @ mention
|
||||
// state so the picker reloads candidates for the new project. Without this,
|
||||
// dirCache/searchCache retain entries from the old project and the picker
|
||||
// shows stale results (issue #3601).
|
||||
const prevCwdRef = useRef(cwd);
|
||||
useEffect(() => {
|
||||
if (prevCwdRef.current === cwd) return; // skip mount — state already initial
|
||||
prevCwdRef.current = cwd;
|
||||
const clearFileRefState = useCallback(() => {
|
||||
dirCache.current = {};
|
||||
searchCache.current = {};
|
||||
setEntries([]);
|
||||
@@ -714,29 +774,50 @@ export function Composer({
|
||||
setLoadingPastChats(false);
|
||||
setActive(0);
|
||||
setDismissed(false);
|
||||
}, [cwd]);
|
||||
}, []);
|
||||
|
||||
// When the workspace/project changes (cwd prop), invalidate all @ mention
|
||||
// state so the picker reloads candidates for the new project. Without this,
|
||||
// dirCache/searchCache retain entries from the old project and the picker
|
||||
// shows stale results (issue #3601).
|
||||
const prevCwdRef = useRef(cwd);
|
||||
useEffect(() => {
|
||||
if (prevCwdRef.current === cwd) return; // skip mount — state already initial
|
||||
prevCwdRef.current = cwd;
|
||||
clearFileRefState();
|
||||
}, [clearFileRefState, cwd]);
|
||||
|
||||
const prevFileRefRefreshKeyRef = useRef(fileRefRefreshKey);
|
||||
useEffect(() => {
|
||||
if (prevFileRefRefreshKeyRef.current === fileRefRefreshKey) return;
|
||||
prevFileRefRefreshKeyRef.current = fileRefRefreshKey;
|
||||
clearFileRefState();
|
||||
}, [clearFileRefState, fileRefRefreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (atRaw === null) return;
|
||||
const cached = dirCache.current[atDir];
|
||||
if (cached) {
|
||||
setEntries(cached);
|
||||
return;
|
||||
} else {
|
||||
setEntries([]);
|
||||
}
|
||||
let live = true;
|
||||
app
|
||||
.ListDir(atDir)
|
||||
.then((es) => {
|
||||
const list = asArray(es);
|
||||
if (!live) return;
|
||||
dirCache.current[atDir] = list;
|
||||
if (live) setEntries(list);
|
||||
setEntries(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// re-fetch when the menu opens or the directory level changes
|
||||
}, [atRaw === null, atDir, cwd]);
|
||||
// Re-fetch when the menu opens, the directory level changes, or the
|
||||
// workspace tree refreshes; cached data is only a fast first paint.
|
||||
}, [atRaw === null, atDir, cwd, fileRefRefreshKey]);
|
||||
useEffect(() => {
|
||||
if (atRaw === null || atDir !== "" || atFrag === "") {
|
||||
setSearchEntries([]);
|
||||
@@ -744,23 +825,25 @@ export function Composer({
|
||||
}
|
||||
const cached = searchCache.current[atFrag];
|
||||
if (cached) {
|
||||
setSearchEntries(cached);
|
||||
return;
|
||||
setSearchEntries(cached.entries);
|
||||
if (Date.now() - cached.cachedAt < FILE_REF_SEARCH_CACHE_TTL_MS) return;
|
||||
} else {
|
||||
setSearchEntries([]);
|
||||
}
|
||||
setSearchEntries([]);
|
||||
let live = true;
|
||||
app
|
||||
.SearchFileRefs(atFrag)
|
||||
.then((es) => {
|
||||
const list = es ?? [];
|
||||
searchCache.current[atFrag] = list;
|
||||
if (live) setSearchEntries(list);
|
||||
const list = asArray(es);
|
||||
if (!live) return;
|
||||
searchCache.current[atFrag] = { entries: list, cachedAt: Date.now() };
|
||||
setSearchEntries(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [atRaw === null, atDir, atFrag, cwd]);
|
||||
}, [atRaw === null, atDir, atFrag, cwd, fileRefRefreshKey]);
|
||||
const atMatches = useMemo(
|
||||
() => {
|
||||
if (atRaw === null) return [];
|
||||
@@ -817,6 +900,28 @@ export function Composer({
|
||||
setDismissed(true);
|
||||
}, [transientDismissSignal]);
|
||||
|
||||
const takeSelfDispatchedGuidance = useCallback((text: string): boolean => {
|
||||
const idx = selfDispatchedGuidanceRef.current.findIndex((queued) => guidanceTextMatches(queued, text));
|
||||
if (idx < 0) return false;
|
||||
selfDispatchedGuidanceRef.current.splice(idx, 1);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!guidanceConsumedKey || guidanceConsumedKey === lastGuidanceConsumedKey.current) return;
|
||||
lastGuidanceConsumedKey.current = guidanceConsumedKey;
|
||||
const consumed = (guidanceConsumedText ?? "").trim();
|
||||
if (consumed && takeSelfDispatchedGuidance(consumed)) return;
|
||||
setPendingGuidance((items) => {
|
||||
if (items.length === 0) return items;
|
||||
const idx = consumed
|
||||
? items.findIndex((item) => guidanceTextMatches(item.submitText, consumed) || guidanceTextMatches(item.text, consumed))
|
||||
: -1;
|
||||
const removeAt = idx >= 0 ? idx : 0;
|
||||
return items.filter((_, index) => index !== removeAt);
|
||||
});
|
||||
}, [guidanceConsumedKey, guidanceConsumedText, takeSelfDispatchedGuidance]);
|
||||
|
||||
// When the @ trigger disappears (user deleted the @), close the past:chats
|
||||
// sub-menu and reset related state. Without this, showPastChats can outlive
|
||||
// the @ token and leave the session list visible with no way to dismiss it.
|
||||
@@ -1111,7 +1216,7 @@ export function Composer({
|
||||
}, [showToast, t]);
|
||||
|
||||
const submit = async () => {
|
||||
if (disabled || submitDisabled || readOnly || submittingRef.current) return;
|
||||
if (disabled || (!running && submitDisabled) || readOnly || submittingRef.current) return;
|
||||
const submitDraftKey = activeDraftKeyRef.current;
|
||||
const currentText = textRef.current;
|
||||
const trimmedText = currentText.trim();
|
||||
@@ -1150,6 +1255,16 @@ export function Composer({
|
||||
const sessionContext = currentSessionRefs.length === 0 ? "" : await buildSessionContext(currentSessionRefs);
|
||||
const baseSubmitText = [expandPastedBlocks(trimmedText), refs].filter(Boolean).join(trimmedText && refs ? " " : "");
|
||||
const submitText = sessionContext ? `${sessionContext}${baseSubmitText}` : baseSubmitText;
|
||||
if (running) {
|
||||
const guidanceText = displayText.trim();
|
||||
const guidanceSubmitText = submitText.trim();
|
||||
if (guidanceText) {
|
||||
const id = nextGuidanceId.current++;
|
||||
setPendingGuidance((items) => [...items, { id, text: guidanceText, submitText: guidanceSubmitText || guidanceText }]);
|
||||
}
|
||||
clearSubmittedDraft(submitDraftKey);
|
||||
return;
|
||||
}
|
||||
await onSend(displayText, submitText);
|
||||
clearSubmittedDraft(submitDraftKey);
|
||||
} catch (error) {
|
||||
@@ -1160,6 +1275,27 @@ export function Composer({
|
||||
}
|
||||
};
|
||||
|
||||
const sendQueuedGuidance = async (item: PendingGuidance) => {
|
||||
if (disabled || readOnly || guidanceSendingId !== null) return;
|
||||
const displayText = item.text.trim();
|
||||
const submitText = item.submitText.trim() || displayText;
|
||||
if (!displayText || !submitText) return;
|
||||
selfDispatchedGuidanceRef.current.push(submitText);
|
||||
setGuidanceSendingId(item.id);
|
||||
try {
|
||||
await onSend(displayText, submitText);
|
||||
setPendingGuidance((items) => items.filter((queued) => queued.id !== item.id));
|
||||
window.setTimeout(() => {
|
||||
takeSelfDispatchedGuidance(submitText);
|
||||
}, 5000);
|
||||
} catch (error) {
|
||||
takeSelfDispatchedGuidance(submitText);
|
||||
showToast(error instanceof Error ? error.message : String(error), "warn");
|
||||
} finally {
|
||||
setGuidanceSendingId((current) => (current === item.id ? null : current));
|
||||
}
|
||||
};
|
||||
|
||||
const readFileAsDataURL = (file: File) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -1929,6 +2065,21 @@ export function Composer({
|
||||
return `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
|
||||
})()
|
||||
: null;
|
||||
const submitEmpty = !text.trim() && attachments.length === 0 && workspaceRefs.length === 0;
|
||||
const submitBlocked = submitting || pendingPaste > 0 || (submitEmpty && !(goalModeOn && !activeGoal)) || disabled || (!running && submitDisabled) || readOnly;
|
||||
const submitTooltip = running ? t("composer.queueGuidance") : t("composer.send");
|
||||
const composerPlaceholder = readOnly
|
||||
? t("composer.readOnlyChannel")
|
||||
: disabled
|
||||
? t("common.loading")
|
||||
: running
|
||||
? t("composer.steerPlaceholder")
|
||||
: goalModeOn && !activeGoal
|
||||
? t("composer.goalInputPlaceholder")
|
||||
: t("composer.placeholder");
|
||||
const hiddenGuidanceCount = Math.max(0, pendingGuidance.length - 2);
|
||||
const visibleGuidance = guidanceExpanded ? pendingGuidance : pendingGuidance.slice(0, 2);
|
||||
const showGuidanceExpander = pendingGuidance.length > 2;
|
||||
const composerMetaClass = [
|
||||
"composer-meta",
|
||||
hasEffort ? "composer-meta--has-effort" : "composer-meta--no-effort",
|
||||
@@ -2185,6 +2336,58 @@ export function Composer({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pendingGuidance.length > 0 && (
|
||||
<div className="composer-guidance-shelf" aria-label={t("composer.guidanceQueue")}>
|
||||
<div className="composer-guidance-head">
|
||||
<span className="composer-guidance-head__label">
|
||||
<CornerDownRight size={14} />
|
||||
<span>{t("composer.guidanceCount", { n: pendingGuidance.length })}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="composer-guidance-list">
|
||||
{visibleGuidance.map((item) => (
|
||||
<div className="composer-guidance-item" key={item.id}>
|
||||
<CornerDownRight size={14} className="composer-guidance-item__icon" />
|
||||
<span className="composer-guidance-item__text">{item.text}</span>
|
||||
<Tooltip label={t("composer.guidanceSend")}>
|
||||
<button
|
||||
className="composer-guidance-item__guide"
|
||||
type="button"
|
||||
aria-label={t("composer.guidanceSend")}
|
||||
disabled={!running || disabled || readOnly || guidanceSendingId !== null}
|
||||
onClick={() => void sendQueuedGuidance(item)}
|
||||
>
|
||||
<CornerDownRight size={13} />
|
||||
<span>{t("composer.guidanceMode")}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("composer.guidanceDismiss")}>
|
||||
<button
|
||||
className="composer-guidance-item__action"
|
||||
type="button"
|
||||
aria-label={t("composer.guidanceDismiss")}
|
||||
disabled={guidanceSendingId === item.id}
|
||||
onClick={() => setPendingGuidance((items) => items.filter((queued) => queued.id !== item.id))}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
{showGuidanceExpander && (
|
||||
<button
|
||||
className="composer-guidance-more"
|
||||
type="button"
|
||||
aria-expanded={guidanceExpanded}
|
||||
onClick={() => setGuidanceExpanded((value) => !value)}
|
||||
>
|
||||
{guidanceExpanded ? <ChevronUp size={13} /> : <ChevronDown size={13} />}
|
||||
<span>{guidanceExpanded ? t("composer.guidanceCollapse") : t("composer.guidanceRemaining", { n: hiddenGuidanceCount })}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(attachments.length > 0 || workspaceRefs.length > 0 || sessionRefs.length > 0) && (
|
||||
<div className="composer-context" aria-label={t("composer.contextItems")}>
|
||||
{sortComposerAttachments(attachments).map((a) => {
|
||||
@@ -2193,10 +2396,11 @@ export function Composer({
|
||||
<ComposerContextCard
|
||||
key={a.path}
|
||||
variant="attachment"
|
||||
tooltipLabel={a.path}
|
||||
tooltipLabel={a.previewUrl ? `${t("imageViewer.clickToPreview")} — ${a.path}` : a.path}
|
||||
removeLabel={t("composer.removeImage")}
|
||||
onRemove={() => removeAttachment(a.path)}
|
||||
previewUrl={a.previewUrl}
|
||||
onImageClick={a.previewUrl ? () => openComposerImageViewer(a.previewUrl!, attachmentName(a)) : undefined}
|
||||
imageOnly={imageOnly}
|
||||
name={attachmentName(a)}
|
||||
meta={attachmentExt(attachmentName(a)) || t("msg.fileAttachment")}
|
||||
@@ -2240,6 +2444,12 @@ export function Composer({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ImageViewer
|
||||
open={imageViewer.open}
|
||||
imageUrl={imageViewer.url}
|
||||
imageName={imageViewer.name}
|
||||
onClose={closeComposerImageViewer}
|
||||
/>
|
||||
{activePastedBlocks.length > 0 && (
|
||||
<div className="composer__pasted">
|
||||
{activePastedBlocks.map((block) => {
|
||||
@@ -2324,7 +2534,7 @@ export function Composer({
|
||||
lastCompositionEndAt.current = Date.now();
|
||||
}}
|
||||
style={textareaStyle}
|
||||
placeholder={readOnly ? t("composer.readOnlyChannel") : disabled ? t("common.loading") : goalModeOn && !activeGoal ? t("composer.goalInputPlaceholder") : t("composer.placeholder")}
|
||||
placeholder={composerPlaceholder}
|
||||
rows={1}
|
||||
disabled={disabled || readOnly}
|
||||
/>
|
||||
@@ -2333,17 +2543,16 @@ export function Composer({
|
||||
{composerPrompt}
|
||||
</span>
|
||||
)}
|
||||
{!running && (
|
||||
<Tooltip label={t("composer.send")}>
|
||||
<button
|
||||
className="composer__btn composer__btn--send"
|
||||
onClick={submit}
|
||||
disabled={submitting || pendingPaste > 0 || ((!text.trim() && attachments.length === 0 && workspaceRefs.length === 0) && !(goalModeOn && !activeGoal)) || disabled || submitDisabled || readOnly}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={submitTooltip}>
|
||||
<button
|
||||
className={`composer__btn composer__btn--send${running ? " composer__btn--steer" : ""}`}
|
||||
onClick={submit}
|
||||
disabled={submitBlocked}
|
||||
aria-label={submitTooltip}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={composerMetaClass}>
|
||||
<div className="composer-meta__params">
|
||||
|
||||
@@ -12,6 +12,7 @@ export function ComposerContextCard({
|
||||
removeDisabled = false,
|
||||
removeIconSize,
|
||||
previewUrl,
|
||||
onImageClick,
|
||||
imageOnly = false,
|
||||
folder = false,
|
||||
name,
|
||||
@@ -26,6 +27,8 @@ export function ComposerContextCard({
|
||||
removeDisabled?: boolean;
|
||||
removeIconSize?: number;
|
||||
previewUrl?: string;
|
||||
/** called when the image thumbnail is clicked (only for image attachments with previewUrl) */
|
||||
onImageClick?: () => void;
|
||||
imageOnly?: boolean;
|
||||
folder?: boolean;
|
||||
name?: string;
|
||||
@@ -46,7 +49,13 @@ export function ComposerContextCard({
|
||||
<Tooltip label={tooltipLabel}>
|
||||
<span className="composer-context__label">
|
||||
{previewUrl ? (
|
||||
<span className="composer-context__thumb">
|
||||
<span
|
||||
className={`composer-context__thumb${onImageClick ? " composer-context__thumb--interactive" : ""}`}
|
||||
onClick={onImageClick}
|
||||
role={onImageClick ? "button" : undefined}
|
||||
tabIndex={onImageClick ? 0 : undefined}
|
||||
onKeyDown={onImageClick ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onImageClick(); } } : undefined}
|
||||
>
|
||||
<img src={previewUrl} alt="" draggable={false} />
|
||||
</span>
|
||||
) : variant === "attachment" ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// ContextPanel shows the active tab's context gauge and token usage.
|
||||
// All visible text is routed through the i18n dictionary.
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { app } from "../lib/bridge";
|
||||
import { useI18n, type Locale, type Translator } from "../lib/i18n";
|
||||
@@ -23,9 +23,9 @@ interface ContextPanelProps {
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1000) return `${Math.round(n / 1000)}k`;
|
||||
return String(n);
|
||||
function fmtFullTokens(n: number): string {
|
||||
if (n <= 0) return "0";
|
||||
return String(Math.round(n));
|
||||
}
|
||||
|
||||
function fmtDuration(ms: number, t: Translator): string {
|
||||
@@ -62,11 +62,6 @@ export function formatMetricTokens(tokens: number | undefined, locale: Locale |
|
||||
return { display: exact, exact };
|
||||
}
|
||||
|
||||
function fmtTurns(turns: number | undefined, t: Translator): string {
|
||||
if (typeof turns !== "number" || turns < 0) return "-";
|
||||
return t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns });
|
||||
}
|
||||
|
||||
function fmtUsageCacheRate(usage?: WireUsage): string {
|
||||
if (!usage) return "-";
|
||||
const denom = usage.cacheHitTokens + usage.cacheMissTokens;
|
||||
@@ -80,10 +75,44 @@ export function formatCacheHitRate(hitTokens: number, missTokens: number): strin
|
||||
return `${((hitTokens / denom) * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
interface HealthResult {
|
||||
type MetricTone = "accent" | "good" | "notice" | "warn";
|
||||
type UsageAnalysisView = "source" | "type";
|
||||
type ContextUsageRefreshFields = Pick<
|
||||
WireUsage,
|
||||
"totalTokens" | "promptTokens" | "completionTokens" | "reasoningTokens" | "sessionCacheHitTokens" | "sessionCacheMissTokens"
|
||||
>;
|
||||
|
||||
export function contextUsageRefreshKey(usage?: ContextUsageRefreshFields): string {
|
||||
if (!usage) return "";
|
||||
return [
|
||||
usage.totalTokens ?? 0,
|
||||
usage.promptTokens ?? 0,
|
||||
usage.completionTokens ?? 0,
|
||||
usage.reasoningTokens ?? 0,
|
||||
usage.sessionCacheHitTokens ?? 0,
|
||||
usage.sessionCacheMissTokens ?? 0,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export function cacheHitTone(hitTokens: number, missTokens: number): MetricTone | undefined {
|
||||
const denom = hitTokens + missTokens;
|
||||
if (denom <= 0) return undefined;
|
||||
const pct = (hitTokens / denom) * 100;
|
||||
if (pct >= 80) return "good";
|
||||
if (pct >= 60) return "notice";
|
||||
return "warn";
|
||||
}
|
||||
|
||||
function formatSharePercent(value: number, total: number): string {
|
||||
if (total <= 0 || value <= 0) return "-";
|
||||
const pct = (value / total) * 100;
|
||||
if (pct > 0 && pct < 1) return "<1%";
|
||||
return `${Math.round(pct)}%`;
|
||||
}
|
||||
|
||||
interface ContextWindowStatus {
|
||||
tone: "good" | "notice" | "warn";
|
||||
shortKey: DictKey;
|
||||
vars: Record<string, string | number>;
|
||||
key: DictKey;
|
||||
}
|
||||
|
||||
export function contextCostDisplay({
|
||||
@@ -97,24 +126,40 @@ export function contextCostDisplay({
|
||||
sessionCurrency?: string;
|
||||
usage?: Pick<WireUsage, "cost" | "costUsd" | "currency">;
|
||||
}): { amount: number; currency?: string } {
|
||||
// Session-scoped sources only: this value renders under the 会话费用 label,
|
||||
// and falling back to a single request's usage.cost silently displayed one
|
||||
// turn's spend as the whole session's. usage now contributes currency only.
|
||||
if (info?.sessionCost && info.sessionCost > 0) {
|
||||
return { amount: info.sessionCost, currency: info.sessionCurrency || sessionCurrency || usage?.currency };
|
||||
}
|
||||
if (sessionCost && sessionCost > 0) {
|
||||
return { amount: sessionCost, currency: sessionCurrency || info?.sessionCurrency || usage?.currency };
|
||||
}
|
||||
if (usage?.cost && usage.cost > 0) {
|
||||
return { amount: usage.cost, currency: usage.currency || sessionCurrency || info?.sessionCurrency };
|
||||
}
|
||||
if (info?.sessionCostUsd && info.sessionCostUsd > 0) {
|
||||
return { amount: info.sessionCostUsd, currency: info.sessionCurrency || sessionCurrency || usage?.currency };
|
||||
}
|
||||
if (usage?.costUsd && usage.costUsd > 0) {
|
||||
return { amount: usage.costUsd, currency: usage.currency || sessionCurrency || info?.sessionCurrency };
|
||||
}
|
||||
return { amount: 0, currency: info?.sessionCurrency || sessionCurrency || usage?.currency };
|
||||
}
|
||||
|
||||
// contextSessionCache picks the session-cumulative cache hit/miss pair for the
|
||||
// panel's session average. All-sources telemetry (panel info, then ContextInfo)
|
||||
// wins over the wire session counters, which the Go agent scopes to the
|
||||
// executor only — the same preference StatusBar applies — and the pair always
|
||||
// comes from a single source so the computed rate never mixes scopes.
|
||||
export function contextSessionCache(
|
||||
info?: Pick<ContextPanelInfo, "sessionCacheHitTokens" | "sessionCacheMissTokens"> | null,
|
||||
context?: Pick<ContextInfo, "cacheHitTokens" | "cacheMissTokens">,
|
||||
usage?: Pick<WireUsage, "sessionCacheHitTokens" | "sessionCacheMissTokens">,
|
||||
): { hit: number; miss: number } {
|
||||
const infoHit = info?.sessionCacheHitTokens ?? 0;
|
||||
const infoMiss = info?.sessionCacheMissTokens ?? 0;
|
||||
if (infoHit + infoMiss > 0) return { hit: infoHit, miss: infoMiss };
|
||||
const ctxHit = context?.cacheHitTokens ?? 0;
|
||||
const ctxMiss = context?.cacheMissTokens ?? 0;
|
||||
if (ctxHit + ctxMiss > 0) return { hit: ctxHit, miss: ctxMiss };
|
||||
return { hit: usage?.sessionCacheHitTokens ?? 0, miss: usage?.sessionCacheMissTokens ?? 0 };
|
||||
}
|
||||
|
||||
interface ContextBreakdown {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
@@ -171,33 +216,11 @@ export function contextBreakdown(
|
||||
};
|
||||
}
|
||||
|
||||
function contextHealth(usagePct: number, cachePct: number, readCount: number): HealthResult {
|
||||
if (usagePct >= 85) {
|
||||
return {
|
||||
tone: "warn",
|
||||
shortKey: "context.healthNearLimitShort",
|
||||
vars: { pct: usagePct },
|
||||
};
|
||||
}
|
||||
if (readCount >= 8) {
|
||||
return {
|
||||
tone: "notice",
|
||||
shortKey: "context.healthManyFilesShort",
|
||||
vars: { count: readCount },
|
||||
};
|
||||
}
|
||||
if (cachePct > 0 && cachePct < 50) {
|
||||
return {
|
||||
tone: "notice",
|
||||
shortKey: "context.healthLowCacheShort",
|
||||
vars: { pct: cachePct },
|
||||
};
|
||||
}
|
||||
return {
|
||||
tone: "good",
|
||||
shortKey: "context.healthGoodShort",
|
||||
vars: {},
|
||||
};
|
||||
export function contextWindowStatus(usagePct: number, compactPct: number): ContextWindowStatus {
|
||||
if (usagePct >= 90) return { tone: "warn", key: "context.windowStatusNearLimit" };
|
||||
if (compactPct > 0 && usagePct >= compactPct) return { tone: "warn", key: "context.windowStatusPastCompact" };
|
||||
if (compactPct > 0 && usagePct >= Math.max(0, compactPct - 10)) return { tone: "notice", key: "context.windowStatusWatch" };
|
||||
return { tone: "good", key: "context.windowStatusHealthy" };
|
||||
}
|
||||
|
||||
const SOURCE_ORDER = ["executor", "planner", "subagent", "compaction", "classifier", "title"];
|
||||
@@ -286,7 +309,6 @@ export function ContextPanel({
|
||||
sessionTokens,
|
||||
sessionCost,
|
||||
sessionCurrency,
|
||||
sessionTurns,
|
||||
turnTokens,
|
||||
turnCost,
|
||||
balance,
|
||||
@@ -295,24 +317,24 @@ export function ContextPanel({
|
||||
}: ContextPanelProps) {
|
||||
const { locale, t } = useI18n();
|
||||
const [info, setInfo] = useState<ContextPanelInfo | null>(null);
|
||||
const [analysisView, setAnalysisView] = useState<UsageAnalysisView>("source");
|
||||
const refreshSeq = useRef(0);
|
||||
const lastRefreshTime = useRef(0);
|
||||
const usageRefreshKey = contextUsageRefreshKey(usage);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!tabId) return;
|
||||
const seq = ++refreshSeq.current;
|
||||
try {
|
||||
const next = await app.ContextPanel(tabId);
|
||||
if (refreshSeq.current === seq) setInfo(next);
|
||||
if (refreshSeq.current === seq) {
|
||||
setInfo(next);
|
||||
}
|
||||
} catch {
|
||||
/* bridge unavailable */
|
||||
}
|
||||
}, [tabId]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => void refresh(), 2000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshSeq.current += 1;
|
||||
setInfo(null);
|
||||
@@ -323,19 +345,23 @@ export function ContextPanel({
|
||||
void refresh();
|
||||
}, [refresh, refreshKey]);
|
||||
|
||||
const hasPanelUsage = Boolean(
|
||||
(info?.requestCount ?? 0) > 0 ||
|
||||
(info?.promptTokens ?? 0) > 0 ||
|
||||
(info?.completionTokens ?? 0) > 0 ||
|
||||
(info?.totalTokens ?? 0) > 0 ||
|
||||
(info?.reasoningTokens ?? 0) > 0 ||
|
||||
(info?.cacheHitTokens ?? 0) > 0 ||
|
||||
(info?.cacheMissTokens ?? 0) > 0
|
||||
);
|
||||
// Refresh the panel snapshot while usage events stream. The key includes
|
||||
// general token fields so providers without cache telemetry still tick.
|
||||
useEffect(() => {
|
||||
if (!usageRefreshKey) return;
|
||||
const now = Date.now();
|
||||
if (now - lastRefreshTime.current >= 1000) {
|
||||
lastRefreshTime.current = now;
|
||||
void refresh();
|
||||
}
|
||||
}, [usageRefreshKey, refresh]);
|
||||
|
||||
const usedTokens = context?.used && context.used > 0 ? context.used : info?.usedTokens ?? 0;
|
||||
const windowTokens = context?.window && context.window > 0 ? context.window : info?.windowTokens ?? 0;
|
||||
const promptTokens = hasPanelUsage ? info?.promptTokens ?? 0 : usage?.promptTokens ?? 0;
|
||||
const completionTokens = hasPanelUsage ? info?.completionTokens ?? 0 : usage?.completionTokens ?? 0;
|
||||
// Prefer live usage props (updated in real-time by the reducer during streaming)
|
||||
// over the async-fetched info snapshot (only refreshed on turn_done).
|
||||
const promptTokens = usage?.promptTokens ?? info?.promptTokens ?? 0;
|
||||
const completionTokens = usage?.completionTokens ?? info?.completionTokens ?? 0;
|
||||
const totalTokens = info?.totalTokens && info.totalTokens > 0
|
||||
? info.totalTokens
|
||||
: sessionTokens && sessionTokens > 0
|
||||
@@ -343,33 +369,29 @@ export function ContextPanel({
|
||||
: usage?.totalTokens && usage.totalTokens > 0
|
||||
? usage.totalTokens
|
||||
: promptTokens + completionTokens;
|
||||
const reasoningTokens = hasPanelUsage ? info?.reasoningTokens ?? 0 : usage?.reasoningTokens ?? 0;
|
||||
const cacheHitTokens = hasPanelUsage ? info?.cacheHitTokens ?? 0 : usage?.cacheHitTokens ?? 0;
|
||||
const cacheMissTokens = hasPanelUsage ? info?.cacheMissTokens ?? 0 : usage?.cacheMissTokens ?? 0;
|
||||
// Session-cumulative values for the metrics cards (方案A: 纯前端改数据源)
|
||||
const sessionCacheHit = info?.sessionCacheHitTokens ?? usage?.sessionCacheHitTokens ?? context?.cacheHitTokens ?? 0;
|
||||
const sessionCacheMiss = info?.sessionCacheMissTokens ?? usage?.sessionCacheMissTokens ?? context?.cacheMissTokens ?? 0;
|
||||
const sessionCompletion = info?.sessionCompletionTokens ?? 0;
|
||||
const sessionCacheHitMetric = formatMetricTokens(sessionCacheHit, locale);
|
||||
const sessionCacheMissMetric = formatMetricTokens(sessionCacheMiss, locale);
|
||||
const sessionCompletionMetric = formatMetricTokens(sessionCompletion, locale);
|
||||
const reasoningTokens = usage?.reasoningTokens ?? info?.reasoningTokens ?? 0;
|
||||
// Session-cumulative cache tokens for the top summary: all-sources telemetry
|
||||
// first (matching the session cost and per-source rows in this panel — the
|
||||
// wire session counters are executor-only), with the live counters bridging
|
||||
// only a fresh session's first turn before the telemetry refresh. Hit and
|
||||
// miss come as a pair from one source so the rate cannot mix scopes.
|
||||
const { hit: sessionCacheHit, miss: sessionCacheMiss } = contextSessionCache(info, context, usage);
|
||||
const totalTokensMetric = formatMetricTokens(totalTokens, locale);
|
||||
const cost = contextCostDisplay({ info, sessionCost, sessionCurrency, usage });
|
||||
const sourceUsageRows = contextSourceRows(info, sessionCurrency);
|
||||
const showSourceUsageRows = sourceUsageRows.length > 0;
|
||||
const sourceTotalTokens = sourceUsageRows.reduce((sum, row) => sum + sourceTokenTotal(row), 0);
|
||||
const visibleSourceRows = sourceUsageRows.slice(0, 3);
|
||||
const hiddenSourceRows = sourceUsageRows.slice(3);
|
||||
const readFiles = asArray(info?.readFiles);
|
||||
const changedFiles = asArray(info?.changedFiles);
|
||||
|
||||
const usagePct = windowTokens > 0 ? Math.min(100, Math.round((usedTokens / windowTokens) * 100)) : 0;
|
||||
const compactPct = context?.compactRatio ? Math.round(context.compactRatio * 100) : 0;
|
||||
const cacheDenom = cacheHitTokens + cacheMissTokens;
|
||||
const cachePct = cacheDenom > 0 ? (cacheHitTokens / cacheDenom) * 100 : 0;
|
||||
const cachePctDisplay = formatCacheHitRate(cacheHitTokens, cacheMissTokens);
|
||||
const compactRatio = context?.compactRatio && context.compactRatio > 0 ? context.compactRatio : 0.8;
|
||||
const compactPct = Math.round(compactRatio * 100);
|
||||
const compactTokens = windowTokens > 0 ? Math.round(windowTokens * compactRatio) : 0;
|
||||
const tokensUntilCompact = compactTokens > usedTokens ? compactTokens - usedTokens : 0;
|
||||
const breakdown = contextBreakdown(usedTokens, windowTokens, promptTokens, completionTokens, reasoningTokens);
|
||||
const donutStyle = {
|
||||
background: `conic-gradient(#13a7a5 0 ${breakdown.promptPct}%, #2f6df6 ${breakdown.promptPct}% ${breakdown.completionPct}%, #f97316 ${breakdown.completionPct}% ${breakdown.reasoningPct}%, var(--border) ${breakdown.reasoningPct}% ${breakdown.otherPct}%, var(--border-soft) ${breakdown.otherPct}% 100%)`,
|
||||
};
|
||||
const eventTimes = [
|
||||
...readFiles.map((file) => file.time),
|
||||
...changedFiles.map((file) => file.latestTime ?? 0),
|
||||
@@ -378,100 +400,165 @@ export function ContextPanel({
|
||||
const elapsed = info?.elapsedMs && info.elapsedMs > 0 ? info.elapsedMs : derivedElapsed;
|
||||
const derivedRequestCount = Math.max(readFiles.length + changedFiles.length, 0);
|
||||
const requestCount = info?.requestCount && info.requestCount > 0 ? info.requestCount : derivedRequestCount;
|
||||
const health = contextHealth(usagePct, Math.round(cachePct), readFiles.length);
|
||||
const windowStatus = contextWindowStatus(usagePct, compactPct);
|
||||
const balanceLabel = balance?.available && balance.display ? balance.display : "-";
|
||||
const turnCostLabel = formatMoneyLocalized(turnCost, sessionCurrency, { locale, empty: "dash" });
|
||||
const sessionCostLabel = formatMoneyLocalized(sessionCost, sessionCurrency, { locale, empty: "dash" });
|
||||
const sessionCostLabel = formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" });
|
||||
const totalTokensTitle = totalTokensMetric.exact === "-" ? "-" : t("context.tokensValue", { value: totalTokensMetric.exact });
|
||||
const usedLabel = fmtFullTokens(usedTokens);
|
||||
const windowLabel = fmtFullTokens(windowTokens);
|
||||
const compactRemainingLabel = tokensUntilCompact > 0 ? fmtFullTokens(tokensUntilCompact) : "0";
|
||||
const compactMarkerPct = Math.max(0, Math.min(100, compactPct));
|
||||
const usageMarkerPct = Math.max(6, Math.min(94, usagePct));
|
||||
const compactLabelPct = Math.max(6, Math.min(94, compactMarkerPct));
|
||||
const usageSummary = t("context.windowUsageSummary", { used: usedLabel, window: windowLabel, pct: usagePct });
|
||||
const compactSummary = t("context.windowCompactRemaining", { used: usedLabel, window: windowLabel, tokens: compactRemainingLabel, pct: compactPct });
|
||||
const activeAnalysisView: UsageAnalysisView = showSourceUsageRows ? analysisView : "type";
|
||||
const tokenTypeRows = [
|
||||
{ key: "prompt", label: t("context.prompt"), value: breakdown.promptTokens },
|
||||
{ key: "completion", label: t("context.completion"), value: breakdown.completionTokens },
|
||||
{ key: "reasoning", label: t("context.reasoning"), value: breakdown.reasoningTokens },
|
||||
{ key: "other", label: t("context.other"), value: breakdown.otherTokens },
|
||||
];
|
||||
const tokenCompositionTotal = tokenTypeRows.reduce((sum, row) => sum + row.value, 0);
|
||||
const renderSourceRow = (row: ContextSourceRow) => {
|
||||
const inputMetric = formatMetricTokens(row.promptTokens, locale);
|
||||
const outputMetric = formatMetricTokens(row.completionTokens, locale);
|
||||
const hitMetric = formatMetricTokens(row.cacheHitTokens, locale);
|
||||
const missMetric = formatMetricTokens(row.cacheMissTokens, locale);
|
||||
const totalMetric = formatMetricTokens(sourceTokenTotal(row), locale);
|
||||
const cacheReported = row.cacheHitTokens + row.cacheMissTokens > 0;
|
||||
const cacheRate = cacheReported ? formatCacheHitRate(row.cacheHitTokens, row.cacheMissTokens) : t("context.cacheNotReported");
|
||||
const costLabel = formatMoneyLocalized(row.cost, row.currency, { locale, empty: "dash" });
|
||||
return (
|
||||
<div className="context-panel__source-row" key={row.source}>
|
||||
<div className="context-panel__source-head">
|
||||
<span>
|
||||
<i className={`context-panel__source-dot context-panel__source-tone--${sourceTone(row.source)}`} aria-hidden="true" />
|
||||
{sourceLabel(row.label, t)}
|
||||
</span>
|
||||
<em>{t("context.sourceRequests", { count: row.requests })}</em>
|
||||
</div>
|
||||
<div className="context-panel__source-summary">
|
||||
<SourceMetric label={t("context.total")} value={totalMetric.display} title={totalMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceCacheRate")} value={cacheRate} />
|
||||
<SourceMetric label={t("context.sourceCost")} value={costLabel} />
|
||||
</div>
|
||||
<details className="context-panel__source-details">
|
||||
<summary>{t("context.sourceDetails")}</summary>
|
||||
<div className="context-panel__source-details-body">
|
||||
<SourceSplitBar
|
||||
label={`${t("context.sourceInput")}/${t("context.sourceOutput")}`}
|
||||
segments={[
|
||||
{ label: t("context.sourceInput"), value: row.promptTokens, tone: "input" },
|
||||
{ label: t("context.sourceOutput"), value: row.completionTokens, tone: "output" },
|
||||
]}
|
||||
/>
|
||||
{cacheReported ? (
|
||||
<SourceSplitBar
|
||||
label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`}
|
||||
segments={[
|
||||
{ label: t("context.sourceCacheHit"), value: row.cacheHitTokens, tone: "hit" },
|
||||
{ label: t("context.sourceCacheMiss"), value: row.cacheMissTokens, tone: "miss" },
|
||||
]}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<SourceSplitBar label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`} segments={[]} compact />
|
||||
)}
|
||||
<div className="context-panel__source-metrics">
|
||||
<SourceMetric label={t("context.sourceInput")} value={inputMetric.display} title={inputMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceOutput")} value={outputMetric.display} title={outputMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceCacheHit")} value={hitMetric.display} title={hitMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceCacheMiss")} value={missMetric.display} title={missMetric.exact} />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="context-panel">
|
||||
<div className="context-panel__body">
|
||||
<section className="context-panel__overview">
|
||||
<section className="context-panel__usage">
|
||||
<SectionHeading title={t("context.windowTitle")} meta={t("context.windowSubtitle")} />
|
||||
<div className="context-panel__usage-visual">
|
||||
<div className="context-panel__donut" style={donutStyle}>
|
||||
<div className="context-panel__donut-core">
|
||||
<strong>{fmtTokens(usedTokens)}</strong>
|
||||
<span>/ {fmtTokens(windowTokens)} tokens</span>
|
||||
<SectionHeading title={t("context.windowTitle")} />
|
||||
<div className={`context-panel__capacity-card context-panel__capacity-card--${windowStatus.tone}`}>
|
||||
<div className="context-panel__capacity-top">
|
||||
<span className="context-panel__capacity-status">{t(windowStatus.key)}</span>
|
||||
<strong>{usedLabel}/{windowLabel}</strong>
|
||||
</div>
|
||||
<div className="context-panel__usage-progress context-panel__capacity-meter" aria-label={`${t(windowStatus.key)}. ${usageSummary}. ${compactSummary}`}>
|
||||
<div className="context-panel__capacity-scale" aria-hidden="true">
|
||||
<span className="context-panel__capacity-pin context-panel__capacity-pin--used" style={{ left: `${usageMarkerPct}%` }}>{usagePct}%</span>
|
||||
<span className="context-panel__capacity-pin context-panel__capacity-pin--compact" style={{ left: `${compactLabelPct}%` }}>{compactPct}%</span>
|
||||
</div>
|
||||
<div className="context-panel__progress-track" aria-hidden="true">
|
||||
<span className="context-panel__progress-segment context-panel__progress-segment--prompt" style={{ width: `${breakdown.promptPct}%` }} />
|
||||
<span className="context-panel__progress-segment context-panel__progress-segment--completion" style={{ width: `${Math.max(0, breakdown.completionPct - breakdown.promptPct)}%` }} />
|
||||
<span className="context-panel__progress-segment context-panel__progress-segment--reasoning" style={{ width: `${Math.max(0, breakdown.reasoningPct - breakdown.completionPct)}%` }} />
|
||||
<span className="context-panel__progress-segment context-panel__progress-segment--other" style={{ width: `${Math.max(0, breakdown.otherPct - breakdown.reasoningPct)}%` }} />
|
||||
<span className="context-panel__compact-marker" style={{ left: `${compactMarkerPct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="context-panel__percent">{usagePct}%</div>
|
||||
</div>
|
||||
<div className="context-panel__usage-progress" aria-label={t("context.windowSubtitle")}>
|
||||
<div className="context-panel__progress-head">
|
||||
<strong>{fmtTokens(usedTokens)} / {fmtTokens(windowTokens)}</strong>
|
||||
<span>{usagePct}%</span>
|
||||
</div>
|
||||
<div className="context-panel__progress-track" aria-hidden="true">
|
||||
<span className="context-panel__progress-fill" style={{ width: `${usagePct}%` }} />
|
||||
<div className="context-panel__capacity-foot">
|
||||
<span>{t("context.windowUsedLabel")}</span>
|
||||
<span className="context-panel__capacity-remaining">
|
||||
<span>{t("context.windowCompactDistance")}</span>
|
||||
<strong>{compactRemainingLabel}</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="context-panel__summary-rows">
|
||||
<MiniStat label={t("status.compactLabel")} value={compactPct > 0 ? `${compactPct}%` : "-"} />
|
||||
<MiniStat label={t("status.cacheAvgLabel")} value={formatCacheHitRate(sessionCacheHit, sessionCacheMiss)} />
|
||||
<MiniStat label={t("context.sessionCost")} value={sessionCostLabel} />
|
||||
<MiniStat label={t("status.sessionTurnsLabel")} value={fmtTurns(sessionTurns, t)} />
|
||||
</div>
|
||||
<div className="context-panel__breakdown">
|
||||
<TokenLegend label={t("context.prompt")} value={breakdown.promptTokens} color="prompt" />
|
||||
<TokenLegend label={t("context.completion")} value={breakdown.completionTokens} color="completion" />
|
||||
<TokenLegend label={t("context.reasoning")} value={breakdown.reasoningTokens} color="reasoning" />
|
||||
<TokenLegend label={t("context.other")} value={breakdown.otherTokens} color="other" />
|
||||
<div className="context-panel__total">
|
||||
<span>{t("context.total")}</span>
|
||||
<strong>{usedTokens.toLocaleString()} / {windowTokens.toLocaleString()}</strong>
|
||||
</section>
|
||||
<section className="context-panel__section context-panel__session-section">
|
||||
<SectionHeading title={t("context.sessionMetrics")} />
|
||||
<div className="context-panel__session-metrics">
|
||||
<div className="context-panel__summary-rows">
|
||||
<MiniStat label={t("status.cacheAvgLabel")} value={formatCacheHitRate(sessionCacheHit, sessionCacheMiss)} tone={cacheHitTone(sessionCacheHit, sessionCacheMiss)} />
|
||||
<MiniStat label={t("context.sessionCost")} value={sessionCostLabel} />
|
||||
<MiniStat label={t("context.time")} value={fmtDuration(elapsed, t)} />
|
||||
<MiniStat label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
|
||||
<MiniStat label={t("context.sessionTokensShort")} value={totalTokensMetric.display} title={totalTokensTitle} wide />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="context-panel__creation-grid" aria-label={t("context.overview")}>
|
||||
<MetricCard label={t("context.time")} value={fmtDuration(elapsed, t)} />
|
||||
<MetricCard label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
|
||||
<MetricCard label={t("status.cacheLabel")} value={fmtUsageCacheRate(usage)} tone="accent" />
|
||||
<MetricCard label={t("status.turnTokensLabel")} value={fmtOptionalTokens(turnTokens)} />
|
||||
<MetricCard label={t("status.turnCostLabel")} value={turnCostLabel} />
|
||||
<MetricCard label={t("status.balanceLabel")} value={balanceLabel} tone="accent" />
|
||||
</section>
|
||||
<section className="context-panel__section">
|
||||
<SectionHeading title={t("context.runtimeMetrics")} />
|
||||
<div className="context-panel__runtime-card">
|
||||
<div className="context-panel__runtime-summary">
|
||||
<RuntimeMetric label={t("context.time")} value={fmtDuration(elapsed, t)} />
|
||||
<RuntimeMetric label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
|
||||
<RuntimeMetric label={t("context.outputTokens")} value={sessionCompletionMetric.display} title={sessionCompletionMetric.exact} />
|
||||
<RuntimeMetric label={t("context.sessionTokens")} value={totalTokensMetric.display} title={totalTokensMetric.exact} />
|
||||
</div>
|
||||
<div className="context-panel__runtime-cache">
|
||||
<div className="context-panel__runtime-cache-head">
|
||||
<span>{t("context.inputCache")}</span>
|
||||
<strong>{formatCacheHitRate(sessionCacheHit, sessionCacheMiss)}</strong>
|
||||
<section className="context-panel__section context-panel__analysis">
|
||||
<SectionHeading title={t("context.usageAnalysis")}>
|
||||
{showSourceUsageRows && (
|
||||
<div className="context-panel__view-switch" role="tablist" aria-label={t("context.usageAnalysisView")}>
|
||||
<button
|
||||
type="button"
|
||||
className={`context-panel__view-tab${activeAnalysisView === "source" ? " context-panel__view-tab--active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={activeAnalysisView === "source"}
|
||||
onClick={() => setAnalysisView("source")}
|
||||
>
|
||||
{t("context.usageAnalysisSource")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`context-panel__view-tab${activeAnalysisView === "type" ? " context-panel__view-tab--active" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={activeAnalysisView === "type"}
|
||||
onClick={() => setAnalysisView("type")}
|
||||
>
|
||||
{t("context.usageAnalysisType")}
|
||||
</button>
|
||||
</div>
|
||||
<SourceSplitBar
|
||||
label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`}
|
||||
segments={[
|
||||
{ label: t("context.sourceCacheHit"), value: sessionCacheHit, tone: "hit" },
|
||||
{ label: t("context.sourceCacheMiss"), value: sessionCacheMiss, tone: "miss" },
|
||||
]}
|
||||
/>
|
||||
<div className="context-panel__runtime-cache-values">
|
||||
<SourceMetric label={t("context.sourceCacheHit")} value={sessionCacheHitMetric.display} title={sessionCacheHitMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceCacheMiss")} value={sessionCacheMissMetric.display} title={sessionCacheMissMetric.exact} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="context-panel__section">
|
||||
<SectionHeading title={t("context.costMetrics")} />
|
||||
<div className="context-panel__stats">
|
||||
<MetricCard label={t("context.cacheHit")} value={cachePctDisplay} tone="accent" />
|
||||
<MetricCard label={t("context.sessionCost")} value={formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" })} />
|
||||
</div>
|
||||
{showSourceUsageRows && (
|
||||
<div className="context-panel__source-list" aria-label={t("context.sourceBreakdown")}>
|
||||
)}
|
||||
</SectionHeading>
|
||||
{activeAnalysisView === "source" ? (
|
||||
<div className="context-panel__source-list" aria-label={t("context.sourceBreakdown")} role="tabpanel">
|
||||
<div className="context-panel__source-overview">
|
||||
<div className="context-panel__source-overview-head">
|
||||
<strong>{t("context.sourceBreakdown")}</strong>
|
||||
<span>{t("context.sourceShareTokens")}</span>
|
||||
<strong>{t("context.sourceShareTitle")}</strong>
|
||||
</div>
|
||||
<div className="context-panel__source-sharebar" aria-hidden="true">
|
||||
{sourceUsageRows.map((row) => {
|
||||
@@ -498,70 +585,51 @@ export function ContextPanel({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{sourceUsageRows.map((row) => {
|
||||
const inputMetric = formatMetricTokens(row.promptTokens, locale);
|
||||
const outputMetric = formatMetricTokens(row.completionTokens, locale);
|
||||
const hitMetric = formatMetricTokens(row.cacheHitTokens, locale);
|
||||
const missMetric = formatMetricTokens(row.cacheMissTokens, locale);
|
||||
const totalMetric = formatMetricTokens(sourceTokenTotal(row), locale);
|
||||
const cacheReported = row.cacheHitTokens + row.cacheMissTokens > 0;
|
||||
const cacheRate = cacheReported ? formatCacheHitRate(row.cacheHitTokens, row.cacheMissTokens) : t("context.cacheNotReported");
|
||||
const costLabel = formatMoneyLocalized(row.cost, row.currency, { locale, empty: "dash" });
|
||||
return (
|
||||
<div className="context-panel__source-row" key={row.source}>
|
||||
<div className="context-panel__source-head">
|
||||
<span>
|
||||
<i className={`context-panel__source-dot context-panel__source-tone--${sourceTone(row.source)}`} aria-hidden="true" />
|
||||
{sourceLabel(row.label, t)}
|
||||
</span>
|
||||
<em>{t("context.sourceRequests", { count: row.requests })}</em>
|
||||
</div>
|
||||
<div className="context-panel__source-summary">
|
||||
<SourceMetric label={t("context.total")} value={totalMetric.display} title={totalMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceCacheRate")} value={cacheRate} />
|
||||
<SourceMetric label={t("context.sourceCost")} value={costLabel} />
|
||||
</div>
|
||||
<SourceSplitBar
|
||||
label={`${t("context.sourceInput")}/${t("context.sourceOutput")}`}
|
||||
segments={[
|
||||
{ label: t("context.sourceInput"), value: row.promptTokens, tone: "input" },
|
||||
{ label: t("context.sourceOutput"), value: row.completionTokens, tone: "output" },
|
||||
]}
|
||||
/>
|
||||
{cacheReported ? (
|
||||
<SourceSplitBar
|
||||
label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`}
|
||||
segments={[
|
||||
{ label: t("context.sourceCacheHit"), value: row.cacheHitTokens, tone: "hit" },
|
||||
{ label: t("context.sourceCacheMiss"), value: row.cacheMissTokens, tone: "miss" },
|
||||
]}
|
||||
compact
|
||||
/>
|
||||
) : (
|
||||
<SourceSplitBar label={`${t("context.sourceCacheHit")}/${t("context.sourceCacheMiss")}`} segments={[]} compact />
|
||||
)}
|
||||
<details className="context-panel__source-details">
|
||||
<summary>{t("context.sourceDetails")}</summary>
|
||||
<div className="context-panel__source-metrics">
|
||||
<SourceMetric label={t("context.sourceInput")} value={inputMetric.display} title={inputMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceOutput")} value={outputMetric.display} title={outputMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceCacheHit")} value={hitMetric.display} title={hitMetric.exact} />
|
||||
<SourceMetric label={t("context.sourceCacheMiss")} value={missMetric.display} title={missMetric.exact} />
|
||||
</div>
|
||||
</details>
|
||||
{visibleSourceRows.map(renderSourceRow)}
|
||||
{hiddenSourceRows.length > 0 && (
|
||||
<details className="context-panel__source-more">
|
||||
<summary>{t("context.moreSources", { count: hiddenSourceRows.length })}</summary>
|
||||
<div className="context-panel__source-more-list">
|
||||
{hiddenSourceRows.map(renderSourceRow)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="context-panel__type-panel" aria-label={t("context.tokenBreakdown")} role="tabpanel">
|
||||
<div className="context-panel__type-overview">
|
||||
<div className="context-panel__type-overview-head">
|
||||
<strong>{t("context.tokenBreakdown")}</strong>
|
||||
</div>
|
||||
<div className="context-panel__type-sharebar" aria-hidden="true">
|
||||
{tokenTypeRows.map((row) => row.value > 0 ? (
|
||||
<span
|
||||
className={`context-panel__type-share context-panel__type-share--${row.key}`}
|
||||
key={row.key}
|
||||
style={{ width: `${(row.value / Math.max(1, tokenCompositionTotal)) * 100}%` }}
|
||||
/>
|
||||
) : null)}
|
||||
</div>
|
||||
<div className="context-panel__type-legend">
|
||||
{tokenTypeRows.map((row) => (
|
||||
<span key={row.key}>
|
||||
<i className={`context-panel__type-dot context-panel__type-dot--${row.key}`} aria-hidden="true" />
|
||||
{row.label} {formatSharePercent(row.value, tokenCompositionTotal)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<details className="context-panel__breakdown-details">
|
||||
<summary>{t("context.sourceDetails")}</summary>
|
||||
<div className="context-panel__breakdown">
|
||||
{tokenTypeRows.map((row) => (
|
||||
<TokenLegend key={row.key} label={row.label} value={row.value} color={row.key} />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="context-panel__section context-panel__section--status">
|
||||
<SectionHeading title={t("context.sessionStatus")} />
|
||||
<div className="context-panel__stats">
|
||||
<MetricCard label={t("context.health")} value={t(health.shortKey, health.vars)} tone={health.tone} />
|
||||
<MetricCard label={t("context.compaction")} value={compactPct > 0 ? `${compactPct}%` : "-"} />
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -569,11 +637,12 @@ export function ContextPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeading({ title, meta }: { title: string; meta?: string }) {
|
||||
function SectionHeading({ title, meta, children }: { title: string; meta?: string; children?: ReactNode }) {
|
||||
return (
|
||||
<header className="context-panel__section-head">
|
||||
<h3>{title}</h3>
|
||||
{meta && <span>{meta}</span>}
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -588,11 +657,14 @@ function TokenLegend({ label, value, color }: { label: string; value: number; co
|
||||
);
|
||||
}
|
||||
|
||||
function MiniStat({ label, value }: { label: string; value: string }) {
|
||||
function MiniStat({ label, value, title, tone, wide }: { label: string; value: string; title?: string; tone?: MetricTone; wide?: boolean }) {
|
||||
const toneClass = tone ? ` context-panel__mini-stat--${tone}` : "";
|
||||
const wideClass = wide ? " context-panel__mini-stat--wide" : "";
|
||||
const exactTitle = title && title !== value ? title : undefined;
|
||||
return (
|
||||
<div className="context-panel__mini-stat">
|
||||
<div className={`context-panel__mini-stat${toneClass}${wideClass}`} aria-label={exactTitle ? `${label}: ${exactTitle}` : undefined}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<strong title={exactTitle}>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -609,16 +681,6 @@ function MetricCard({ label, value, valueTitle, tone, wide }: { label: string; v
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeMetric({ label, value, title }: { label: string; value: string; title?: string }) {
|
||||
const exactTitle = title && title !== value ? title : undefined;
|
||||
return (
|
||||
<div className="context-panel__runtime-metric" aria-label={exactTitle ? `${label}: ${exactTitle}` : undefined}>
|
||||
<span>{label}</span>
|
||||
<strong title={exactTitle}>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceMetric({ label, value, title }: { label: string; value: string; title?: string }) {
|
||||
const exactTitle = title && title !== value ? title : undefined;
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,13 @@ import { app } from "../lib/bridge";
|
||||
import type { DirEntry } from "../lib/types";
|
||||
import { VirtualMenu } from "./VirtualMenu";
|
||||
|
||||
const FILE_REF_SEARCH_CACHE_TTL_MS = 5000;
|
||||
|
||||
type FileRefSearchCacheEntry = {
|
||||
entries: DirEntry[];
|
||||
cachedAt: number;
|
||||
};
|
||||
|
||||
export function dirEntrySubmitPath(entry: DirEntry, atDir: string): string {
|
||||
return entry.path || atDir + entry.name;
|
||||
}
|
||||
@@ -58,7 +65,7 @@ export function useFileReferenceMenu(text: string, cwd?: string) {
|
||||
const [active, setActive] = useState(0);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const dirCache = useRef<Record<string, DirEntry[]>>({});
|
||||
const searchCache = useRef<Record<string, DirEntry[]>>({});
|
||||
const searchCache = useRef<Record<string, FileRefSearchCacheEntry>>({});
|
||||
const prevCwdRef = useRef(cwd);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -82,15 +89,17 @@ export function useFileReferenceMenu(text: string, cwd?: string) {
|
||||
const cached = dirCache.current[atDir];
|
||||
if (cached) {
|
||||
setEntries(cached);
|
||||
return;
|
||||
} else {
|
||||
setEntries([]);
|
||||
}
|
||||
let live = true;
|
||||
app
|
||||
.ListDir(atDir)
|
||||
.then((next) => {
|
||||
const list = asArray(next);
|
||||
if (!live) return;
|
||||
dirCache.current[atDir] = list;
|
||||
if (live) setEntries(list);
|
||||
setEntries(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
@@ -105,17 +114,19 @@ export function useFileReferenceMenu(text: string, cwd?: string) {
|
||||
}
|
||||
const cached = searchCache.current[atFrag];
|
||||
if (cached) {
|
||||
setSearchEntries(cached);
|
||||
return;
|
||||
setSearchEntries(cached.entries);
|
||||
if (Date.now() - cached.cachedAt < FILE_REF_SEARCH_CACHE_TTL_MS) return;
|
||||
} else {
|
||||
setSearchEntries([]);
|
||||
}
|
||||
setSearchEntries([]);
|
||||
let live = true;
|
||||
app
|
||||
.SearchFileRefs(atFrag)
|
||||
.then((next) => {
|
||||
const list = next ?? [];
|
||||
searchCache.current[atFrag] = list;
|
||||
if (live) setSearchEntries(list);
|
||||
const list = asArray(next);
|
||||
if (!live) return;
|
||||
searchCache.current[atFrag] = { entries: list, cachedAt: Date.now() };
|
||||
setSearchEntries(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
|
||||
@@ -470,6 +470,7 @@ export function HistoryPanel({
|
||||
{!isTrash && s.current && <span className="hist-item__badge hist-item__badge--current">{tr("history.current")}</span>}
|
||||
{!isTrash && !s.current && s.open && <span className="hist-item__badge hist-item__badge--open">{tr("history.open")}</span>}
|
||||
{isTrash && <span className="hist-item__badge hist-item__badge--deleted">{tr("history.deleted")}</span>}
|
||||
{s.recovered && <span className="hist-item__badge">{tr("recovery.badge")}</span>}
|
||||
{sessionLocation(s, tr) && <span className="hist-item__scope">{sessionLocation(s, tr)}</span>}
|
||||
<span className="hist-item__metaspacer" />
|
||||
<span className="hist-item__stat">{tr(s.turns === 1 ? "history.turnOne" : "history.turnOther", { n: s.turns })}</span>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { useT } from "../lib/i18n";
|
||||
|
||||
export interface ImageViewerProps {
|
||||
open: boolean;
|
||||
/** data-URL of the image to display */
|
||||
imageUrl: string;
|
||||
/** optional filename shown at the bottom */
|
||||
imageName?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ImageViewer renders a full-size image preview in a portal overlay.
|
||||
*
|
||||
* It follows the same portal pattern as MermaidDiagram fullscreen:
|
||||
* the overlay is portaled into .chat-pane (fallback document.body) so it
|
||||
* covers the chat area without being clipped by overflow containers.
|
||||
*/
|
||||
export function ImageViewer({ open, imageUrl, imageName, onClose }: ImageViewerProps) {
|
||||
const t = useT();
|
||||
const [portalTarget, setPortalTarget] = useState<Element | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
// Resolve portal target when opening.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setVisible(false);
|
||||
setPortalTarget(null);
|
||||
return;
|
||||
}
|
||||
const target = document.querySelector(".chat-pane") ?? document.body;
|
||||
setPortalTarget(target);
|
||||
// Trigger enter animation on the next frame.
|
||||
const raf = requestAnimationFrame(() => setVisible(true));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [open]);
|
||||
|
||||
// Close on Escape.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Prevent body scroll while open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
if (!open || !portalTarget || !imageUrl) return null;
|
||||
|
||||
const overlay = (
|
||||
<div
|
||||
className={`image-viewer-backdrop${visible ? " image-viewer--enter" : ""}`}
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={imageName || t("imageViewer.title")}
|
||||
>
|
||||
<button
|
||||
className="image-viewer__close"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("imageViewer.closePreview")}
|
||||
>
|
||||
<X size={22} />
|
||||
</button>
|
||||
<div className="image-viewer__content">
|
||||
<img
|
||||
className="image-viewer__image"
|
||||
src={imageUrl}
|
||||
alt={imageName || ""}
|
||||
draggable={false}
|
||||
/>
|
||||
{imageName && <div className="image-viewer__name">{imageName}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(overlay, portalTarget);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { BrainCircuit, ChevronDown, ChevronRight, FileText, Folder, GitBranch, Image, MessageSquare, Pencil, RotateCcw, ScrollText } from "lucide-react";
|
||||
import { Markdown } from "./Markdown";
|
||||
@@ -10,6 +10,7 @@ import type { DisplayAttachment } from "../lib/attachmentDisplay";
|
||||
import { app } from "../lib/bridge";
|
||||
import { replaySubmitText } from "../lib/editReplay";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { ImageViewer } from "./ImageViewer";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
|
||||
import { displayReasoningText } from "../lib/reasoningDisplay";
|
||||
@@ -81,6 +82,31 @@ function mergeDisplayAttachments(existing: DisplayAttachment[], incoming: Displa
|
||||
return merged;
|
||||
}
|
||||
|
||||
type PastedBlockInfo = {
|
||||
label: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const PASTE_LABEL_RE = /\[(?:已粘贴文本|已貼上文字|Pasted text) #\d+ · \d+ (?:行|lines)\]/g;
|
||||
|
||||
export function parsePastedBlocks(text: string, submitText?: string): PastedBlockInfo[] {
|
||||
const labels = text.match(PASTE_LABEL_RE);
|
||||
if (!labels || labels.length === 0 || !submitText) return [];
|
||||
const unique = [...new Set(labels)];
|
||||
const blocks: PastedBlockInfo[] = [];
|
||||
for (const label of unique) {
|
||||
const beginMarker = `--- Begin ${label} ---`;
|
||||
const endMarker = `--- End ${label} ---`;
|
||||
const beginIdx = submitText.indexOf(beginMarker);
|
||||
const endIdx = submitText.indexOf(endMarker);
|
||||
if (beginIdx < 0 || endIdx <= beginIdx) continue;
|
||||
const contentStart = beginIdx + beginMarker.length;
|
||||
const content = submitText.slice(contentStart, endIdx).replace(/^\r?\n/, "");
|
||||
blocks.push({ label, content });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function MemoryCitations({ citations }: { citations?: MemoryCitation[] }) {
|
||||
const t = useT();
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
@@ -181,6 +207,65 @@ export function UserMessage({
|
||||
const [editSubmitting, setEditSubmitting] = useState(false);
|
||||
const editRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [imagePreviews, setImagePreviews] = useState<Record<string, string>>({});
|
||||
const [imageViewer, setImageViewer] = useState<{ open: boolean; url: string; name: string }>({ open: false, url: "", name: "" });
|
||||
const openImageViewer = useCallback(async (path: string, name: string) => {
|
||||
let url = imagePreviews[path];
|
||||
if (!url) {
|
||||
try {
|
||||
url = await app.AttachmentDataURL(path);
|
||||
setImagePreviews((prev) => (prev[path] ? prev : { ...prev, [path]: url }));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setImageViewer({ open: true, url, name });
|
||||
}, [imagePreviews]);
|
||||
|
||||
const closeImageViewer = useCallback(() => {
|
||||
setImageViewer((prev) => (prev.open ? { ...prev, open: false } : prev));
|
||||
}, []);
|
||||
|
||||
const pasteBlocks = useMemo(() => parsePastedBlocks(actionText, submitText), [actionText, submitText]);
|
||||
const [expandedPasteLabels, setExpandedPasteLabels] = useState<Record<string, boolean>>({});
|
||||
|
||||
type DisplaySegment =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "paste"; block: PastedBlockInfo };
|
||||
|
||||
const displaySegments = useMemo((): DisplaySegment[] => {
|
||||
if (pasteBlocks.length === 0) return [{ type: "text", content: displayText }];
|
||||
const segments: DisplaySegment[] = [];
|
||||
// Order blocks by their position in the text so cards appear inline.
|
||||
const ordered = pasteBlocks
|
||||
.map((b) => ({ block: b, pos: displayText.indexOf(b.label) }))
|
||||
.filter((x) => x.pos >= 0)
|
||||
.sort((a, b) => a.pos - b.pos);
|
||||
let remaining = displayText;
|
||||
for (const { block } of ordered) {
|
||||
const idx = remaining.indexOf(block.label);
|
||||
if (idx < 0) continue;
|
||||
// Text before the label: strip the trailing newline that separated the
|
||||
// label from the preceding line so the card sits tight against the text.
|
||||
if (idx > 0) {
|
||||
let before = remaining.slice(0, idx);
|
||||
before = before.replace(/\n$/, "");
|
||||
if (before) segments.push({ type: "text", content: before });
|
||||
}
|
||||
segments.push({ type: "paste", block });
|
||||
remaining = remaining.slice(idx + block.label.length);
|
||||
}
|
||||
// Strip the leading newline that followed the label.
|
||||
remaining = remaining.replace(/^\n/, "");
|
||||
if (remaining.trim()) segments.push({ type: "text", content: remaining });
|
||||
return segments.length > 0 ? segments : [{ type: "text", content: displayText }];
|
||||
}, [displayText, pasteBlocks]);
|
||||
|
||||
const togglePasteExpand = (label: string) => {
|
||||
setExpandedPasteLabels((prev) => ({
|
||||
...prev,
|
||||
[label]: !prev[label],
|
||||
}));
|
||||
};
|
||||
const orderedDraftAttachments = sortDisplayAttachments(draftAttachments);
|
||||
const imagePreviewKey = orderedAttachments
|
||||
.concat(orderedDraftAttachments)
|
||||
@@ -305,11 +390,12 @@ export function UserMessage({
|
||||
<ComposerContextCard
|
||||
key={attachment.path}
|
||||
variant={attachment.source === "workspace" ? "workspace" : "attachment"}
|
||||
tooltipLabel={attachment.source === "workspace" ? formatAttachmentRefForSubmit(attachment) : attachment.path}
|
||||
tooltipLabel={imagePreview ? `${t("imageViewer.clickToPreview")} — ${attachment.path}` : attachment.source === "workspace" ? formatAttachmentRefForSubmit(attachment) : attachment.path}
|
||||
removeLabel={attachment.source === "workspace" ? t("composer.removeReference") : t("composer.removeImage")}
|
||||
removeDisabled={editSubmitting}
|
||||
onRemove={() => removeDraftAttachment(attachment.path)}
|
||||
previewUrl={imagePreview}
|
||||
onImageClick={imagePreview ? () => openImageViewer(attachment.path, attachment.name) : undefined}
|
||||
imageOnly={imageOnly}
|
||||
folder={attachment.kind === "folder"}
|
||||
label={attachment.kind === "folder" ? `${attachment.name}/` : attachment.name}
|
||||
@@ -355,26 +441,78 @@ export function UserMessage({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
displayText && <div className="msg__text">{displayText}</div>
|
||||
<>
|
||||
{displaySegments.map((seg, i) => {
|
||||
if (seg.type === "text") {
|
||||
return seg.content ? <div className="msg__text" key={`s${i}`}>{seg.content}</div> : null;
|
||||
}
|
||||
const expanded = Boolean(expandedPasteLabels[seg.block.label]);
|
||||
return (
|
||||
<div className="msg-pasted" key={seg.block.label}>
|
||||
<div className="msg-pasted-block">
|
||||
<div className="msg-pasted-head">
|
||||
<FileText size={15} />
|
||||
<span className="msg-pasted-label">{seg.block.label}</span>
|
||||
<div className="msg-pasted-actions">
|
||||
<Tooltip label={t(expanded ? "msg.pastedCollapseTooltip" : "msg.pastedExpandTooltip")}>
|
||||
<button type="button" onClick={() => togglePasteExpand(seg.block.label)}>
|
||||
{expanded ? t("common.collapse") : t("composer.pastedExpand")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="msg-pasted-expanded">{seg.block.content}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{failed && <div className="msg__send-failed">{t("msg.sendFailed")}</div>}
|
||||
{orderedAttachments.length > 0 && (
|
||||
<div className="msg-attachments" aria-label={t("msg.attachments")}>
|
||||
{orderedAttachments.map((attachment, index) => (
|
||||
<div className={`msg-attachment msg-attachment--${attachment.kind}`} key={`${attachment.path}:${index}`} title={attachment.path}>
|
||||
<span className={`msg-attachment__icon msg-attachment__icon--${attachment.kind}`} aria-hidden="true">
|
||||
{attachment.kind === "image" && imagePreviews[attachment.path] ? <img src={imagePreviews[attachment.path]} alt="" draggable={false} /> : attachmentIcon(attachment.kind)}
|
||||
</span>
|
||||
<span className="msg-attachment__main">
|
||||
<span className="msg-attachment__name">{attachment.name}</span>
|
||||
<span className="msg-attachment__meta">
|
||||
{attachment.kind === "folder"
|
||||
? t("msg.folderReference")
|
||||
: `${attachment.ext || t("msg.fileAttachment")} · ${attachment.source === "workspace" ? t("msg.workspaceReference") : attachment.kind === "image" ? t("msg.imageAttachment") : t("msg.fileAttachment")}`}
|
||||
{orderedAttachments.map((attachment, index) => {
|
||||
const isImage = attachment.kind === "image";
|
||||
const el = (
|
||||
<div
|
||||
className={`msg-attachment msg-attachment--${attachment.kind}`}
|
||||
key={isImage ? undefined : `${attachment.path}:${index}`}
|
||||
title={isImage ? undefined : attachment.path}
|
||||
onClick={isImage ? () => openImageViewer(attachment.path, attachment.name) : undefined}
|
||||
role={isImage ? "button" : undefined}
|
||||
tabIndex={isImage ? 0 : undefined}
|
||||
onKeyDown={isImage ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openImageViewer(attachment.path, attachment.name); } } : undefined}
|
||||
>
|
||||
<span className={`msg-attachment__icon msg-attachment__icon--${attachment.kind}`} aria-hidden="true">
|
||||
{isImage && imagePreviews[attachment.path] ? <img src={imagePreviews[attachment.path]} alt="" draggable={false} /> : attachmentIcon(attachment.kind)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<span className="msg-attachment__main">
|
||||
<span className="msg-attachment__name">{attachment.name}</span>
|
||||
<span className="msg-attachment__meta">
|
||||
{attachment.kind === "folder"
|
||||
? t("msg.folderReference")
|
||||
: `${attachment.ext || t("msg.fileAttachment")} · ${attachment.source === "workspace" ? t("msg.workspaceReference") : attachment.kind === "image" ? t("msg.imageAttachment") : t("msg.fileAttachment")}`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
if (isImage) {
|
||||
return (
|
||||
<Tooltip key={`${attachment.path}:${index}`} label={t("imageViewer.clickToPreview")} block>
|
||||
{el}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return el;
|
||||
})}
|
||||
<ImageViewer
|
||||
open={imageViewer.open}
|
||||
imageUrl={imageViewer.url}
|
||||
imageName={imageViewer.name}
|
||||
onClose={closeImageViewer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -419,6 +557,7 @@ export function TurnActions({
|
||||
actionPending = false,
|
||||
rewindDisabled = false,
|
||||
hoverMenus = false,
|
||||
isLastTurn = false,
|
||||
}: {
|
||||
text: string;
|
||||
turn?: number;
|
||||
@@ -429,6 +568,8 @@ export function TurnActions({
|
||||
actionPending?: boolean;
|
||||
rewindDisabled?: boolean;
|
||||
hoverMenus?: boolean;
|
||||
/** true when this is the last user turn — disables "summarize after" */
|
||||
isLastTurn?: boolean;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [confirmScope, setConfirmScope] = useState<MessageActionScope | null>(null);
|
||||
@@ -439,6 +580,9 @@ export function TurnActions({
|
||||
if ((scope === "fork" || scope === "summ-from" || scope === "conversation") && !checkpoint.canConversation) {
|
||||
return t("rewind.disabledNoBoundary");
|
||||
}
|
||||
if (scope === "summ-from" && isLastTurn) {
|
||||
return t("rewind.disabledNoLater");
|
||||
}
|
||||
if (scope === "summ-upto") {
|
||||
if (!checkpoint.canConversation) return t("rewind.disabledNoBoundary");
|
||||
if ((turn ?? 0) <= 0) return t("rewind.disabledNoEarlier");
|
||||
@@ -483,9 +627,9 @@ export function TurnActions({
|
||||
}
|
||||
};
|
||||
const actionMeta = (scope: MessageActionScope): string => {
|
||||
if ((scope === "code" || scope === "both") && checkpoint?.files?.length) {
|
||||
const total = checkpoint.files.length;
|
||||
const turnCount = checkpoint.turnFileCount ?? 0;
|
||||
const total = checkpoint?.fileCount ?? checkpoint?.files?.length ?? 0;
|
||||
if ((scope === "code" || scope === "both") && total > 0) {
|
||||
const turnCount = checkpoint?.turnFileCount ?? 0;
|
||||
if (turnCount > 0 && turnCount < total) {
|
||||
return `${t("rewind.filesChanged", { count: total })} (${t("rewind.turnFiles", { count: turnCount })})`;
|
||||
}
|
||||
@@ -496,12 +640,16 @@ export function TurnActions({
|
||||
const actionTooltipLabel = (scope: MessageActionScope) => {
|
||||
const reason = actionDisabledReason(scope);
|
||||
if (reason) return <span>{reason}</span>;
|
||||
if ((scope === "code" || scope === "both") && checkpoint?.files?.length) {
|
||||
const files = checkpoint?.files ?? [];
|
||||
const total = checkpoint?.fileCount ?? files.length;
|
||||
if ((scope === "code" || scope === "both") && total > 0) {
|
||||
const hidden = Math.max(0, total - files.length);
|
||||
return (
|
||||
<div className="rewind__files-tooltip">
|
||||
{checkpoint.files.map((file) => (
|
||||
{files.map((file) => (
|
||||
<div key={file}>{file.split(/[/\\]/).pop() || file}</div>
|
||||
))}
|
||||
{hidden > 0 && <div>+{hidden}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -123,16 +123,20 @@ function topicIsActive(node: ProjectNode, activeScope?: string, activeWorkspaceR
|
||||
);
|
||||
}
|
||||
|
||||
function topicMetaLine(node: ProjectNode, t: Translator, compact = false): string {
|
||||
export function projectTreeTopicMetaLine(node: ProjectNode, t: Translator, compact = false): string {
|
||||
const parts: string[] = [];
|
||||
const turns = node.turns ?? 0;
|
||||
if (turns > 0) parts.push(t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns }));
|
||||
const activityAt = node.lastActivityAt || node.createdAt || 0;
|
||||
if (activityAt) parts.push(topicActivityLabel(activityAt, t, compact));
|
||||
if (parts.length === 0) parts.push(t("projectTree.justNow"));
|
||||
if (parts.length === 0) parts.push(t("projectTree.previously"));
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function topicUnknownTimeLabel(node: ProjectNode, t: Translator): string {
|
||||
return topicActivityAt(node) ? "" : t("projectTree.previously");
|
||||
}
|
||||
|
||||
const topicStatusLabels: Record<ProjectTopicStatus, DictKey> = {
|
||||
thinking: "projectTree.status.thinking",
|
||||
streaming: "projectTree.status.streaming",
|
||||
@@ -159,6 +163,43 @@ function topicStatusLabel(node: ProjectNode, t: Translator): string {
|
||||
return status ? t(topicStatusLabels[status]) : "";
|
||||
}
|
||||
|
||||
function topicActivityAt(node: ProjectNode): number {
|
||||
return node.lastActivityAt || node.createdAt || 0;
|
||||
}
|
||||
|
||||
export function projectTreeReadActivityKey(node: ProjectNode): string | null {
|
||||
const request = projectTreeTopicOpenRequest(node);
|
||||
if (!request?.topicId) return null;
|
||||
return [
|
||||
request.scope,
|
||||
request.workspaceRoot,
|
||||
request.topicId,
|
||||
request.sessionPath ?? "",
|
||||
].join("\u001f");
|
||||
}
|
||||
|
||||
type ProjectTreeReadActivity = Record<string, number>;
|
||||
|
||||
export function projectTreeTopicHasUnreadActivity(
|
||||
node: ProjectNode,
|
||||
readActivity: ProjectTreeReadActivity,
|
||||
activeScope?: string,
|
||||
activeWorkspaceRoot?: string,
|
||||
activeTopicId?: string,
|
||||
activeSessionPath?: string,
|
||||
): boolean {
|
||||
if (!isTopicNode(node) && !isRuntimeSessionNode(node)) return false;
|
||||
if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) return false;
|
||||
if (topicStatus(node) !== "") return false;
|
||||
const key = projectTreeReadActivityKey(node);
|
||||
const activityAt = topicActivityAt(node);
|
||||
return Boolean(key && activityAt > 0 && (readActivity[key] ?? 0) < activityAt);
|
||||
}
|
||||
|
||||
export function projectTreeShouldRenderTopicActions(isSessionNode: boolean, compactTopics: boolean, unread: boolean): boolean {
|
||||
return !isSessionNode && compactTopics && !unread;
|
||||
}
|
||||
|
||||
function topicActivityLabel(ms: number, t: Translator, compact = false): string {
|
||||
if (ms <= 0) return "";
|
||||
const delta = Date.now() - ms;
|
||||
@@ -226,6 +267,31 @@ type WorkbenchTreeSections = {
|
||||
const GLOBAL_PROJECT_ORDER_KEY = "__global__";
|
||||
const WORKBENCH_ORGANIZE_KEY = "projectTree:workbenchOrganize";
|
||||
const WORKBENCH_SORT_KEY = "projectTree:workbenchSort";
|
||||
const READ_ACTIVITY_KEY = "projectTree:readActivity";
|
||||
const READ_ACTIVITY_INIT_KEY = "projectTree:readActivityInitialized";
|
||||
|
||||
function loadReadActivity(): ProjectTreeReadActivity {
|
||||
try {
|
||||
const raw = localStorage.getItem(READ_ACTIVITY_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const out: ProjectTreeReadActivity = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveReadActivity(readActivity: ProjectTreeReadActivity) {
|
||||
try {
|
||||
localStorage.setItem(READ_ACTIVITY_KEY, JSON.stringify(readActivity));
|
||||
} catch {
|
||||
/* localStorage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
function loadWorkbenchOrganizeMode(): WorkbenchOrganizeMode {
|
||||
try {
|
||||
@@ -497,6 +563,7 @@ export function ProjectTree({
|
||||
const [workbenchHeaderMenu, setWorkbenchHeaderMenu] = useState<WorkbenchHeaderMenu>(null);
|
||||
const [workbenchOrganizeMode, setWorkbenchOrganizeMode] = useState<WorkbenchOrganizeMode>(loadWorkbenchOrganizeMode);
|
||||
const [workbenchSortMode, setWorkbenchSortMode] = useState<WorkbenchSortMode>(loadWorkbenchSortMode);
|
||||
const [readActivity, setReadActivity] = useState<ProjectTreeReadActivity>(loadReadActivity);
|
||||
const filterRef = useRef<HTMLDivElement>(null);
|
||||
const filterTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -562,6 +629,67 @@ export function ProjectTree({
|
||||
void refresh();
|
||||
}, [refresh, refreshSignal]);
|
||||
|
||||
const markNodeRead = useCallback((node: ProjectNode) => {
|
||||
const key = projectTreeReadActivityKey(node);
|
||||
const activityAt = topicActivityAt(node);
|
||||
if (!key || activityAt <= 0) return;
|
||||
setReadActivity((prev) => {
|
||||
if ((prev[key] ?? 0) >= activityAt) return prev;
|
||||
const next = { ...prev, [key]: activityAt };
|
||||
saveReadActivity(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (tree.length === 0) return;
|
||||
try {
|
||||
if (localStorage.getItem(READ_ACTIVITY_INIT_KEY)) return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const baseline: ProjectTreeReadActivity = {};
|
||||
const collectBaseline = (nodes: ProjectNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if ((isTopicNode(node) || isRuntimeSessionNode(node)) && topicStatus(node) === "") {
|
||||
const key = projectTreeReadActivityKey(node);
|
||||
const activityAt = topicActivityAt(node);
|
||||
if (key && activityAt > 0) baseline[key] = Math.max(baseline[key] ?? 0, activityAt);
|
||||
}
|
||||
collectBaseline(asArray(node.children));
|
||||
}
|
||||
};
|
||||
collectBaseline(tree);
|
||||
try {
|
||||
localStorage.setItem(READ_ACTIVITY_INIT_KEY, "1");
|
||||
} catch {
|
||||
/* localStorage unavailable */
|
||||
}
|
||||
if (Object.keys(baseline).length === 0) return;
|
||||
setReadActivity((prev) => {
|
||||
const next = { ...prev };
|
||||
let changed = false;
|
||||
for (const [key, value] of Object.entries(baseline)) {
|
||||
if ((next[key] ?? 0) >= value) continue;
|
||||
next[key] = value;
|
||||
changed = true;
|
||||
}
|
||||
if (!changed) return prev;
|
||||
saveReadActivity(next);
|
||||
return next;
|
||||
});
|
||||
}, [tree]);
|
||||
|
||||
useEffect(() => {
|
||||
const markActive = (nodes: ProjectNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) markNodeRead(node);
|
||||
markActive(asArray(node.children));
|
||||
}
|
||||
};
|
||||
markActive(tree);
|
||||
}, [activeScope, activeSessionPath, activeTopicId, activeWorkspaceRoot, markNodeRead, tree]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(WORKBENCH_ORGANIZE_KEY, workbenchOrganizeMode);
|
||||
@@ -926,8 +1054,10 @@ export function ProjectTree({
|
||||
const filtered = tree
|
||||
.map(filterNode)
|
||||
.filter((node): node is ProjectNode => node !== null);
|
||||
return compactTopics ? arrangeWorkbenchTree(filtered, workbenchOrganizeMode, workbenchSortMode) : filtered;
|
||||
}, [compactTopics, query, tree, timeFilter, workbenchOrganizeMode, workbenchSortMode]);
|
||||
if (compactTopics) return arrangeWorkbenchTree(filtered, workbenchOrganizeMode, workbenchSortMode);
|
||||
if (creationTopics) return arrangeWorkbenchTree(filtered, "project", "updated");
|
||||
return filtered;
|
||||
}, [compactTopics, creationTopics, query, tree, timeFilter, workbenchOrganizeMode, workbenchSortMode]);
|
||||
|
||||
const workbenchTreeSections = useMemo<WorkbenchTreeSections>(() => {
|
||||
if (!compactTopics) return { pinned: [], projects: visibleTree };
|
||||
@@ -1002,20 +1132,23 @@ export function ProjectTree({
|
||||
const accentStyle = projectAccentStyle(node.projectColor, scope === "global" ? "var(--project-tree-global-accent)" : undefined);
|
||||
const active = topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
|
||||
const label = (node.label || node.topicId || "Untitled").replace(/^●\s*/, "");
|
||||
const conflictCopyLabel = isSessionNode && node.recovered ? t("recovery.badge") : "";
|
||||
const activityAt = node.lastActivityAt || node.createdAt || 0;
|
||||
const sideTimeVisible = compactTopics || creationTopics;
|
||||
const timeLabel = sideTimeVisible && activityAt ? topicActivityLabel(activityAt, t, true) : "";
|
||||
const timeLabel = sideTimeVisible ? (activityAt ? topicActivityLabel(activityAt, t, true) : topicUnknownTimeLabel(node, t)) : "";
|
||||
const exactTimeLabel = sideTimeVisible && activityAt ? topicActivityDateLabel(activityAt) : "";
|
||||
const meta = topicMetaLine(node, t, compactTopics);
|
||||
const meta = projectTreeTopicMetaLine(node, t, compactTopics);
|
||||
const status = topicStatus(node);
|
||||
const statusLabel = topicStatusLabel(node, t);
|
||||
const showStatusInSide = status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job";
|
||||
const unread = projectTreeTopicHasUnreadActivity(node, readActivity, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
|
||||
const topicId = node.topicId ?? "";
|
||||
const imSource = scope === "global" && topicId ? imTopicSources[topicId] : undefined;
|
||||
const imSourceLabel = imSource?.label || "";
|
||||
const imSourceTitle = imSourceLabel ? t("msg.fromIm", { source: imSourceLabel }) : "";
|
||||
const imSourcePlatform = (imSource?.platform || "im").replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "im";
|
||||
const title = [label, imSourceTitle, statusLabel, meta, exactTimeLabel].filter(Boolean).join(" · ");
|
||||
const conflictCopyTitle = isSessionNode && node.recovered ? t("recovery.branch") : "";
|
||||
const title = [label, conflictCopyTitle, imSourceTitle, statusLabel, meta, exactTimeLabel].filter(Boolean).join(" · ");
|
||||
const topicMenuOpen = !isSessionNode && menuTopic === topicId;
|
||||
const pinned = Boolean(node.pinned);
|
||||
const pinLabel = t(pinned ? "projectTree.unpinTopic" : "projectTree.pinTopic");
|
||||
@@ -1092,7 +1225,7 @@ export function ProjectTree({
|
||||
}
|
||||
const row = (
|
||||
<div
|
||||
className={`project-tree__topic${scopeClass}${isSessionNode ? " project-tree__topic--session" : ""}${active ? " project-tree__topic--active" : ""}${node.running ? " project-tree__topic--running" : ""}${status ? ` project-tree__topic--status-${status}` : ""}${!isSessionNode && pinned ? " project-tree__topic--pinned" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}${sideTimeVisible && (timeLabel || showStatusInSide) ? " project-tree__topic--with-side" : meta ? " project-tree__topic--has-meta" : ""}${imSource ? " project-tree__topic--im-source" : ""}${shortcutIndex > 0 ? " project-tree__topic--show-shortcut" : ""}`}
|
||||
className={`project-tree__topic${scopeClass}${isSessionNode ? " project-tree__topic--session" : ""}${active ? " project-tree__topic--active" : ""}${node.running ? " project-tree__topic--running" : ""}${status ? ` project-tree__topic--status-${status}` : ""}${unread ? " project-tree__topic--unread" : ""}${!isSessionNode && pinned ? " project-tree__topic--pinned" : ""}${topicMenuOpen ? " project-tree__topic--menu-open" : ""}${sideTimeVisible && (timeLabel || showStatusInSide) ? " project-tree__topic--with-side" : meta ? " project-tree__topic--has-meta" : ""}${imSource ? " project-tree__topic--im-source" : ""}${shortcutIndex > 0 ? " project-tree__topic--show-shortcut" : ""}`}
|
||||
style={accentStyle}
|
||||
onContextMenu={isSessionNode ? undefined : openTopicMenu}
|
||||
>
|
||||
@@ -1112,6 +1245,7 @@ export function ProjectTree({
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
if (clickTimerRef.current?.timer === timer) clickTimerRef.current = null;
|
||||
markNodeRead(node);
|
||||
onOpenTopic(openRequest.scope, openRequest.workspaceRoot, openRequest.topicId, openRequest.sessionPath);
|
||||
}, 200);
|
||||
clickTimerRef.current = { ...nextClick, timer };
|
||||
@@ -1133,7 +1267,7 @@ export function ProjectTree({
|
||||
>
|
||||
<span className="project-tree__topic-copy">
|
||||
<span className="project-tree__topic-heading">
|
||||
<span className="project-tree__topic-label">{label}</span>
|
||||
<span className="project-tree__topic-label">{conflictCopyLabel ? `${label} · ${conflictCopyLabel}` : label}</span>
|
||||
{imSource && (
|
||||
<span
|
||||
className={`project-tree__topic-im project-tree__topic-im--${imSourcePlatform}`}
|
||||
@@ -1169,7 +1303,8 @@ export function ProjectTree({
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{!isSessionNode && compactTopics && (
|
||||
{unread && <span className="project-tree__topic-unread-dot" aria-hidden="true" />}
|
||||
{projectTreeShouldRenderTopicActions(isSessionNode, compactTopics, unread) && (
|
||||
<span className="project-tree__topic-actions" aria-label={t("projectTree.topicActions")}>
|
||||
<Tooltip label={pinLabel} side="top" className="project-tree__topic-action-slot">
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
|
||||
export function PromptShelf({
|
||||
className,
|
||||
cardClassName,
|
||||
titleId,
|
||||
title,
|
||||
badges,
|
||||
@@ -13,6 +15,8 @@ export function PromptShelf({
|
||||
barRef,
|
||||
role = "dialog",
|
||||
}: {
|
||||
className?: string;
|
||||
cardClassName?: string;
|
||||
titleId: string;
|
||||
title: ReactNode;
|
||||
badges?: ReactNode;
|
||||
@@ -26,10 +30,10 @@ export function PromptShelf({
|
||||
role?: "dialog" | "region";
|
||||
}) {
|
||||
return (
|
||||
<div className="prompt-shelf" aria-live="polite">
|
||||
<div className={["prompt-shelf", className ?? ""].filter(Boolean).join(" ")} aria-live="polite">
|
||||
<div
|
||||
ref={barRef}
|
||||
className="prompt-shelf__card"
|
||||
className={["prompt-shelf__card", cardClassName ?? ""].filter(Boolean).join(" ")}
|
||||
role={role}
|
||||
aria-modal={role === "dialog" ? "false" : undefined}
|
||||
aria-labelledby={titleId}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -22,9 +22,10 @@ function nowRate(u?: WireUsage): string | null {
|
||||
}
|
||||
|
||||
// avgRate is the SESSION-AGGREGATE cache-hit % — Σhit/Σ(hit+miss) across every
|
||||
// turn — the steadier, cost-oriented number that matches the legacy dashboard.
|
||||
// On a non-compacting DeepSeek session it trails nowRate (early cold-start turns
|
||||
// drag the average down); it overtakes only when compaction craters single turns.
|
||||
// turn — but scoped to the EXECUTOR agent only: the wire session counters come
|
||||
// from the main agent and exclude subagent/planner/auxiliary requests. It is
|
||||
// only the pre-first-refresh fallback; the authoritative all-sources number is
|
||||
// contextAvgRate below, so the "session average" label reports one scope.
|
||||
function avgRate(u?: WireUsage): string | null {
|
||||
if (!u) return null;
|
||||
const denom = u.sessionCacheHitTokens + u.sessionCacheMissTokens;
|
||||
@@ -32,8 +33,10 @@ function avgRate(u?: WireUsage): string | null {
|
||||
}
|
||||
|
||||
// contextAvgRate computes the session-aggregate cache-hit % from ContextInfo
|
||||
// cache tokens (loaded from persisted telemetry on session resume). Used as a
|
||||
// fallback when no live WireUsage is available yet.
|
||||
// cache tokens — the tab telemetry that accumulates ALL request sources
|
||||
// (executor, subagents, planner, auxiliary calls), refreshed at turn
|
||||
// boundaries. Preferred over avgRate: it matches the 会话费用 tooltip's
|
||||
// "includes main model, subagents and auxiliary calls" scope.
|
||||
function contextAvgRate(ctx: ContextInfo): string | null {
|
||||
const hit = ctx.cacheHitTokens ?? 0;
|
||||
const miss = ctx.cacheMissTokens ?? 0;
|
||||
@@ -186,7 +189,9 @@ export function StatusBar({
|
||||
const compactNear = pct !== null && compactPct !== null && pct >= Math.max(0, compactPct - 10);
|
||||
const compactReached = pct !== null && compactPct !== null && pct >= compactPct;
|
||||
const nowPct = nowRate(usage);
|
||||
const avgPct = avgRate(usage) ?? contextAvgRate(context);
|
||||
// All-sources telemetry first; the executor-only live counters only bridge
|
||||
// the gap before the first ContextInfo refresh of a fresh session.
|
||||
const avgPct = contextAvgRate(context) ?? avgRate(usage);
|
||||
const turnCostLabel = formatMoneyLocalized(turnCost, currency, { locale });
|
||||
const costLabel = formatMoneyLocalized(cost, currency, { locale });
|
||||
const displayWorkspacePath = (workspacePath || workspaceName || "").trim();
|
||||
|
||||
@@ -1,22 +1,39 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { Todo } from "../lib/tools";
|
||||
import { shouldOpenTodoPanelByDefault } from "../lib/todoVisibility";
|
||||
import { PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf";
|
||||
|
||||
const STORAGE_KEY = "todoPanel:open";
|
||||
const STORAGE_KEY = "todoPanel:openStates";
|
||||
const MAX_STORED_OPEN_STATES = 80;
|
||||
|
||||
function loadOpenState(): boolean {
|
||||
function loadOpenStates(): Record<string, boolean> {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
return saved === null ? true : saved === "1";
|
||||
if (!saved) return {};
|
||||
const parsed = JSON.parse(saved) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||
const states: Record<string, boolean> = {};
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (typeof value === "boolean") states[key] = value;
|
||||
}
|
||||
return states;
|
||||
} catch {
|
||||
return true;
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveOpenState(open: boolean): void {
|
||||
function loadOpenState(stateKey: string, defaultOpen: boolean): boolean {
|
||||
const states = loadOpenStates();
|
||||
return Object.prototype.hasOwnProperty.call(states, stateKey) ? states[stateKey] : defaultOpen;
|
||||
}
|
||||
|
||||
function saveOpenState(stateKey: string, open: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, open ? "1" : "0");
|
||||
const entries = Object.entries(loadOpenStates()).filter(([key]) => key !== stateKey);
|
||||
entries.push([stateKey, open]);
|
||||
const trimmed = entries.slice(-MAX_STORED_OPEN_STATES);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(Object.fromEntries(trimmed)));
|
||||
} catch {
|
||||
/* ignore quota errors */
|
||||
}
|
||||
@@ -24,23 +41,35 @@ function saveOpenState(open: boolean): void {
|
||||
|
||||
// TodoPanel is the live task list pinned just above the composer — the kernel's
|
||||
// latest todo_write call drives it, and it updates in place as the agent flips
|
||||
// items to in_progress / completed. The collapsed/expanded state is persisted
|
||||
// globally in localStorage so the user's choice sticks across sessions.
|
||||
// items to in_progress / completed. Open state follows the current todo batch:
|
||||
// new incomplete work opens by default, finished work collapses to a reviewable
|
||||
// summary, and manual expand/collapse is restored only for the same batch.
|
||||
export function TodoPanel({
|
||||
stateKey,
|
||||
todos,
|
||||
onDismiss,
|
||||
}: {
|
||||
stateKey: string;
|
||||
todos: Todo[];
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(loadOpenState);
|
||||
const currentRef = useRef<HTMLLIElement | null>(null);
|
||||
|
||||
const done = todos.filter((t) => t.status === "completed").length;
|
||||
const current = todos.find((t) => t.status === "in_progress");
|
||||
const allDone = todos.length > 0 && done === todos.length;
|
||||
const summary = current?.activeForm || current?.content || todos[todos.length - 1]?.content || "";
|
||||
const [open, setOpen] = useState(() => loadOpenState(stateKey, shouldOpenTodoPanelByDefault(todos)));
|
||||
const wasAllDoneRef = useRef(allDone);
|
||||
|
||||
useEffect(() => {
|
||||
if (allDone && !wasAllDoneRef.current) {
|
||||
saveOpenState(stateKey, false);
|
||||
setOpen(false);
|
||||
}
|
||||
wasAllDoneRef.current = allDone;
|
||||
}, [allDone, stateKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -58,7 +87,13 @@ export function TodoPanel({
|
||||
role="region"
|
||||
headerActions={
|
||||
<>
|
||||
<PromptHeaderAction onClick={() => setOpen((value) => { const next = !value; saveOpenState(next); return next; })}>
|
||||
<PromptHeaderAction
|
||||
onClick={() => setOpen((value) => {
|
||||
const next = !value;
|
||||
saveOpenState(stateKey, next);
|
||||
return next;
|
||||
})}
|
||||
>
|
||||
{open ? t("common.collapse") : t("common.expand")}
|
||||
</PromptHeaderAction>
|
||||
{allDone && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { ChevronRight, Compass } from "lucide-react";
|
||||
import { CodeViewer } from "./CodeViewer";
|
||||
import { DiffView } from "./DiffView";
|
||||
import { useT } from "../lib/i18n";
|
||||
@@ -91,7 +91,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
app.ToolResultForTab(tabId, item.id).then((d) => {
|
||||
if (!cancelled && d) setFullData(d);
|
||||
}).catch(() => {}),
|
||||
);
|
||||
).catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [open, item.id, item.dataArchived, fullData, tabId]);
|
||||
|
||||
@@ -127,7 +127,12 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
aria-expanded={hasBody ? open : undefined}
|
||||
>
|
||||
<span className="tool__label-group">
|
||||
{hasNested && <span className="tool__nested-count">⊞{nested.length}</span>}
|
||||
{hasNested && (
|
||||
<span className="tool__nested-count" aria-label={`${nested.length} nested tool calls`}>
|
||||
<Compass className="tool__nested-icon" size={14} strokeWidth={2} aria-hidden="true" />
|
||||
<span>{nested.length}</span>
|
||||
</span>
|
||||
)}
|
||||
{item.status === "error" && <span className="tool__status-icon tool__status-icon--err">✗</span>}
|
||||
{item.status === "done" && <span className="tool__status-icon tool__status-icon--ok">✓</span>}
|
||||
{item.status === "stopped" && <span className="tool__status-icon tool__status-icon--stopped">—</span>}
|
||||
|
||||
@@ -14,7 +14,8 @@ import { isReadOnlyTool } from "../lib/useController";
|
||||
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
|
||||
import { useEntranceAnimation } from "../lib/useEntranceAnimation";
|
||||
import { useScrollManager } from "../lib/useScrollManager";
|
||||
import { buildStepGroups, buildTurnGroups, compactQuestionText, createWarmLayerState, questionAnchorId, questionTurnsById, scrollVersion, warmColdPageForTurn, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination, warmUserPreview, type QuestionAnchor, type TurnGroup, type WarmLayerState } from "../lib/transcriptGrouping";
|
||||
import { buildStepGroups, buildTurnGroups, compactQuestionText, createWarmLayerState, lastQuestionTurn, questionAnchorId, questionTurnsById, scrollVersion, warmColdPageForTurn, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination, warmUserPreview, type QuestionAnchor, type TurnGroup, type WarmLayerState } from "../lib/transcriptGrouping";
|
||||
import { appendTurnActionCopyText } from "../lib/turnActionCopy";
|
||||
|
||||
type ToolItem = Extract<Item, { kind: "tool" }>;
|
||||
type AssistantItem = Extract<Item, { kind: "assistant" }>;
|
||||
@@ -314,6 +315,7 @@ export function Transcript({
|
||||
}, [openAction]);
|
||||
|
||||
const userTurn = useMemo(() => questionTurnsById(questions), [questions]);
|
||||
const lastTurn = useMemo(() => lastQuestionTurn(questions, userTurn), [questions, userTurn]);
|
||||
const checkpointsByTurn = useMemo(() => new Map(checkpoints.map((checkpoint) => [checkpoint.turn, checkpoint])), [checkpoints]);
|
||||
|
||||
// ── JumpBar integration ───────────────────────────────────────────────────
|
||||
@@ -390,6 +392,7 @@ export function Transcript({
|
||||
actionPending={actionPending}
|
||||
rewindDisabled={rewindDisabled}
|
||||
hoverMenus={actionHoverMenus}
|
||||
isLastTurn={turn === lastTurn}
|
||||
onRewind={(targetTurn, scope) => {
|
||||
onRewind?.(targetTurn, scope);
|
||||
setOpenAction(null);
|
||||
@@ -500,7 +503,7 @@ export function Transcript({
|
||||
/>,
|
||||
);
|
||||
if (!it.streaming && it.text.trim() !== "") {
|
||||
actionText = it.text;
|
||||
actionText = appendTurnActionCopyText(actionText, it.text);
|
||||
actionReady = true;
|
||||
}
|
||||
}
|
||||
@@ -573,7 +576,7 @@ export function Transcript({
|
||||
case "assistant":
|
||||
out.push(<LiveAssistantMessage key={it.id} item={it as AssistantItem} defaultExpanded={false} creationMode={creationMode} />);
|
||||
if (!it.streaming && it.text.trim() !== "") {
|
||||
actionText = it.text;
|
||||
actionText = appendTurnActionCopyText(actionText, it.text);
|
||||
actionReady = true;
|
||||
}
|
||||
break;
|
||||
@@ -593,7 +596,7 @@ export function Transcript({
|
||||
if (!running) pushTurnActions();
|
||||
}
|
||||
return out;
|
||||
}, [hotStartIdx, items, openAction, actionPending, rewindDisabled, running, onEditPrompt, onRewind, subcallsByParent, userTurn, checkpointsByTurn, displayMode, stepGroups, tabId, actionHoverMenus, creationMode]);
|
||||
}, [hotStartIdx, items, openAction, actionPending, rewindDisabled, running, onEditPrompt, onRewind, subcallsByParent, userTurn, checkpointsByTurn, displayMode, stepGroups, tabId, actionHoverMenus, creationMode, lastTurn]);
|
||||
|
||||
// ── Assemble rendered output ──────────────────────────────────────────────
|
||||
// Warm/cold zone is a separate memo'd WarmZone component so streaming tokens
|
||||
@@ -631,6 +634,7 @@ export function Transcript({
|
||||
warmSubcalls={subcallsByParent}
|
||||
warmUserTurn={userTurn}
|
||||
warmCheckpoints={checkpointsByTurn}
|
||||
warmLastTurn={lastTurn}
|
||||
warmOpenAction={openAction}
|
||||
warmActionPending={actionPending}
|
||||
warmRewindDisabled={rewindDisabled}
|
||||
@@ -686,6 +690,7 @@ const WarmZone = memo(function WarmZone({
|
||||
warmSubcalls,
|
||||
warmUserTurn,
|
||||
warmCheckpoints,
|
||||
warmLastTurn,
|
||||
warmOpenAction,
|
||||
warmActionPending,
|
||||
warmRewindDisabled,
|
||||
@@ -708,6 +713,7 @@ const WarmZone = memo(function WarmZone({
|
||||
warmSubcalls: ReadonlyMap<string, ToolItem[]>;
|
||||
warmUserTurn: ReadonlyMap<string, number>;
|
||||
warmCheckpoints: ReadonlyMap<number, CheckpointMeta>;
|
||||
warmLastTurn?: number;
|
||||
warmOpenAction: OpenTurnAction | null;
|
||||
warmActionPending: boolean;
|
||||
warmRewindDisabled: boolean;
|
||||
@@ -773,6 +779,7 @@ const WarmZone = memo(function WarmZone({
|
||||
onEdit={warmOnEdit}
|
||||
tabId={tabId}
|
||||
creationMode={creationMode}
|
||||
lastTurn={warmLastTurn}
|
||||
/>
|
||||
</WarmTurnCard>,
|
||||
);
|
||||
@@ -820,6 +827,7 @@ function WarmTurnItems({
|
||||
onEdit,
|
||||
tabId,
|
||||
creationMode = false,
|
||||
lastTurn,
|
||||
}: {
|
||||
startIdx: number;
|
||||
endIdx: number;
|
||||
@@ -836,6 +844,7 @@ function WarmTurnItems({
|
||||
onEdit?: (turn: number, displayText: string, submitText?: string) => boolean | void | Promise<boolean | void>;
|
||||
tabId?: string;
|
||||
creationMode?: boolean;
|
||||
lastTurn?: number;
|
||||
}) {
|
||||
const nodes: React.ReactNode[] = [];
|
||||
let actionText = "";
|
||||
@@ -856,6 +865,7 @@ function WarmTurnItems({
|
||||
actionPending={actionPending}
|
||||
rewindDisabled={rewindDisabled}
|
||||
hoverMenus={actionHoverMenus}
|
||||
isLastTurn={turn === lastTurn}
|
||||
onRewind={(targetTurn, scope) => {
|
||||
onRewind?.(targetTurn, scope);
|
||||
setOpenAction(null);
|
||||
@@ -926,7 +936,7 @@ function WarmTurnItems({
|
||||
case "assistant": {
|
||||
nodes.push(<AssistantMessage key={it.id} item={it} defaultExpanded={false} creationMode={creationMode} />);
|
||||
if (!it.streaming && it.text.trim() !== "") {
|
||||
actionText = it.text;
|
||||
actionText = appendTurnActionCopyText(actionText, it.text);
|
||||
actionReady = true;
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { app } from "../lib/bridge";
|
||||
import { useT } from "../lib/i18n";
|
||||
import {
|
||||
@@ -206,6 +207,7 @@ export function WorkspacePanel({
|
||||
onPreviewModeChange,
|
||||
onAddToChat,
|
||||
onRequestPanelWidth,
|
||||
onFileTreeRefresh,
|
||||
refreshKey,
|
||||
initialViewMode = "files",
|
||||
revealPathRequest,
|
||||
@@ -224,6 +226,7 @@ export function WorkspacePanel({
|
||||
onPreviewModeChange?: (active: boolean) => void;
|
||||
onAddToChat?: (text: string) => void;
|
||||
onRequestPanelWidth?: (width: number) => void;
|
||||
onFileTreeRefresh?: () => void;
|
||||
refreshKey?: number;
|
||||
initialViewMode?: "files" | "changed";
|
||||
revealPathRequest?: WorkspaceRevealRequest | null;
|
||||
@@ -275,6 +278,8 @@ export function WorkspacePanel({
|
||||
const workspaceChangesRequestIdRef = useRef(0);
|
||||
const gitHistoryRequestIdRef = useRef(0);
|
||||
const commitDetailRequestIdRef = useRef(0);
|
||||
const dirLoadGenerationRef = useRef(0);
|
||||
const dirLoadRequestIdsRef = useRef<Record<string, number>>({});
|
||||
const recentAnchorRef = useRef<HTMLButtonElement>(null);
|
||||
const openDirsRef = useRef(openDirs);
|
||||
const pendingTreeRevealPathRef = useRef<string | null>(null);
|
||||
@@ -284,8 +289,12 @@ export function WorkspacePanel({
|
||||
}, [openDirs]);
|
||||
|
||||
const loadDir = useCallback(async (dir: string) => {
|
||||
const entries = await app.ListDir(dir).catch(() => []);
|
||||
setEntriesByDir((prev) => ({ ...prev, [dir]: entries ?? [] }));
|
||||
const generation = dirLoadGenerationRef.current;
|
||||
const requestId = (dirLoadRequestIdsRef.current[dir] ?? 0) + 1;
|
||||
dirLoadRequestIdsRef.current[dir] = requestId;
|
||||
const entries = await app.ListDir(dir).catch((): DirEntry[] => []);
|
||||
if (dirLoadGenerationRef.current !== generation || dirLoadRequestIdsRef.current[dir] !== requestId) return;
|
||||
setEntriesByDir((prev) => ({ ...prev, [dir]: asArray(entries) }));
|
||||
}, []);
|
||||
|
||||
const loadGitHistory = useCallback(async () => {
|
||||
@@ -399,15 +408,15 @@ export function WorkspacePanel({
|
||||
setOpenTabs((tabs) => [...tabs.filter((tab) => tab !== path), path].slice(-WORKSPACE_MAX_PREVIEW_TABS));
|
||||
const dirs = parentDirs(path);
|
||||
setOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
|
||||
dirs.forEach((dir) => {
|
||||
if (!entriesByDir[dir]) void loadDir(dir);
|
||||
});
|
||||
dirs.forEach((dir) => void loadDir(dir));
|
||||
},
|
||||
[entriesByDir, loadDir, openTabs.length, panelWidth, selectedPath, treeVisible],
|
||||
[loadDir, openTabs.length, panelWidth, selectedPath, treeVisible],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
dirLoadGenerationRef.current += 1;
|
||||
dirLoadRequestIdsRef.current = {};
|
||||
setEntriesByDir({});
|
||||
setOpenDirs(new Set([""]));
|
||||
setSelectedPath(null);
|
||||
@@ -503,10 +512,8 @@ export function WorkspacePanel({
|
||||
setTreeMenu(null);
|
||||
const dirs = Array.from(new Set(paths.flatMap(parentDirs)));
|
||||
setOpenDirs((prev) => new Set([...Array.from(prev), ...dirs]));
|
||||
dirs.forEach((dir) => {
|
||||
if (!entriesByDir[dir]) void loadDir(dir);
|
||||
});
|
||||
}, [entriesByDir, fileListRequest, loadDir, open, scopedFilePaths, viewMode]);
|
||||
dirs.forEach((dir) => void loadDir(dir));
|
||||
}, [fileListRequest, loadDir, open, scopedFilePaths, viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || changeListRequest) return;
|
||||
@@ -649,10 +656,10 @@ export function WorkspacePanel({
|
||||
void loadWorkspaceChanges();
|
||||
return;
|
||||
}
|
||||
onFileTreeRefresh?.();
|
||||
const dirs = Array.from(openDirsRef.current);
|
||||
setEntriesByDir({});
|
||||
dirs.forEach((dir) => void loadDir(dir));
|
||||
}, [loadGitHistory, loadWorkspaceChanges, loadDir, viewMode]);
|
||||
}, [loadGitHistory, loadWorkspaceChanges, loadDir, onFileTreeRefresh, viewMode]);
|
||||
|
||||
const refreshSelected = useCallback(() => {
|
||||
if (!selectedPath) return;
|
||||
@@ -698,12 +705,12 @@ export function WorkspacePanel({
|
||||
next.delete(dir);
|
||||
} else {
|
||||
next.add(dir);
|
||||
if (!entriesByDir[dir]) void loadDir(dir);
|
||||
void loadDir(dir);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[entriesByDir, loadDir],
|
||||
[loadDir],
|
||||
);
|
||||
|
||||
const breadcrumbDirs = selectedPath ? parentDirs(selectedPath) : [""];
|
||||
@@ -1262,6 +1269,7 @@ export function WorkspacePanel({
|
||||
setFilter("");
|
||||
showTreeEvenSplit();
|
||||
setOpenDirs((prev) => new Set([...Array.from(prev), ""]));
|
||||
void loadDir("");
|
||||
}}
|
||||
>
|
||||
{shortCwd(cwd) || t("workspace.title")}
|
||||
|
||||
@@ -13,11 +13,25 @@ export function isLikelyInlineMath(math: string): boolean {
|
||||
// Unary plus/minus: +2, -x, +\alpha, - 3.14
|
||||
if (/^[+\-]\s*(?:\d+(?:\.\d+)?|[A-Za-z\\])/.test(math)) return true;
|
||||
|
||||
if (/\\[A-Za-z]+\b/.test(math)) return true;
|
||||
// LaTeX command (\alpha, \frac{x}{y}, \tfrac12, ...). Do not require a
|
||||
// word boundary after the command name: \tfrac12 / \sqrt2 / \log3 have no
|
||||
// boundary between the command letters and the trailing digit.
|
||||
if (/\\[A-Za-z]+/.test(math)) return true;
|
||||
if (/[\^_{}|]/.test(math)) return true;
|
||||
// Lone math operators are common in physics prose: "the sign of $+$",
|
||||
// "the $<$ relation".
|
||||
if (/^[+\-=<>±∓]$/.test(math)) return true;
|
||||
if (/\b(?:alpha|beta|gamma|sum|int|prod|lim|infty|sqrt|frac|sin|cos|tan|log|ln|max|min|partial|nabla|left|right)\b/.test(math)) return true;
|
||||
if (/^[A-Za-z]\s*\([^)]{1,80}\)$/.test(math)) return true;
|
||||
if (/[A-Za-z0-9)\]}]\s*[+\-*/=<>]\s*[A-Za-z0-9([{\\]/.test(math)) return true;
|
||||
// Short identifiers followed by parenthesised arguments cover both
|
||||
// function calls (f(x)) and group notation (SO(3,1), SU(2), GL(n)).
|
||||
if (/^[A-Za-z]{1,6}\s*\([^)]{1,80}\)$/.test(math)) return true;
|
||||
// Primed function call: f'(x), g''(t).
|
||||
if (/^[A-Za-z]'{1,}\s*\([^)]{1,80}\)$/.test(math)) return true;
|
||||
// Permutation cycle notation: (12), (123), (12)(34).
|
||||
if (/^(?:\(\d+\))+$/.test(math)) return true;
|
||||
// Binary operator between operands. The RHS may start with a unary sign:
|
||||
// K = -iJ, p = +\alpha, a = -b.
|
||||
if (/[A-Za-z0-9)\]}]\s*[+\-*/=<>]\s*[+\-]?\s*[A-Za-z0-9([{\\]/.test(math)) return true;
|
||||
// One-sided comparison: < B, > 0, B < — comparison against an implicit
|
||||
// operand is common in prose.
|
||||
if (/^(?:<=?|>=?|≠|≤|≥)\s*[A-Za-z0-9]|[A-Za-z0-9]\s*(?:<=?|>=?|≠|≤|≥)$/.test(math)) return true;
|
||||
@@ -25,11 +39,19 @@ export function isLikelyInlineMath(math: string): boolean {
|
||||
// \alpha, \beta. Currency/env-var usage never looks like this.
|
||||
if (/^\(?(?:[A-Za-z0-9]|\\[A-Za-z]+)(?:\s*,\s*(?:[A-Za-z0-9]|\\[A-Za-z]+)){1,10}\)?$/.test(math)) return true;
|
||||
|
||||
// Bracketed labels/indexes such as [56], [8], [N], [\mathbf{56}], [56,0^+]
|
||||
// are common in group theory and arrays. Markdown links are rejected above
|
||||
// by the "](" guard.
|
||||
if (/^\[[A-Za-z0-9^_+\-,.\\\s{}]+\]$/.test(math)) return true;
|
||||
|
||||
if (/[A-Za-z]\s+[A-Za-z]/.test(math)) return false;
|
||||
if (/^[A-Z][A-Z0-9_]{1,}$/.test(math)) return false;
|
||||
if (/^v\d+(?:\.\d+)*$/i.test(math)) return false;
|
||||
if (/^[A-Za-z]{2,}$/.test(math)) return false;
|
||||
|
||||
// Letter or Greek command followed by one or more primes: x', S', y'', \psi'.
|
||||
if (/^(?:[A-Za-z]|\\[A-Za-z]+)'{1,}$/.test(math)) return true;
|
||||
|
||||
// Single letter (a-z, A-Z). Uppercase single letters (S, A, G, …) are
|
||||
// common math names (sets, algebras, groups) and $X$ is essentially
|
||||
// never written for the English word I/A by hand.
|
||||
|
||||
@@ -67,30 +67,13 @@ function normalizeMathText(s: string): string {
|
||||
const escapedDollarToken = unusedEscapedDollarToken(r);
|
||||
r = r.split("\\$").join(escapedDollarToken);
|
||||
|
||||
// Step 3: repair inline $$. CommonMark requires a blank line before
|
||||
// block math; without it remark-math parses the opening $$ as an
|
||||
// empty math node and the formula leaks out as literal text.
|
||||
// Digits are excluded so `c^2$$` inside a formula is left alone.
|
||||
// Comma is included so `…D(q^2),$$` (closing $$ on the same line as
|
||||
// the trailing content) is repaired: micromark-extension-math only
|
||||
// recognises a closing $$ fence at the start of a new line, so
|
||||
// without this repair it consumes the rest of the document as math
|
||||
// and katex fails on the stray $ in the next paragraph. Closing
|
||||
// braces } and { are included too — a model that writes
|
||||
// `\end{array}$$` or `\frac{a}{b}$$` on one line has the same
|
||||
// micromark-fence problem, and these are the most common ends of
|
||||
// LaTeX math content.
|
||||
r = r.replace(/([A-Za-z\)\]\>\.。!?,{}])\$\$/g, (_m, prev) => prev + "\n\n$$");
|
||||
|
||||
// Orphan opening $$ (model forgot the closing $$) is left alone:
|
||||
// converting it to a lone $ would interact badly with the $…$
|
||||
// matcher below and wrap whole prose paragraphs in math spans. The
|
||||
// right fix is upstream — a post-generation lint or stricter prompt.
|
||||
|
||||
// Step 4: $$…$$ → display placeholders. KaTeX-specific normalisation
|
||||
// runs here so |→\vert (with \| protected) and \text{} escapes both
|
||||
// apply to display math.
|
||||
r = r.replace(/\$\$([\s\S]*?)\$\$/g, (_m, m) => `${DM}${latexNormalizeForKatex(m)}${DM}`);
|
||||
// Step 3+4: normalise display $$ blocks and run KaTeX-specific
|
||||
// normalisation on each recognised display source. remark-math requires
|
||||
// opening and closing $$ to sit on their own lines; LLMs often emit
|
||||
// single-line displays, opening fences glued to prose, and adjacent display
|
||||
// blocks separated by prose. A line parser avoids the old cross-block regex
|
||||
// capture that swallowed prose between two display blocks.
|
||||
r = normaliseDisplayBlocks(r);
|
||||
|
||||
// Step 5: $\cmd{...}$ pairs where the body may contain a stray $
|
||||
// (e.g. $\text{price is $5}$). Recognised first so the inner $ doesn't
|
||||
@@ -246,3 +229,95 @@ function lineEnd(s: string, start: number): number {
|
||||
const end = s.indexOf("\n", start);
|
||||
return end < 0 ? s.length : end;
|
||||
}
|
||||
|
||||
function normaliseDisplayBlocks(s: string): string {
|
||||
const lines = s.split("\n");
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const $$idx = line.indexOf("$$");
|
||||
|
||||
if ($$idx >= 0 && line.indexOf("$$", $$idx + 2) >= 0
|
||||
&& !($$idx > 0 && /\d/.test(line[$$idx - 1]))) {
|
||||
const m = line.match(/^(.*?)\$\$([^\n]*?)\$\$(.*)$/);
|
||||
if (m) {
|
||||
const quote = blockquotePrefix(m[1]);
|
||||
pushDisplayBefore(out, m[1]);
|
||||
out.push(DM);
|
||||
out.push(latexNormalizeForKatex(m[2]));
|
||||
out.push(DM);
|
||||
if (m[3]) out.push(normaliseDisplayBlocks(quote ? quote + m[3].trimStart() : m[3]));
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($$idx >= 0 && line.indexOf("$$", $$idx + 2) < 0
|
||||
&& !($$idx > 0 && /\d/.test(line[$$idx - 1]))) {
|
||||
const before = line.slice(0, $$idx);
|
||||
const afterOpen = line.slice($$idx + 2);
|
||||
const quote = blockquotePrefix(before);
|
||||
|
||||
const formulaLines: string[] = [];
|
||||
if (afterOpen) formulaLines.push(afterOpen);
|
||||
|
||||
let j = i + 1;
|
||||
let found = false;
|
||||
while (j < lines.length) {
|
||||
const rawLine = lines[j];
|
||||
const fLine = quote ? stripBlockquotePrefix(rawLine, quote) : rawLine;
|
||||
const closeIdx = fLine.indexOf("$$");
|
||||
if (closeIdx >= 0 && fLine.indexOf("$$", closeIdx + 2) < 0) {
|
||||
const formulaPart = fLine.slice(0, closeIdx);
|
||||
const afterClose = fLine.slice(closeIdx + 2);
|
||||
pushDisplayBefore(out, before);
|
||||
if (formulaPart) formulaLines.push(formulaPart);
|
||||
const formula = formulaLines.join("\n");
|
||||
out.push(DM);
|
||||
out.push(latexNormalizeForKatex(formula));
|
||||
out.push(DM);
|
||||
if (afterClose) out.push(quote ? quote + afterClose.trimStart() : afterClose);
|
||||
i = j + 1;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
formulaLines.push(fLine);
|
||||
j += 1;
|
||||
}
|
||||
|
||||
if (found) continue;
|
||||
pushDisplayBefore(out, before);
|
||||
out.push("$$");
|
||||
if (afterOpen) out.push(afterOpen);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(line);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function pushDisplayBefore(out: string[], before: string): void {
|
||||
if (!before.trim()) return;
|
||||
out.push(before);
|
||||
}
|
||||
|
||||
function blockquotePrefix(before: string): string | null {
|
||||
const m = before.match(/^(\s*>\s*)/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function stripBlockquotePrefix(line: string, prefix: string): string {
|
||||
if (line.startsWith(prefix)) return line.slice(prefix.length);
|
||||
const marker = prefix.trimEnd();
|
||||
if (marker && line.startsWith(marker)) {
|
||||
const rest = line.slice(marker.length);
|
||||
return rest.startsWith(" ") ? rest.slice(1) : rest;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
@@ -190,9 +190,10 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
|
||||
enabled: true,
|
||||
approvalMode: "yolo",
|
||||
newConversationEachRun: false,
|
||||
notifyChannels: false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, [open, startNew]);
|
||||
|
||||
@@ -209,17 +210,22 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(async () => {
|
||||
const id = await heartbeatGenerateID();
|
||||
setEditing({
|
||||
id,
|
||||
title: "",
|
||||
prompt: "",
|
||||
interval: "30m",
|
||||
enabled: true,
|
||||
approvalMode: "yolo",
|
||||
newConversationEachRun: false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
try {
|
||||
const id = await heartbeatGenerateID();
|
||||
setEditing({
|
||||
id,
|
||||
title: "",
|
||||
prompt: "",
|
||||
interval: "30m",
|
||||
enabled: true,
|
||||
approvalMode: "yolo",
|
||||
newConversationEachRun: false,
|
||||
notifyChannels: false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleEdit = useCallback((task: HeartbeatTask) => {
|
||||
@@ -236,8 +242,12 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
|
||||
|
||||
const handleTrigger = useCallback(
|
||||
async (id: string) => {
|
||||
await heartbeatTriggerNow(id);
|
||||
void loadTasks();
|
||||
try {
|
||||
await heartbeatTriggerNow(id);
|
||||
void loadTasks();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[loadTasks],
|
||||
);
|
||||
@@ -952,37 +962,63 @@ function TaskEditor({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Approval Mode */}
|
||||
<div className="heartbeat-editor__field">
|
||||
<label>{t("heartbeat.fieldApprovalMode")}</label>
|
||||
<div className="set-seg" style={{ alignSelf: "flex-start" }}>
|
||||
<button
|
||||
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "ask" ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "ask" }))}
|
||||
title={t("heartbeat.approvalModeAskTooltip")}
|
||||
>
|
||||
{t("heartbeat.approvalModeAsk")}
|
||||
</button>
|
||||
<button
|
||||
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "auto" ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "auto" }))}
|
||||
title={t("heartbeat.approvalModeAutoTooltip")}
|
||||
>
|
||||
{t("heartbeat.approvalModeAuto")}
|
||||
</button>
|
||||
<button
|
||||
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "yolo" ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "yolo" }))}
|
||||
title={t("heartbeat.approvalModeYoloTooltip")}
|
||||
>
|
||||
{t("heartbeat.approvalModeYolo")}
|
||||
</button>
|
||||
{/* Approval Mode + Push to bot (side by side) */}
|
||||
<div style={{ display: "flex", gap: "16px", flexWrap: "wrap" }}>
|
||||
<div className="heartbeat-editor__field" style={{ flex: "1 1 45%", minWidth: "200px" }}>
|
||||
<label>{t("heartbeat.fieldApprovalMode")}</label>
|
||||
<div className="set-seg" style={{ alignSelf: "flex-start" }}>
|
||||
<button
|
||||
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "ask" ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "ask" }))}
|
||||
title={t("heartbeat.approvalModeAskTooltip")}
|
||||
>
|
||||
{t("heartbeat.approvalModeAsk")}
|
||||
</button>
|
||||
<button
|
||||
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "auto" ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "auto" }))}
|
||||
title={t("heartbeat.approvalModeAutoTooltip")}
|
||||
>
|
||||
{t("heartbeat.approvalModeAuto")}
|
||||
</button>
|
||||
<button
|
||||
className={`set-seg__btn${normalizeMode(draft.approvalMode) === "yolo" ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, approvalMode: "yolo" }))}
|
||||
title={t("heartbeat.approvalModeYoloTooltip")}
|
||||
>
|
||||
{t("heartbeat.approvalModeYolo")}
|
||||
</button>
|
||||
</div>
|
||||
<span className="heartbeat-editor__mode-hint">
|
||||
{normalizeMode(draft.approvalMode) === "yolo" ? t("heartbeat.approvalModeYoloHint") :
|
||||
normalizeMode(draft.approvalMode) === "auto" ? t("heartbeat.approvalModeAutoHint") :
|
||||
t("heartbeat.approvalModeAskHint")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Push to bot channels */}
|
||||
<div className="heartbeat-editor__field" style={{ flex: "1 1 45%", minWidth: "200px", textAlign: "left" }}>
|
||||
<label>{t("heartbeat.notifyChannels")} <span className="heartbeat-editor__optional">{t("heartbeat.optional")}</span></label>
|
||||
<div className="set-seg" style={{ alignSelf: "flex-start" }}>
|
||||
<button
|
||||
className={`set-seg__btn${draft.notifyChannels === true ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, notifyChannels: true }))}
|
||||
>
|
||||
{t("heartbeat.notifyChannelsOn")}
|
||||
</button>
|
||||
<button
|
||||
className={`set-seg__btn${draft.notifyChannels !== true ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => setDraft((prev) => ({ ...prev, notifyChannels: false }))}
|
||||
>
|
||||
{t("heartbeat.notifyChannelsOff")}
|
||||
</button>
|
||||
</div>
|
||||
<span className="heartbeat-editor__mode-hint">
|
||||
{draft.notifyChannels === true
|
||||
? t("heartbeat.notifyChannelsOnHint")
|
||||
: t("heartbeat.notifyChannelsOffHint")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="heartbeat-editor__mode-hint">
|
||||
{normalizeMode(draft.approvalMode) === "yolo" ? t("heartbeat.approvalModeYoloHint") :
|
||||
normalizeMode(draft.approvalMode) === "auto" ? t("heartbeat.approvalModeAutoHint") :
|
||||
t("heartbeat.approvalModeAskHint")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* New conversation per run */}
|
||||
|
||||
@@ -15,4 +15,5 @@ export interface HeartbeatTask {
|
||||
approvalMode?: "ask" | "auto" | "yolo"; // empty defaults to "yolo"
|
||||
timeWindowStart?: string; // "HH:MM" — interval tasks only run after this time
|
||||
timeWindowEnd?: string; // "HH:MM" — interval tasks only run before this time
|
||||
notifyChannels?: boolean; // true = push to bot channels; false/nil = skip
|
||||
}
|
||||
@@ -16,6 +16,9 @@ import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems } from "./statusBarIt
|
||||
import { modeHasAutoApproveTools, modeWithAutoApproveTools, modeWithPlan, normalizeCollaborationMode, normalizeMode, normalizeTokenMode, normalizeToolApprovalMode } from "./types";
|
||||
|
||||
import type {
|
||||
AutoResearchFindingView,
|
||||
AutoResearchEvidenceView,
|
||||
AutoResearchStatusView,
|
||||
BalanceInfo,
|
||||
BotConnectionDiagnostic,
|
||||
BotInstallPollResult,
|
||||
@@ -45,13 +48,18 @@ import type {
|
||||
Mode,
|
||||
ModelInfo,
|
||||
NetworkView,
|
||||
PluginInstallOptions,
|
||||
PluginView,
|
||||
ProjectNode,
|
||||
PromptHistoryEntry,
|
||||
PromptHistoryResult,
|
||||
ProviderPresetView,
|
||||
ProviderView,
|
||||
QuestionAnswer,
|
||||
ServerView,
|
||||
SessionMeta,
|
||||
SessionRecoveryFailedEvent,
|
||||
SessionRecoveryEvent,
|
||||
SettingsView,
|
||||
SkillsSettingsView,
|
||||
SkillRootView,
|
||||
@@ -115,6 +123,10 @@ interface DesktopWindowState {
|
||||
// to AppBindings, then run `pnpm typecheck` to verify.
|
||||
export interface AppBindings {
|
||||
Platform(): Promise<string>;
|
||||
MinimiseMainWindow(): Promise<void>;
|
||||
ToggleMaximiseMainWindow(): Promise<void>;
|
||||
IsMainWindowMaximised(): Promise<boolean>;
|
||||
CloseMainWindow(): Promise<void>;
|
||||
// ── Heartbeat ──
|
||||
HeartbeatListTasks(): Promise<unknown>;
|
||||
HeartbeatReloadTasks(): Promise<unknown>;
|
||||
@@ -190,10 +202,23 @@ export interface AppBindings {
|
||||
ToolResultForTab(tabID: string, toolID: string): Promise<{ args: string; output: string } | null>;
|
||||
Meta(): Promise<Meta>;
|
||||
MetaForTab(tabID: string): Promise<Meta>;
|
||||
AutoResearchCurrent(): Promise<AutoResearchStatusView>;
|
||||
AutoResearchStatus(tabID: string): Promise<AutoResearchStatusView>;
|
||||
AutoResearchList(tabID: string): Promise<AutoResearchStatusView[]>;
|
||||
AutoResearchFindings(tabID: string, limit: number): Promise<AutoResearchFindingView[]>;
|
||||
AutoResearchOpenTask(tabID: string): Promise<void>;
|
||||
AutoResearchRecordEvidence(tabID: string, criterionID: string, input: AutoResearchEvidenceView): Promise<void>;
|
||||
Commands(): Promise<CommandInfo[]>;
|
||||
Capabilities(): Promise<CapabilitiesView>;
|
||||
MCPServers(): Promise<ServerView[]>;
|
||||
SkillsSettings(): Promise<SkillsSettingsView>;
|
||||
Plugins(): Promise<PluginView[]>;
|
||||
PlanPluginInstall(source: string, options: PluginInstallOptions): Promise<string>;
|
||||
InstallPlugin(source: string, options: PluginInstallOptions): Promise<string>;
|
||||
RemovePlugin(name: string): Promise<void>;
|
||||
SetPluginEnabled(name: string, enabled: boolean): Promise<void>;
|
||||
UpdatePlugin(name: string): Promise<string>;
|
||||
PluginDoctor(name: string): Promise<PluginView>;
|
||||
AddMCPServer(input: MCPServerInput): Promise<number>;
|
||||
UpdateMCPServer(name: string, input: MCPServerInput): Promise<void>;
|
||||
RemoveMCPServer(name: string): Promise<void>;
|
||||
@@ -203,6 +228,7 @@ export interface AppBindings {
|
||||
TrustMCPServerTools(name: string, toolNames: string[]): Promise<void>;
|
||||
UntrustMCPServerTool(name: string, toolName: string): Promise<void>;
|
||||
PickSkillFolder(): Promise<string>;
|
||||
PickPluginFolder(): Promise<string>;
|
||||
AddSkillPath(path: string): Promise<void>;
|
||||
RemoveSkillPath(path: string): Promise<void>;
|
||||
RefreshSkills(): Promise<void>;
|
||||
@@ -264,18 +290,24 @@ export interface AppBindings {
|
||||
SetPlannerModel(ref: string): Promise<void>;
|
||||
SetSubagentModel(ref: string): Promise<void>;
|
||||
SetSubagentEffort(level: string): Promise<void>;
|
||||
SetMaxSubagentDepth(depth: number): Promise<void>;
|
||||
SetAutoPlan(mode: string): Promise<void>;
|
||||
SetDefaultToolApprovalMode(mode: string): Promise<void>;
|
||||
SaveProvider(p: ProviderView): Promise<void>;
|
||||
SaveProviderWithKey(p: ProviderView, key: string): Promise<string>;
|
||||
AddOfficialProviderAccess(kind: string, key: string): Promise<string>;
|
||||
AddProviderPresetAccess(id: string, key: string): Promise<string>;
|
||||
ResetProviderPresetAccess(id: string): Promise<void>;
|
||||
FetchProviderModels(p: ProviderView): Promise<string[]>;
|
||||
DeleteProvider(name: string): Promise<void>;
|
||||
RemoveProviderAccess(name: string): Promise<void>;
|
||||
SaveProviderKey(apiKeyEnv: string, value: string): Promise<string>;
|
||||
SetProviderKey(apiKeyEnv: string, value: string): Promise<string>;
|
||||
ClearProviderKey(apiKeyEnv: string): Promise<void>;
|
||||
SetPermissionMode(mode: string): Promise<void>;
|
||||
AddPermissionRule(list: string, rule: string): Promise<void>;
|
||||
RemovePermissionRule(list: string, rule: string): Promise<void>;
|
||||
ReloadSettings(): Promise<void>;
|
||||
SetSandbox(bash: string, network: boolean, workspaceRoot: string, allowWrite: string[], shell: string): Promise<void>;
|
||||
SetNetwork(n: NetworkView): Promise<void>;
|
||||
SetBotSettings(b: BotSettingsView): Promise<void>;
|
||||
@@ -294,6 +326,9 @@ export interface AppBindings {
|
||||
SetDesktopLanguage(lang: string): Promise<void>;
|
||||
SetDesktopAppearance(theme: string, style: string): Promise<void>;
|
||||
SetDesktopLayoutStyle(style: string): Promise<void>;
|
||||
SetDesktopZoomFactor(factor: number): Promise<void>;
|
||||
GetDesktopZoomFactor(): Promise<number>;
|
||||
RestartApplication(): Promise<void>;
|
||||
SetDesktopCheckUpdates(enabled: boolean): Promise<void>;
|
||||
SetDesktopTelemetry(enabled: boolean): Promise<void>;
|
||||
SetDesktopMetrics(enabled: boolean): Promise<void>;
|
||||
@@ -394,6 +429,8 @@ const EVENT_CHANNEL = "agent:event";
|
||||
const RECENT_NATIVE_FILE_DRAG_MS = 2000;
|
||||
const WAILS_NON_FILE_DRAG_MESSAGE = "additional File object is not a file on the disk";
|
||||
const UNCAUGHT_ERROR_PREFIX_RE = /^Uncaught(?:\s+\(in promise\))?(?:\s+\w*Error)?:\s*/i;
|
||||
const WAILS_IPC_CONNECTING_RE = /Failed to execute 'send' on 'WebSocket': Still in CONNECTING state/i;
|
||||
const WAILS_IPC_NULL_SEND_RE = /Cannot read properties of null \(reading 'send'\)/i;
|
||||
|
||||
// Resolve the Wails binding at CALL time, not module-load time: in dev the Wails
|
||||
// runtime can inject window.go AFTER this module first evaluates, so snapshotting
|
||||
@@ -452,6 +489,11 @@ export function isWailsNonFileDragErrorEvent(
|
||||
return event.error != null && isWailsNonFileDragError(event.message, recentNativeFileDrag);
|
||||
}
|
||||
|
||||
export function isTransientWailsIPCError(err: unknown): boolean {
|
||||
const msg = errorMessage(err).trim().replace(UNCAUGHT_ERROR_PREFIX_RE, "");
|
||||
return WAILS_IPC_CONNECTING_RE.test(msg) || WAILS_IPC_NULL_SEND_RE.test(msg);
|
||||
}
|
||||
|
||||
function dataTransferLooksLikeFileDrag(dt: DataTransfer | null): boolean {
|
||||
if (!dt) return false;
|
||||
if (dt.files?.length > 0) return true;
|
||||
@@ -472,12 +514,12 @@ export function installWailsNonFileDragErrorSuppression(): () => void {
|
||||
};
|
||||
const hasRecentNativeFileDrag = () => Date.now() - lastNativeFileDragAt <= RECENT_NATIVE_FILE_DRAG_MS;
|
||||
const suppressNonFileDragError = (e: ErrorEvent) => {
|
||||
if (isWailsNonFileDragErrorEvent(e, hasRecentNativeFileDrag())) {
|
||||
if (isWailsNonFileDragErrorEvent(e, hasRecentNativeFileDrag()) || isTransientWailsIPCError(e.error ?? e.message)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
const suppressNonFileDragRejection = (e: PromiseRejectionEvent) => {
|
||||
if (isWailsNonFileDragError(e.reason, hasRecentNativeFileDrag())) {
|
||||
if (isWailsNonFileDragError(e.reason, hasRecentNativeFileDrag()) || isTransientWailsIPCError(e.reason)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
@@ -548,23 +590,38 @@ export function onProjectTreeChanged(cb: () => void): () => void {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
export function onSessionRecovered(cb: (payload: SessionRecoveryEvent) => void): () => void {
|
||||
if (realApp() && typeof window !== "undefined" && window.runtime) {
|
||||
return window.runtime.EventsOn("session:recovered", (payload?: unknown) => cb((payload ?? {}) as SessionRecoveryEvent));
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
export function onSessionRecoveryFailed(cb: (payload: SessionRecoveryFailedEvent) => void): () => void {
|
||||
if (realApp() && typeof window !== "undefined" && window.runtime) {
|
||||
return window.runtime.EventsOn("session:recovery-failed", (payload?: unknown) => cb((payload ?? {}) as SessionRecoveryFailedEvent));
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
// app proxies each call to the live binding (or the dev mock only when truly
|
||||
// outside the shell), so a late-injected window.go is picked up transparently.
|
||||
function bridgeBreadcrumb(method: string): string {
|
||||
if (method === "ReportCrash") return "";
|
||||
if (/^(Submit|SubmitDisplay|RunShell|Steer|Cancel|Approve|AnswerQuestion|ReplayPendingPrompts)/.test(method))
|
||||
return `turn ${method}`;
|
||||
if (/^(SetModel|SetEffort|SetTokenMode|SetDefaultModel|SetPlannerModel|SetSubagentModel|SetSubagentEffort)/.test(method))
|
||||
if (/^(SetModel|SetEffort|SetTokenMode|SetDefaultModel|SetPlannerModel|SetSubagentModel|SetSubagentEffort|SetMaxSubagentDepth)/.test(method))
|
||||
return `model ${method}`;
|
||||
if (/^(SetDesktop|SetCloseBehavior|SetDisplayMode|SetStatusBar|SetExpandThinking|SetAutoPlan|SetDefaultToolApprovalMode|SetMemoryCompilerEnabled|SetReasoningLanguage)/.test(method))
|
||||
return `settings ${method}`;
|
||||
if (/^(SaveProvider|AddOfficialProviderAccess|RemoveProviderAccess|DeleteProvider|SetProviderKey|ClearProviderKey|FetchProviderModels|ConnectKey)/.test(method))
|
||||
if (/^(SaveProvider|AddOfficialProviderAccess|AddProviderPresetAccess|ResetProviderPresetAccess|RemoveProviderAccess|DeleteProvider|SaveProviderKey|SetProviderKey|ClearProviderKey|FetchProviderModels|ConnectKey)/.test(method))
|
||||
return `provider ${method}`;
|
||||
if (/^(CheckUpdate|DownloadUpdate|InstallUpdate|ApplyUpdate|OpenDownloadPage)/.test(method)) return `update ${method}`;
|
||||
if (/^(AddMCPServer|UpdateMCPServer|RemoveMCPServer|ReconnectMCPServer|ClearMCPServerAuthentication|TrustMCPServerTool|TrustMCPServerTools|UntrustMCPServerTool|SetMCPServer)/.test(method))
|
||||
return `mcp ${method}`;
|
||||
if (/^(AddSkillPath|RemoveSkillPath|RefreshSkills|SetSkillEnabled|AcceptSkillSuggestion)/.test(method))
|
||||
return `skill ${method}`;
|
||||
if (/^(MinimiseMainWindow|ToggleMaximiseMainWindow|IsMainWindowMaximised|CloseMainWindow)$/.test(method)) return `window ${method}`;
|
||||
if (/^(OpenProjectTab|OpenGlobalTab|OpenTopicSession|EnsureBlankTab|ActivateTopic|EnsureBlankSurface|SetActiveTab|CloseTab|ReorderTabs|CreateTopic|RenameTopic|DeleteTopic|TrashTopic|RenameProject|RemoveWorkspace|SwitchWorkspace|PickWorkspace)/.test(method))
|
||||
return `nav ${method}`;
|
||||
return "";
|
||||
@@ -662,18 +719,162 @@ function browserPlatformOverride(): "darwin" | "windows" | "linux" | "" {
|
||||
return value === "darwin" || value === "windows" || value === "linux" ? value : "";
|
||||
}
|
||||
|
||||
function mockScenario(): "demo" | "fresh" | "running" {
|
||||
function mockScenario(): "demo" | "fresh" | "running" | "guidance" | "sandbox_escape" {
|
||||
if (typeof window === "undefined") return "demo";
|
||||
const value = new URLSearchParams(window.location.search).get("mock")?.trim().toLowerCase();
|
||||
if (value === "fresh" || value === "empty" || value === "first-run") return "fresh";
|
||||
if (value === "guidance" || value === "guide" || value === "steer") return "guidance";
|
||||
if (value === "running" || value === "busy" || value === "streaming") return "running";
|
||||
if (value === "sandbox_escape" || value === "sandbox-escape" || value === "sandboxescape") return "sandbox_escape";
|
||||
return "demo";
|
||||
}
|
||||
|
||||
type MockProviderPresetTemplate = {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
keyEnv: string;
|
||||
provider: ProviderView;
|
||||
};
|
||||
|
||||
function mockProviderTemplate(p: Pick<ProviderView, "name" | "kind" | "baseUrl" | "models" | "default" | "apiKeyEnv"> & Partial<ProviderView>): ProviderView {
|
||||
return {
|
||||
name: p.name,
|
||||
builtIn: false,
|
||||
added: true,
|
||||
kind: p.kind,
|
||||
baseUrl: p.baseUrl,
|
||||
modelsUrl: p.modelsUrl ?? "",
|
||||
models: p.models,
|
||||
visionModels: p.visionModels ?? [],
|
||||
visionModelsConfigured: Boolean(p.visionModelsConfigured ?? ((p.visionModels ?? []).length > 0)),
|
||||
default: p.default,
|
||||
apiKeyEnv: p.apiKeyEnv,
|
||||
headers: p.headers,
|
||||
extraBody: p.extraBody,
|
||||
authHeader: p.authHeader,
|
||||
keySet: Boolean(p.keySet),
|
||||
balanceUrl: p.balanceUrl ?? "",
|
||||
contextWindow: p.contextWindow ?? 0,
|
||||
reasoningProtocol: p.reasoningProtocol ?? "",
|
||||
thinking: p.thinking ?? "",
|
||||
supportedEfforts: p.supportedEfforts ?? [],
|
||||
defaultEffort: p.defaultEffort ?? "",
|
||||
modelOverrides: p.modelOverrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockPreset(id: string, label: string, description: string, keyEnv: string, provider: ProviderView): MockProviderPresetTemplate {
|
||||
return { id, label, description, keyEnv, provider };
|
||||
}
|
||||
|
||||
const mockKimiAPIModels = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"];
|
||||
const mockLongCatModels = ["LongCat-2.0"];
|
||||
const mockMiMoV25Models = ["mimo-v2.5-pro", "mimo-v2.5"];
|
||||
const mockMiniMaxModels = ["MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.7-highspeed"];
|
||||
const mockGLMAPIModels = ["glm-5.2", "glm-5.1", "glm-5", "glm-5-turbo", "glm-5v-turbo", "glm-4.7", "glm-4.7-flash", "glm-4.7-flashx", "glm-4.6", "glm-4.5", "glm-4.5-air", "glm-4.5-flash"];
|
||||
const mockGLMCodingModels = ["glm-5.2", "glm-5.1", "glm-5", "glm-4.7"];
|
||||
const mockGLMAnthropicModels = ["glm-5.2[1m]", "glm-5.2", "glm-5.1", "glm-5", "glm-4.7", "glm-4.5-air"];
|
||||
const mockQwenAPIModels = ["qwen3.7-plus", "qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus", "qwen3-max-2026-01-23", "qwen3-coder-next", "qwen3-coder-plus", "MiniMax-M2.5", "glm-5", "glm-4.7", "kimi-k2.5"];
|
||||
const mockQwenPlanModels = ["qwen3.7-plus", "qwen3.6-plus", "kimi-k2.5", "glm-5", "MiniMax-M2.5", "qwen3.5-plus", "qwen3-max-2026-01-23", "qwen3-coder-next", "qwen3-coder-plus", "glm-4.7"];
|
||||
const mockQwenPlanVisionModels = ["qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "kimi-k2.5"];
|
||||
const mockStepFunModels = ["step-3.7-flash", "step-3.5-flash", "step-3.5-flash-2603"];
|
||||
const mockOpenCodeGoModels = ["glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "deepseek-v4-pro", "deepseek-v4-flash", "mimo-v2.5-pro", "mimo-v2.5"];
|
||||
const mockOpenCodeGoAnthropicModels = ["qwen3.7-plus", "qwen3.7-max", "qwen3.6-plus", "minimax-m3", "minimax-m2.7", "minimax-m2.5"];
|
||||
const mockOpenCodeZenAnthropicModels = ["claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5", "qwen3.6-plus", "qwen3.5-plus", "qwen3.6-plus-free"];
|
||||
const mockNovitaModels = ["zai-org/glm-5.2", "moonshotai/kimi-k2.7-code", "minimax/minimax-m3", "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", "qwen/qwen3.7-max", "qwen/qwen3.6-plus", "zai-org/glm-5v-turbo"];
|
||||
const mockGMIModels = ["zai-org/GLM-5.2-FP8", "deepseek-ai/DeepSeek-V4-Pro", "deepseek-ai/DeepSeek-V4-Flash", "moonshotai/Kimi-K2.7-Code", "anthropic/claude-sonnet-4.6", "openai/gpt-5.5"];
|
||||
const mockVercelModels = ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4.8", "openai/gpt-5.4", "openai/gpt-5.4-pro", "moonshotai/kimi-k2.7-code", "zai/glm-5.2", "deepseek/deepseek-v4-pro"];
|
||||
const mockOllamaCloudModels = ["glm-5.2", "kimi-k2.7-code", "deepseek-v4-pro", "deepseek-v4-flash", "minimax-m3", "nemotron-3-nano:30b", "qwen3-coder-next"];
|
||||
|
||||
const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
|
||||
mockPreset("longcat-openai", "LongCat OpenAI", "LongCat Platform OpenAI-compatible endpoint for LongCat-2.0.", "LONGCAT_API_KEY", mockProviderTemplate({ name: "longcat-openai", kind: "openai", baseUrl: "https://api.longcat.chat/openai/v1", modelsUrl: "https://api.longcat.chat/openai/v1/models", models: mockLongCatModels, default: "LongCat-2.0", apiKeyEnv: "LONGCAT_API_KEY", contextWindow: 131072, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("longcat-anthropic", "LongCat Anthropic", "LongCat Platform Anthropic-compatible Messages endpoint for LongCat-2.0.", "LONGCAT_API_KEY", mockProviderTemplate({ name: "longcat-anthropic", kind: "anthropic", baseUrl: "https://api.longcat.chat/anthropic", modelsUrl: "https://api.longcat.chat/anthropic/v1/models", models: mockLongCatModels, default: "LongCat-2.0", apiKeyEnv: "LONGCAT_API_KEY", authHeader: true, contextWindow: 131072, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("kimi-cn", "Kimi CN API", "Moonshot Kimi China OpenAI-compatible API.", "KIMI_API_KEY", mockProviderTemplate({ name: "kimi-cn", kind: "openai", baseUrl: "https://api.moonshot.cn/v1", models: mockKimiAPIModels, visionModels: mockKimiAPIModels, default: "kimi-k2.7-code", apiKeyEnv: "KIMI_API_KEY", balanceUrl: "https://api.moonshot.cn/v1/users/me/balance", contextWindow: 262144, reasoningProtocol: "none" })),
|
||||
mockPreset("kimi-global", "Kimi Global API", "Moonshot Kimi international OpenAI-compatible API.", "MOONSHOT_API_KEY", mockProviderTemplate({ name: "kimi-global", kind: "openai", baseUrl: "https://api.moonshot.ai/v1", models: mockKimiAPIModels, visionModels: mockKimiAPIModels, default: "kimi-k2.7-code", apiKeyEnv: "MOONSHOT_API_KEY", balanceUrl: "https://api.moonshot.ai/v1/users/me/balance", contextWindow: 262144, reasoningProtocol: "none" })),
|
||||
mockPreset("kimi-coding-plan", "Kimi Coding Plan", "Kimi Coding Plan via its dedicated Anthropic-compatible endpoint.", "KIMI_CODING_API_KEY", mockProviderTemplate({ name: "kimi-coding-plan", kind: "anthropic", baseUrl: "https://api.kimi.com/coding/", models: ["kimi-for-coding"], visionModels: ["kimi-for-coding"], default: "kimi-for-coding", apiKeyEnv: "KIMI_CODING_API_KEY", headers: { "User-Agent": "claude-code/0.1.0" }, thinking: "adaptive", contextWindow: 262144 })),
|
||||
mockPreset("mimo-api", "MiMo API", "Xiaomi MiMo direct API with text and vision-capable models.", "MIMO_API_KEY", mockProviderTemplate({ name: "mimo-api", kind: "openai", baseUrl: "https://api.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-anthropic", "MiMo Anthropic", "Xiaomi MiMo direct Anthropic-compatible endpoint.", "MIMO_API_KEY", mockProviderTemplate({ name: "mimo-anthropic", kind: "anthropic", baseUrl: "https://api.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-cn", "MiMo Token Plan CN", "Xiaomi MiMo token-plan China endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-cn", kind: "openai", baseUrl: "https://token-plan-cn.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-cn-anthropic", "MiMo Token Plan CN Anthropic", "Xiaomi MiMo token-plan China Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://token-plan-cn.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-sgp", "MiMo Token Plan SGP", "Xiaomi MiMo token-plan Singapore endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-sgp", kind: "openai", baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-sgp-anthropic", "MiMo Token Plan SGP Anthropic", "Xiaomi MiMo token-plan Singapore Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-sgp-anthropic", kind: "anthropic", baseUrl: "https://token-plan-sgp.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-ams", "MiMo Token Plan AMS", "Xiaomi MiMo token-plan Amsterdam endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-ams", kind: "openai", baseUrl: "https://token-plan-ams.xiaomimimo.com/v1", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", contextWindow: 1048576 })),
|
||||
mockPreset("mimo-token-plan-ams-anthropic", "MiMo Token Plan AMS Anthropic", "Xiaomi MiMo token-plan Amsterdam Anthropic-compatible endpoint.", "MIMO_TOKEN_PLAN_API_KEY", mockProviderTemplate({ name: "mimo-token-plan-ams-anthropic", kind: "anthropic", baseUrl: "https://token-plan-ams.xiaomimimo.com/anthropic", models: mockMiMoV25Models, visionModels: ["mimo-v2.5"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_TOKEN_PLAN_API_KEY", thinking: "adaptive", contextWindow: 1048576 })),
|
||||
mockPreset("minimax-cn-api", "MiniMax CN API", "MiniMax China OpenAI-compatible M-series API endpoint.", "MINIMAX_API_KEY", mockProviderTemplate({ name: "minimax-cn-api", kind: "openai", baseUrl: "https://api.minimaxi.com/v1", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_API_KEY", extraBody: { reasoning_split: true }, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
|
||||
mockPreset("minimax-global-api", "MiniMax Global API", "MiniMax international OpenAI-compatible M-series API endpoint.", "MINIMAX_API_KEY", mockProviderTemplate({ name: "minimax-global-api", kind: "openai", baseUrl: "https://api.minimax.io/v1", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_API_KEY", extraBody: { reasoning_split: true }, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
|
||||
mockPreset("minimax-cn-anthropic", "MiniMax CN Anthropic", "MiniMax China Anthropic-compatible M-series endpoint.", "MINIMAX_PLAN_API_KEY", mockProviderTemplate({ name: "minimax-cn-anthropic", kind: "anthropic", baseUrl: "https://api.minimaxi.com/anthropic", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_PLAN_API_KEY", authHeader: true, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
|
||||
mockPreset("minimax-global-anthropic", "MiniMax Global Anthropic", "MiniMax international Anthropic-compatible endpoint with Bearer auth.", "MINIMAX_API_KEY", mockProviderTemplate({ name: "minimax-global-anthropic", kind: "anthropic", baseUrl: "https://api.minimax.io/anthropic", models: mockMiniMaxModels, visionModels: ["MiniMax-M3"], default: "MiniMax-M3", apiKeyEnv: "MINIMAX_API_KEY", authHeader: true, contextWindow: 1048576, thinking: "adaptive", supportedEfforts: ["disabled", "adaptive"], defaultEffort: "adaptive" })),
|
||||
mockPreset("glm-cn", "GLM CN API", "Zhipu GLM China OpenAI-compatible API with thinking controls.", "GLM_API_KEY", mockProviderTemplate({ name: "glm-cn", kind: "openai", baseUrl: "https://open.bigmodel.cn/api/paas/v4", models: mockGLMAPIModels, visionModels: ["glm-5v-turbo"], default: "glm-5.2", apiKeyEnv: "GLM_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("zai-global", "Z.AI Global API", "Z.AI international OpenAI-compatible GLM API.", "ZAI_API_KEY", mockProviderTemplate({ name: "zai-global", kind: "openai", baseUrl: "https://api.z.ai/api/paas/v4", models: mockGLMAPIModels, visionModels: ["glm-5v-turbo"], default: "glm-5.2", apiKeyEnv: "ZAI_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("glm-coding-plan-cn", "GLM Coding Plan CN", "Zhipu GLM China coding-plan endpoint.", "GLM_PLAN_API_KEY", mockProviderTemplate({ name: "glm-coding-plan-cn", kind: "openai", baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4", models: mockGLMCodingModels, default: "glm-5.2", apiKeyEnv: "GLM_PLAN_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("glm-coding-plan-cn-anthropic", "GLM Coding Plan CN Anthropic", "Zhipu GLM China coding-plan Anthropic-compatible endpoint.", "GLM_PLAN_API_KEY", mockProviderTemplate({ name: "glm-coding-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://open.bigmodel.cn/api/anthropic", models: mockGLMAnthropicModels, default: "glm-5.2[1m]", apiKeyEnv: "GLM_PLAN_API_KEY", authHeader: true, thinking: "adaptive", contextWindow: 1000000 })),
|
||||
mockPreset("zai-coding-plan-global", "Z.AI Coding Plan Global", "Z.AI international coding-plan endpoint.", "ZAI_CODING_API_KEY", mockProviderTemplate({ name: "zai-coding-plan-global", kind: "openai", baseUrl: "https://api.z.ai/api/coding/paas/v4", models: mockGLMCodingModels, default: "glm-5.2", apiKeyEnv: "ZAI_CODING_API_KEY", contextWindow: 1000000, thinking: "enabled", supportedEfforts: ["enabled", "disabled"], defaultEffort: "enabled" })),
|
||||
mockPreset("zai-coding-plan-global-anthropic", "Z.AI Coding Plan Global Anthropic", "Z.AI international coding-plan Anthropic-compatible endpoint.", "ZAI_CODING_API_KEY", mockProviderTemplate({ name: "zai-coding-plan-global-anthropic", kind: "anthropic", baseUrl: "https://api.z.ai/api/anthropic", models: mockGLMAnthropicModels, default: "glm-5.2[1m]", apiKeyEnv: "ZAI_CODING_API_KEY", authHeader: true, thinking: "adaptive", contextWindow: 1000000 })),
|
||||
mockPreset("opencode-go", "OpenCode Go", "OpenCode Go relay with per-model capability overrides.", "OPENCODE_GO_API_KEY", mockProviderTemplate({ name: "opencode-go", kind: "openai", baseUrl: "https://opencode.ai/zen/go/v1", models: mockOpenCodeGoModels, default: "glm-5.2", apiKeyEnv: "OPENCODE_GO_API_KEY", contextWindow: 128000 })),
|
||||
mockPreset("opencode-go-anthropic", "OpenCode Go Anthropic", "OpenCode Go subscription Anthropic-compatible route for Qwen and MiniMax models.", "OPENCODE_GO_API_KEY", mockProviderTemplate({ name: "opencode-go-anthropic", kind: "anthropic", baseUrl: "https://opencode.ai/zen/go", models: mockOpenCodeGoAnthropicModels, visionModels: ["qwen3.7-plus", "qwen3.6-plus"], default: "qwen3.7-plus", apiKeyEnv: "OPENCODE_GO_API_KEY", thinking: "adaptive", contextWindow: 262144 })),
|
||||
mockPreset("opencode-zen-anthropic", "OpenCode Zen Anthropic", "OpenCode Zen Anthropic-compatible route for Claude and Qwen models.", "OPENCODE_API_KEY", mockProviderTemplate({ name: "opencode-zen-anthropic", kind: "anthropic", baseUrl: "https://opencode.ai/zen", models: mockOpenCodeZenAnthropicModels, visionModels: ["claude-sonnet-4-6", "claude-opus-4-8", "claude-haiku-4-5"], default: "claude-sonnet-4-6", apiKeyEnv: "OPENCODE_API_KEY", contextWindow: 262144 })),
|
||||
mockPreset("qwen-cn", "Qwen CN API", "Alibaba DashScope China standard OpenAI-compatible endpoint.", "QWEN_API_KEY", mockProviderTemplate({ name: "qwen-cn", kind: "openai", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", models: mockQwenAPIModels, visionModels: ["qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "kimi-k2.5"], default: "qwen3.7-plus", apiKeyEnv: "QWEN_API_KEY" })),
|
||||
mockPreset("qwen-global", "Qwen Global API", "Alibaba DashScope international standard OpenAI-compatible endpoint.", "QWEN_API_KEY", mockProviderTemplate({ name: "qwen-global", kind: "openai", baseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", models: mockQwenAPIModels, visionModels: ["qwen3.7-plus", "qwen3.6-plus", "qwen3.5-plus", "kimi-k2.5"], default: "qwen3.7-plus", apiKeyEnv: "QWEN_API_KEY" })),
|
||||
mockPreset("qwen-coding-plan-cn", "Qwen Coding Plan CN", "Alibaba Cloud Qwen Coding Plan China endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-cn", kind: "openai", baseUrl: "https://coding.dashscope.aliyuncs.com/v1", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY" })),
|
||||
mockPreset("qwen-coding-plan-cn-anthropic", "Qwen Coding Plan CN Anthropic", "Alibaba Cloud Qwen Coding Plan China Anthropic-compatible endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-cn-anthropic", kind: "anthropic", baseUrl: "https://coding.dashscope.aliyuncs.com/apps/anthropic", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY", thinking: "adaptive" })),
|
||||
mockPreset("qwen-coding-plan-global", "Qwen Coding Plan Global", "Alibaba Cloud Qwen Coding Plan international endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-global", kind: "openai", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY" })),
|
||||
mockPreset("qwen-coding-plan-global-anthropic", "Qwen Coding Plan Global Anthropic", "Alibaba Cloud Qwen Coding Plan international Anthropic-compatible endpoint.", "QWEN_CODING_API_KEY", mockProviderTemplate({ name: "qwen-coding-plan-global-anthropic", kind: "anthropic", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic", models: mockQwenPlanModels, visionModels: mockQwenPlanVisionModels, default: "qwen3.7-plus", apiKeyEnv: "QWEN_CODING_API_KEY", thinking: "adaptive" })),
|
||||
mockPreset("stepfun", "StepFun", "StepFun coding-plan OpenAI-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun", kind: "openai", baseUrl: "https://api.stepfun.com/step_plan/v1", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
|
||||
mockPreset("stepfun-anthropic", "StepFun Anthropic", "StepFun coding-plan Anthropic-compatible endpoint.", "STEPFUN_API_KEY", mockProviderTemplate({ name: "stepfun-anthropic", kind: "anthropic", baseUrl: "https://api.stepfun.com/step_plan", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", thinking: "adaptive", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
|
||||
mockPreset("novita", "NovitaAI", "NovitaAI OpenAI-compatible multi-model gateway.", "NOVITA_API_KEY", mockProviderTemplate({ name: "novita", kind: "openai", baseUrl: "https://api.novita.ai/openai/v1", models: mockNovitaModels, default: "zai-org/glm-5.2", apiKeyEnv: "NOVITA_API_KEY" })),
|
||||
mockPreset("gmi", "GMI Cloud", "GMI Cloud direct multi-model OpenAI-compatible gateway.", "GMI_API_KEY", mockProviderTemplate({ name: "gmi", kind: "openai", baseUrl: "https://api.gmi-serving.com/v1", models: mockGMIModels, default: "zai-org/GLM-5.2-FP8", apiKeyEnv: "GMI_API_KEY", headers: { "User-Agent": "Reasonix" } })),
|
||||
mockPreset("vercel-ai-gateway", "Vercel AI Gateway", "Vercel AI Gateway via Anthropic-compatible Messages API.", "AI_GATEWAY_API_KEY", mockProviderTemplate({ name: "vercel-ai-gateway", kind: "anthropic", baseUrl: "https://ai-gateway.vercel.sh", models: mockVercelModels, visionModels: ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-4.8", "openai/gpt-5.4", "openai/gpt-5.4-pro", "moonshotai/kimi-k2.7-code"], default: "anthropic/claude-sonnet-4.6", apiKeyEnv: "AI_GATEWAY_API_KEY", authHeader: true, contextWindow: 1000000 })),
|
||||
mockPreset("huggingface", "HuggingFace Router", "HuggingFace Inference Router OpenAI-compatible endpoint.", "HF_TOKEN", mockProviderTemplate({ name: "huggingface", kind: "openai", baseUrl: "https://router.huggingface.co/v1", models: ["zai-org/GLM-5.2", "deepseek-ai/DeepSeek-V3.2", "Qwen/Qwen3.5-72B-Instruct"], default: "zai-org/GLM-5.2", apiKeyEnv: "HF_TOKEN" })),
|
||||
mockPreset("nvidia", "NVIDIA NIM", "NVIDIA NIM OpenAI-compatible accelerated inference endpoint.", "NVIDIA_API_KEY", mockProviderTemplate({ name: "nvidia", kind: "openai", baseUrl: "https://integrate.api.nvidia.com/v1", models: ["nvidia/nemotron-3-nano-30b-a3b", "nvidia/nemotron-3-super-120b-a12b", "nvidia/nemotron-3-ultra-550b-a55b", "deepseek-ai/deepseek-v4-pro", "qwen/qwen3.5-397b-a17b"], default: "nvidia/nemotron-3-nano-30b-a3b", apiKeyEnv: "NVIDIA_API_KEY" })),
|
||||
mockPreset("kilocode", "KiloCode", "Kilo Code gateway OpenAI-compatible endpoint.", "KILOCODE_API_KEY", mockProviderTemplate({ name: "kilocode", kind: "openai", baseUrl: "https://api.kilo.ai/api/gateway", models: ["kilo/auto"], default: "kilo/auto", apiKeyEnv: "KILOCODE_API_KEY" })),
|
||||
mockPreset("ollama-cloud", "Ollama Cloud", "Hosted Ollama Cloud OpenAI-compatible endpoint with max reasoning effort.", "OLLAMA_API_KEY", mockProviderTemplate({ name: "ollama-cloud", kind: "openai", baseUrl: "https://ollama.com/v1", models: mockOllamaCloudModels, default: "glm-5.2", apiKeyEnv: "OLLAMA_API_KEY" })),
|
||||
];
|
||||
|
||||
function mockProviderPresetViews(): ProviderPresetView[] {
|
||||
return [...mockProviderPresetTemplates].sort((a, b) => mockProviderPresetDisplayRank(a.id) - mockProviderPresetDisplayRank(b.id)).map((template) => ({
|
||||
id: template.id,
|
||||
label: template.label,
|
||||
description: template.description,
|
||||
keyEnv: template.keyEnv,
|
||||
providerNames: [template.provider.name],
|
||||
models: [...template.provider.models],
|
||||
added: false,
|
||||
status: "available",
|
||||
statusProviderNames: [],
|
||||
keySet: false,
|
||||
requiresKey: true,
|
||||
configured: false,
|
||||
}));
|
||||
}
|
||||
|
||||
function mockProviderPresetDisplayRank(id: string): number {
|
||||
if (id === "glm-cn" || id === "zai-global" || id.startsWith("glm-coding-plan-") || id.startsWith("zai-coding-plan-")) return 0;
|
||||
if (id.startsWith("longcat-")) return 1;
|
||||
if (id.startsWith("kimi-")) return 2;
|
||||
if (id.startsWith("minimax-")) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
function cloneMockProviderTemplate(id: string, key: string): ProviderView | undefined {
|
||||
const template = mockProviderPresetTemplates.find((candidate) => candidate.id === id);
|
||||
if (!template) return undefined;
|
||||
return {
|
||||
...JSON.parse(JSON.stringify(template.provider)) as ProviderView,
|
||||
keySet: Boolean(key.trim()),
|
||||
};
|
||||
}
|
||||
|
||||
const mockPreviewImageDataURL =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='120' viewBox='0 0 160 120'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%23f97316'/%3E%3Cstop offset='1' stop-color='%232563eb'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='160' height='120' rx='14' fill='url(%23g)'/%3E%3Ccircle cx='44' cy='38' r='16' fill='%23fff7ed' opacity='.9'/%3E%3Cpath d='M18 96 62 58l24 22 18-16 38 32z' fill='%23ffffff' opacity='.9'/%3E%3C/svg%3E";
|
||||
|
||||
function makeMockApp(): AppBindings {
|
||||
const scenario = mockScenario();
|
||||
const freshMock = scenario === "fresh";
|
||||
const runningMock = scenario === "running";
|
||||
const guidanceMock = scenario === "guidance";
|
||||
const runningMock = scenario === "running" || guidanceMock;
|
||||
const sandboxEscapeMock = scenario === "sandbox_escape";
|
||||
const mockAttachmentDataURLs = new Map<string, string>();
|
||||
let cancelled = false;
|
||||
let pendingAskPreview = false;
|
||||
let pendingApprovalPreview = false;
|
||||
@@ -681,6 +882,7 @@ function makeMockApp(): AppBindings {
|
||||
let cwd = freshMock ? globalWorkspaceRoot : "~/projects/joyquant-db"; // mutable so PickWorkspace is visible in dev
|
||||
let workspaces = freshMock ? [] : ["~/projects/joyquant-db", "~/projects/joyquant-sys", "~/projects/reasonix", "~/projects/blade"];
|
||||
let mockEffort = "auto";
|
||||
let mockDesktopZoomFactor = 1.0;
|
||||
const day = 86_400_000;
|
||||
const t0 = Date.now();
|
||||
// Mutable so MCP add/remove/retry are observable in browser dev.
|
||||
@@ -762,6 +964,7 @@ function makeMockApp(): AppBindings {
|
||||
],
|
||||
},
|
||||
];
|
||||
let capPlugins: PluginView[] = [];
|
||||
const mockSwitchWorkspace = async (path: string) => {
|
||||
cwd = path || "~";
|
||||
workspaces = [cwd, ...workspaces.filter((p) => p !== cwd)].slice(0, 12);
|
||||
@@ -844,37 +1047,64 @@ function makeMockApp(): AppBindings {
|
||||
subagentEffort: "",
|
||||
autoPlan: "off",
|
||||
providers: [
|
||||
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
|
||||
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
|
||||
],
|
||||
officialProviders: [
|
||||
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
|
||||
{ name: "deepseek", builtIn: true, added: false, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
|
||||
],
|
||||
providerPresets: mockProviderPresetViews(),
|
||||
permissions: { mode: "ask", allow: ["ls", "read_file"], ask: [], deny: ["Bash(rm:*)"] },
|
||||
sandbox: { bash: "enforce", network: true, workspaceRoot: "", allowWrite: [], shell: "auto" },
|
||||
sandbox: { bash: "enforce", network: true, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: cwd, effectiveWriteRoots: [cwd], shell: "auto" },
|
||||
network: {
|
||||
proxyMode: "auto",
|
||||
proxyUrl: "",
|
||||
noProxy: "",
|
||||
proxy: { type: "socks5", server: "127.0.0.1", port: 7890, username: "", password: "" },
|
||||
},
|
||||
agent: { temperature: 0.2, maxSteps: 0, plannerMaxSteps: 0, systemPrompt: "You are Reasonix, a coding agent.", coldResumePrune: true, reasoningLanguage: "auto" },
|
||||
agent: { temperature: 0.2, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, systemPrompt: "You are Reasonix, a coding agent.", coldResumePrune: true, reasoningLanguage: "auto" },
|
||||
bot: {
|
||||
enabled: !freshMock,
|
||||
model: "",
|
||||
toolApprovalMode: "ask",
|
||||
maxSteps: 25,
|
||||
debounceMs: 1500,
|
||||
queueMode: "steer",
|
||||
queueCap: 20,
|
||||
queueDrop: "summarize",
|
||||
ignoreSelfMessages: true,
|
||||
selfUserIds: {
|
||||
qq: [],
|
||||
feishu: [],
|
||||
weixin: [],
|
||||
},
|
||||
control: {
|
||||
enabled: false,
|
||||
addr: "127.0.0.1:37913",
|
||||
tokenEnv: "REASONIX_BOT_CONTROL_TOKEN",
|
||||
},
|
||||
pairing: {
|
||||
enabled: true,
|
||||
requestTtlMinutes: 60,
|
||||
maxPendingPerPlatform: 3,
|
||||
},
|
||||
routes: [],
|
||||
allowlist: {
|
||||
enabled: true,
|
||||
allowAll: false,
|
||||
qqUsers: [],
|
||||
feishuUsers: freshMock ? [] : ["ou_mock_user_001"],
|
||||
weixinUsers: freshMock ? [] : ["wxid_mock_user_001"],
|
||||
qqApprovers: [],
|
||||
feishuApprovers: [],
|
||||
weixinApprovers: [],
|
||||
qqAdmins: [],
|
||||
feishuAdmins: [],
|
||||
weixinAdmins: [],
|
||||
qqGroups: [],
|
||||
feishuGroups: [],
|
||||
weixinGroups: [],
|
||||
},
|
||||
qq: { enabled: false, appId: "", appSecretEnv: "QQ_BOT_APP_SECRET", secretSet: false, sandbox: false },
|
||||
qq: { enabled: false, appId: "", appSecretEnv: "QQ_BOT_APP_SECRET", secretSet: false, sandbox: false, model: "", toolApprovalMode: "ask", workspaceRoot: "", access: { enabled: true, allowAll: false, pairingEnabled: true, users: [], groups: [], approvers: [], admins: [] } },
|
||||
feishu: {
|
||||
enabled: false,
|
||||
domain: "feishu",
|
||||
@@ -901,10 +1131,11 @@ function makeMockApp(): AppBindings {
|
||||
label: "kun",
|
||||
enabled: true,
|
||||
status: "connected",
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
credential: {
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
access: { enabled: true, allowAll: false, pairingEnabled: true, users: ["ou_mock_user_001"], groups: [], approvers: [], admins: [] },
|
||||
credential: {
|
||||
appId: "cli_mock_lark",
|
||||
appSecretEnv: "FEISHU_BOT_APP_SECRET",
|
||||
accountId: "",
|
||||
@@ -935,10 +1166,11 @@ function makeMockApp(): AppBindings {
|
||||
label: "kun",
|
||||
enabled: true,
|
||||
status: "connected",
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
credential: {
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
access: { enabled: true, allowAll: false, pairingEnabled: true, users: ["wxid_mock_user_001"], groups: [], approvers: [], admins: [] },
|
||||
credential: {
|
||||
appId: "",
|
||||
appSecretEnv: "",
|
||||
accountId: "default",
|
||||
@@ -978,7 +1210,7 @@ function makeMockApp(): AppBindings {
|
||||
metrics: true,
|
||||
memoryCompilerEnabled: true,
|
||||
configPath: "~/projects/reasonix/reasonix.toml",
|
||||
providerKinds: ["openai"],
|
||||
providerKinds: ["openai", "anthropic"],
|
||||
autoApproveTools: false,
|
||||
bypass: false,
|
||||
};
|
||||
@@ -1362,7 +1594,7 @@ function makeMockApp(): AppBindings {
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: true,
|
||||
active: !guidanceMock,
|
||||
cwd: "~/projects/joyquant-db",
|
||||
},
|
||||
{
|
||||
@@ -1382,7 +1614,7 @@ function makeMockApp(): AppBindings {
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: false,
|
||||
active: guidanceMock,
|
||||
cwd: "~/projects/joyquant-sys",
|
||||
},
|
||||
{
|
||||
@@ -1404,6 +1636,23 @@ function makeMockApp(): AppBindings {
|
||||
cwd: "~/projects/joyquant-db",
|
||||
},
|
||||
];
|
||||
if (sandboxEscapeMock) {
|
||||
window.setTimeout(() => {
|
||||
if (pendingApprovalPreview) return;
|
||||
pendingApprovalPreview = true;
|
||||
emitMockTurnStarted();
|
||||
emit({ kind: "reasoning", text: t("mock.sandboxEscapeReasoning") });
|
||||
emit({
|
||||
kind: "approval_request",
|
||||
approval: {
|
||||
id: "mock-sandbox-escape-preview",
|
||||
tool: "sandbox_escape",
|
||||
subject: t("mock.sandboxEscapeSubject"),
|
||||
reason: t("mock.sandboxEscapeReason"),
|
||||
},
|
||||
});
|
||||
}, 800);
|
||||
}
|
||||
const mockModelCatalog = [
|
||||
{ ref: "deepseek/deepseek-v4-flash", provider: "deepseek", model: "deepseek-v4-flash" },
|
||||
{ ref: "deepseek/deepseek-v4-pro", provider: "deepseek", model: "deepseek-v4-pro" },
|
||||
@@ -1434,6 +1683,18 @@ function makeMockApp(): AppBindings {
|
||||
}
|
||||
};
|
||||
return {
|
||||
async MinimiseMainWindow() {
|
||||
console.info("mock MinimiseMainWindow");
|
||||
},
|
||||
async ToggleMaximiseMainWindow() {
|
||||
console.info("mock ToggleMaximiseMainWindow");
|
||||
},
|
||||
async IsMainWindowMaximised() {
|
||||
return false;
|
||||
},
|
||||
async CloseMainWindow() {
|
||||
console.info("mock CloseMainWindow");
|
||||
},
|
||||
async Platform() {
|
||||
const override = browserPlatformOverride();
|
||||
if (override) return override;
|
||||
@@ -1488,6 +1749,26 @@ function makeMockApp(): AppBindings {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
trimmedInput === "/sandbox-escape-preview" ||
|
||||
trimmedInput === "sandbox escape preview" ||
|
||||
trimmedInput === "sandbox_escape preview" ||
|
||||
trimmedInput === "sandbox escape预览"
|
||||
) {
|
||||
pendingApprovalPreview = true;
|
||||
await delay(250);
|
||||
if (cancelled) return;
|
||||
emit({
|
||||
kind: "approval_request",
|
||||
approval: {
|
||||
id: "mock-sandbox-escape-preview",
|
||||
tool: "sandbox_escape",
|
||||
subject: t("mock.sandboxEscapeSubject"),
|
||||
reason: t("mock.sandboxEscapeReason"),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
trimmedInput === "/plan-approve-preview" ||
|
||||
trimmedInput === "plan approve preview" ||
|
||||
@@ -1602,6 +1883,67 @@ function makeMockApp(): AppBindings {
|
||||
emitMockTurnDone();
|
||||
return;
|
||||
}
|
||||
if (trimmedInput === "/nested-preview" || trimmedInput === "nested preview" || trimmedInput === "嵌套预览") {
|
||||
const parentId = "mock-nested-explore";
|
||||
await delay(180);
|
||||
if (cancelled) return;
|
||||
emit({
|
||||
kind: "reasoning",
|
||||
text: "我先快速探索相关文件,再整理这个工具行的视觉层级。",
|
||||
});
|
||||
emit({
|
||||
kind: "message",
|
||||
text: "",
|
||||
reasoning: "我先快速探索相关文件,再整理这个工具行的视觉层级。",
|
||||
});
|
||||
emit({
|
||||
kind: "tool_dispatch",
|
||||
tool: {
|
||||
id: parentId,
|
||||
name: "explore",
|
||||
args: JSON.stringify({ task: "在 Reasonix 前端中检查工具调用图标和嵌套调用展示" }),
|
||||
readOnly: true,
|
||||
profile: { model: "mock-reasonix", effort: "high" },
|
||||
},
|
||||
});
|
||||
for (let i = 1; i <= 30; i += 1) {
|
||||
if (cancelled) return;
|
||||
const id = `mock-nested-${i}`;
|
||||
const isSearch = i % 3 === 0;
|
||||
const name = isSearch ? "grep" : "read_file";
|
||||
const args = isSearch
|
||||
? { pattern: i % 2 === 0 ? "tool__nested-count" : "explore", path: "desktop/frontend/src" }
|
||||
: { path: `desktop/frontend/src/${i % 2 === 0 ? "components/ToolCard.tsx" : "styles.css"}`, offset: i * 10, limit: 40 };
|
||||
emit({ kind: "tool_dispatch", tool: { id, name, args: JSON.stringify(args), readOnly: true, parentId } });
|
||||
emit({
|
||||
kind: "tool_result",
|
||||
tool: {
|
||||
id,
|
||||
name,
|
||||
readOnly: true,
|
||||
output: isSearch ? "3 matches" : "read 40 lines",
|
||||
durationMs: 24 + i,
|
||||
},
|
||||
});
|
||||
await delay(18);
|
||||
}
|
||||
emit({
|
||||
kind: "tool_result",
|
||||
tool: {
|
||||
id: parentId,
|
||||
name: "explore",
|
||||
readOnly: true,
|
||||
output: "已读 20 个文件 · 搜索 10 个文件",
|
||||
durationMs: 61510,
|
||||
},
|
||||
});
|
||||
emit({
|
||||
kind: "message",
|
||||
text: "Mock nested tool preview complete. The explore row now shows the compass count marker.",
|
||||
});
|
||||
emitMockTurnDone();
|
||||
return;
|
||||
}
|
||||
// Simulate the server's pre-first-token latency so the deferred user bubble
|
||||
// and the "un-send on Esc before any reply" path are observable in browser
|
||||
// dev. Bail if cancelled during the wait — nothing was streamed yet.
|
||||
@@ -1805,7 +2147,7 @@ function makeMockApp(): AppBindings {
|
||||
async ClearSession() {},
|
||||
async Checkpoints() {
|
||||
return [
|
||||
{ turn: 0, prompt: "你好呀", files: ["src/App.tsx"], turnFileCount: 1, time: Date.now() - 30_000, canCode: true, canConversation: true },
|
||||
{ turn: 0, prompt: "你好呀", files: ["src/App.tsx"], fileCount: 1, turnFileCount: 1, time: Date.now() - 30_000, canCode: true, canConversation: true },
|
||||
];
|
||||
},
|
||||
async CheckpointsForTab() {
|
||||
@@ -2009,6 +2351,7 @@ function makeMockApp(): AppBindings {
|
||||
tokenMode: normalizeTokenMode(active?.tokenMode),
|
||||
goal: active?.goal ?? "",
|
||||
goalStatus: active?.goalStatus ?? (active?.goal ? "running" : "stopped"),
|
||||
autoResearch: active?.goal ? { taskId: "mock-autoresearch", status: "running", iteration: 4, pivotRequired: false, staleCount: 0 } : undefined,
|
||||
};
|
||||
},
|
||||
async MetaForTab(tabID) {
|
||||
@@ -2034,8 +2377,80 @@ function makeMockApp(): AppBindings {
|
||||
tokenMode: normalizeTokenMode(tab?.tokenMode),
|
||||
goal: tab?.goal ?? "",
|
||||
goalStatus: tab?.goalStatus ?? (tab?.goal ? "running" : "stopped"),
|
||||
autoResearch: tab?.goal ? { taskId: "mock-autoresearch", status: "running", iteration: 4, pivotRequired: false, staleCount: 0 } : undefined,
|
||||
};
|
||||
},
|
||||
async AutoResearchCurrent() {
|
||||
return {
|
||||
taskId: "mock-autoresearch",
|
||||
goal: "Mock long-running research",
|
||||
status: "running",
|
||||
iteration: 4,
|
||||
currentDirection: "Inspect status chip",
|
||||
staleCount: 0,
|
||||
pivotCount: 0,
|
||||
pivotRequired: false,
|
||||
lastHeartbeatAt: "2026-06-29T00:00:00Z",
|
||||
findingCount: 1,
|
||||
openCriteria: [],
|
||||
blocker: "",
|
||||
taskPath: "/tmp/mock/.reasonix/autoresearch/mock-autoresearch",
|
||||
nextRequiredAction: "continue with the next evidence-producing step",
|
||||
};
|
||||
},
|
||||
async AutoResearchStatus(_tabID) {
|
||||
return {
|
||||
taskId: "mock-autoresearch",
|
||||
goal: "Mock long-running research",
|
||||
status: "running",
|
||||
iteration: 4,
|
||||
currentDirection: "Inspect status chip",
|
||||
staleCount: 0,
|
||||
pivotCount: 0,
|
||||
pivotRequired: false,
|
||||
lastHeartbeatAt: "2026-06-29T00:00:00Z",
|
||||
findingCount: 1,
|
||||
openCriteria: [],
|
||||
blocker: "",
|
||||
taskPath: "/tmp/mock/.reasonix/autoresearch/mock-autoresearch",
|
||||
nextRequiredAction: "continue with the next evidence-producing step",
|
||||
};
|
||||
},
|
||||
async AutoResearchList(_tabID) {
|
||||
return [{
|
||||
taskId: "mock-autoresearch",
|
||||
goal: "Mock long-running research",
|
||||
status: "running",
|
||||
iteration: 4,
|
||||
currentDirection: "Inspect status chip",
|
||||
staleCount: 0,
|
||||
pivotCount: 0,
|
||||
pivotRequired: false,
|
||||
lastHeartbeatAt: "2026-06-29T00:00:00Z",
|
||||
findingCount: 1,
|
||||
openCriteria: [],
|
||||
blocker: "",
|
||||
taskPath: "/tmp/mock/.reasonix/autoresearch/mock-autoresearch",
|
||||
nextRequiredAction: "continue with the next evidence-producing step",
|
||||
}];
|
||||
},
|
||||
async AutoResearchFindings(_tabID, limit) {
|
||||
return [{
|
||||
id: "f1",
|
||||
kind: "test",
|
||||
summary: "Mock accepted finding",
|
||||
source: "command",
|
||||
command: "go test ./...",
|
||||
accepted: true,
|
||||
createdAt: "2026-06-29T00:00:00Z",
|
||||
}].slice(0, Math.max(0, limit || 1));
|
||||
},
|
||||
async AutoResearchOpenTask(_tabID) {
|
||||
console.info("mock AutoResearchOpenTask");
|
||||
},
|
||||
async AutoResearchRecordEvidence(_tabID, _criterionID, _input) {
|
||||
console.info("mock AutoResearchRecordEvidence");
|
||||
},
|
||||
async Commands() {
|
||||
return [
|
||||
{ name: "new", description: "start new session; save transcript", kind: "builtin" as const },
|
||||
@@ -2044,6 +2459,7 @@ function makeMockApp(): AppBindings {
|
||||
{ name: "model", description: "Switch model", kind: "builtin" as const },
|
||||
{ name: "effort", description: "Set reasoning effort", kind: "builtin" as const },
|
||||
{ name: "skill", description: "List skills", kind: "builtin" as const },
|
||||
{ name: "plugins", description: "Manage plugin packages", kind: "builtin" as const },
|
||||
{ name: "explore", description: "Investigate the codebase in an isolated subagent", kind: "skill" as const },
|
||||
{ name: "review", description: "Review the staged diff", hint: "[focus]", kind: "custom" as const },
|
||||
];
|
||||
@@ -2053,6 +2469,7 @@ function makeMockApp(): AppBindings {
|
||||
servers: capServers.map((s) => ({ ...s })),
|
||||
skills: capSkills.map((s) => ({ ...s })),
|
||||
skillRoots: capSkillRoots.map((s) => ({ ...s })),
|
||||
plugins: capPlugins.map((p) => ({ ...p })),
|
||||
};
|
||||
},
|
||||
async MCPServers() {
|
||||
@@ -2064,6 +2481,59 @@ function makeMockApp(): AppBindings {
|
||||
skillRoots: capSkillRoots.map((s) => ({ ...s })),
|
||||
};
|
||||
},
|
||||
async Plugins() {
|
||||
return capPlugins.map((p) => ({ ...p }));
|
||||
},
|
||||
async PlanPluginInstall(source: string, options: PluginInstallOptions) {
|
||||
const name = options.name || source.split("/").filter(Boolean).pop()?.replace(/\.git$/, "") || "plugin";
|
||||
return JSON.stringify({
|
||||
ok: true,
|
||||
status: "planned",
|
||||
kind: "plugin",
|
||||
actions: [{ kind: "plugin", action: "install_plugin_package", name, source, status: "planned" }],
|
||||
});
|
||||
},
|
||||
async InstallPlugin(source: string, options: PluginInstallOptions) {
|
||||
const name = options.name || source.split("/").filter(Boolean).pop()?.replace(/\.git$/, "") || "plugin";
|
||||
const existing = capPlugins.findIndex((p) => p.name === name);
|
||||
const view: PluginView = {
|
||||
name,
|
||||
version: "dev",
|
||||
description: "Mock plugin",
|
||||
source,
|
||||
root: `~/.reasonix/plugins/${name}`,
|
||||
manifestKind: "reasonix",
|
||||
enabled: true,
|
||||
skills: 1,
|
||||
hooks: 0,
|
||||
mcpServers: 0,
|
||||
skillDetails: [{ name: "plan", description: "Plan work before implementation", invocation: "/plan", runAs: "inline" }],
|
||||
};
|
||||
if (existing >= 0) capPlugins[existing] = view;
|
||||
else capPlugins.push(view);
|
||||
return JSON.stringify({ ok: true, status: "done", kind: "plugin", actions: [{ kind: "plugin", name }] });
|
||||
},
|
||||
async RemovePlugin(name: string) {
|
||||
capPlugins = capPlugins.filter((p) => p.name !== name);
|
||||
},
|
||||
async SetPluginEnabled(name: string, enabled: boolean) {
|
||||
capPlugins = capPlugins.map((p) => p.name === name ? { ...p, enabled } : p);
|
||||
},
|
||||
async UpdatePlugin(name: string) {
|
||||
capPlugins = capPlugins.map((p) => p.name === name ? { ...p, version: p.version || "dev" } : p);
|
||||
return JSON.stringify({ ok: true, status: "done", kind: "plugin", name });
|
||||
},
|
||||
async PluginDoctor(name: string) {
|
||||
return capPlugins.find((p) => p.name === name) || {
|
||||
name,
|
||||
root: "",
|
||||
enabled: false,
|
||||
skills: 0,
|
||||
hooks: 0,
|
||||
mcpServers: 0,
|
||||
error: "plugin is not installed",
|
||||
};
|
||||
},
|
||||
async AddMCPServer(input: MCPServerInput) {
|
||||
const tools = input.transport === "stdio" ? 3 : 5;
|
||||
capServers.push({
|
||||
@@ -2170,6 +2640,9 @@ function makeMockApp(): AppBindings {
|
||||
async PickSkillFolder() {
|
||||
return "~/my-skills";
|
||||
},
|
||||
async PickPluginFolder() {
|
||||
return "~/plugins/superpowers";
|
||||
},
|
||||
async AddSkillPath(path: string) {
|
||||
const dir = path.trim() || "~/my-skills";
|
||||
if (!capSkillRoots.some((r) => r.scope === "custom" && r.dir === dir)) {
|
||||
@@ -2340,14 +2813,20 @@ function makeMockApp(): AppBindings {
|
||||
async RevealPath(path: string) {
|
||||
console.info("mock RevealPath", path);
|
||||
},
|
||||
async SavePastedImage(_dataUrl: string) {
|
||||
return ".reasonix/attachments/mock.png";
|
||||
async SavePastedImage(dataUrl: string) {
|
||||
const path = `.reasonix/attachments/mock-${mockAttachmentDataURLs.size + 1}.png`;
|
||||
mockAttachmentDataURLs.set(path, dataUrl);
|
||||
return path;
|
||||
},
|
||||
async SaveClipboardImage() {
|
||||
return ".reasonix/attachments/mock-clipboard.png";
|
||||
const path = `.reasonix/attachments/mock-clipboard-${mockAttachmentDataURLs.size + 1}.png`;
|
||||
mockAttachmentDataURLs.set(path, mockPreviewImageDataURL);
|
||||
return path;
|
||||
},
|
||||
async SavePastedFile(name: string, _dataUrl: string) {
|
||||
return `.reasonix/attachments/mock-${name}`;
|
||||
async SavePastedFile(name: string, dataUrl: string) {
|
||||
const path = `.reasonix/attachments/mock-${name}`;
|
||||
mockAttachmentDataURLs.set(path, dataUrl);
|
||||
return path;
|
||||
},
|
||||
async PickExportFile(defaultFilename: string, _mimeType: string) {
|
||||
return defaultFilename;
|
||||
@@ -2374,10 +2853,12 @@ function makeMockApp(): AppBindings {
|
||||
const tokenName = name.replace(/[^\w.-]+/g, "-") || "folder";
|
||||
return { kind: "workspace" as const, path: `__reasonix_external_folder/mock/${tokenName}`, isDir: true, displayPath: path };
|
||||
}
|
||||
return { kind: "attachment" as const, path: `.reasonix/attachments/mock-${name}` };
|
||||
const attachmentPath = `.reasonix/attachments/mock-${name}`;
|
||||
mockAttachmentDataURLs.set(attachmentPath, mockPreviewImageDataURL);
|
||||
return { kind: "attachment" as const, path: attachmentPath };
|
||||
},
|
||||
async AttachmentDataURL(_path: string) {
|
||||
return "data:image/png;base64,iVBORw0KGgo=";
|
||||
async AttachmentDataURL(path: string) {
|
||||
return mockAttachmentDataURLs.get(path) ?? mockPreviewImageDataURL;
|
||||
},
|
||||
async Models() {
|
||||
const active = mockTabs.find((tab) => tab.active) ?? mockTabs[0];
|
||||
@@ -2576,6 +3057,9 @@ function makeMockApp(): AppBindings {
|
||||
async SetSubagentEffort(level: string) {
|
||||
settings.subagentEffort = level;
|
||||
},
|
||||
async SetMaxSubagentDepth(depth: number) {
|
||||
settings.agent = { ...settings.agent, maxSubagentDepth: depth <= 1 ? 1 : 2 };
|
||||
},
|
||||
async SetAutoPlan(mode: string) {
|
||||
settings.autoPlan = mode;
|
||||
},
|
||||
@@ -2588,9 +3072,17 @@ function makeMockApp(): AppBindings {
|
||||
if (i >= 0) settings.providers[i] = p;
|
||||
else settings.providers.push(p);
|
||||
},
|
||||
async SaveProviderWithKey(p: ProviderView, key: string) {
|
||||
p.added = true;
|
||||
p.keySet = Boolean(key.trim()) || p.keySet;
|
||||
const i = settings.providers.findIndex((x) => x.name === p.name);
|
||||
if (i >= 0) settings.providers[i] = p;
|
||||
else settings.providers.push(p);
|
||||
return "";
|
||||
},
|
||||
async AddOfficialProviderAccess(kind: string, key: string) {
|
||||
const templates: Record<string, ProviderView> = {
|
||||
deepseek: { name: "deepseek", builtIn: true, added: true, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: !!key.trim(), balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", supportedEfforts: [], defaultEffort: "" },
|
||||
deepseek: { name: "deepseek", builtIn: true, added: true, kind: "openai", baseUrl: "https://api.deepseek.com", modelsUrl: "", models: ["deepseek-v4-flash", "deepseek-v4-pro"], visionModels: [], visionModelsConfigured: false, default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: !!key.trim(), balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, reasoningProtocol: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
|
||||
};
|
||||
const next = templates[kind];
|
||||
if (!next) throw new Error(`unknown official provider template ${kind}`);
|
||||
@@ -2599,12 +3091,47 @@ function makeMockApp(): AppBindings {
|
||||
else settings.providers.push(next);
|
||||
return "";
|
||||
},
|
||||
async AddProviderPresetAccess(id: string, key: string) {
|
||||
const preset = settings.providerPresets.find((p) => p.id === id);
|
||||
if (!preset) throw new Error(`unknown provider preset ${id}`);
|
||||
const next = cloneMockProviderTemplate(id, key);
|
||||
if (!next) throw new Error(`unknown provider preset ${id}`);
|
||||
const i = settings.providers.findIndex((x) => x.name === next.name);
|
||||
if (i >= 0) settings.providers[i] = { ...settings.providers[i], ...next, keySet: next.keySet || settings.providers[i].keySet };
|
||||
else settings.providers.push(next);
|
||||
preset.added = true;
|
||||
preset.status = "installed";
|
||||
preset.statusProviderNames = [...preset.providerNames];
|
||||
preset.keySet = preset.keySet || !!key.trim();
|
||||
preset.configured = !preset.requiresKey || preset.keySet;
|
||||
return "";
|
||||
},
|
||||
async ResetProviderPresetAccess(id: string) {
|
||||
const preset = settings.providerPresets.find((p) => p.id === id);
|
||||
if (!preset) throw new Error(`unknown provider preset ${id}`);
|
||||
const next = cloneMockProviderTemplate(id, "");
|
||||
if (!next) throw new Error(`unknown provider preset ${id}`);
|
||||
const i = settings.providers.findIndex((x) => x.name === next.name);
|
||||
if (i < 0) throw new Error(`provider preset ${id} cannot be reset because no same-name provider exists`);
|
||||
const existing = settings.providers[i];
|
||||
settings.providers[i] = {
|
||||
...next,
|
||||
added: true,
|
||||
keySet: existing.apiKeyEnv === next.apiKeyEnv ? existing.keySet : next.keySet,
|
||||
};
|
||||
preset.added = true;
|
||||
preset.status = "installed";
|
||||
preset.statusProviderNames = [...preset.providerNames];
|
||||
preset.keySet = preset.keySet || settings.providers[i].keySet;
|
||||
preset.configured = !preset.requiresKey || preset.keySet;
|
||||
},
|
||||
async FetchProviderModels(p: ProviderView) {
|
||||
if (!p.baseUrl.trim()) throw new Error(t("settings.fetchModelsMissingBaseUrl"));
|
||||
if (providerRequiresKey(p) && !p.apiKeyEnv.trim()) throw new Error(t("settings.fetchModelsMissingKeyEnv"));
|
||||
await delay(350);
|
||||
if (p.baseUrl.includes("deepseek")) return ["deepseek-v4-flash", "deepseek-v4-pro"];
|
||||
if (p.baseUrl.includes("mimo") || p.baseUrl.includes("xiaomimimo")) return ["mimo-v2.5", "mimo-v2.5-pro"];
|
||||
if (p.baseUrl.includes("token-plan")) return ["mimo-v2.5", "mimo-v2.5-pro"];
|
||||
if (p.baseUrl.includes("xiaomimimo")) return ["mimo-v2.5-pro", "mimo-v2.5"];
|
||||
return ["gpt-5", "gpt-5-mini", "qwen3-coder"];
|
||||
},
|
||||
async DeleteProvider(name: string) {
|
||||
@@ -2615,6 +3142,12 @@ function makeMockApp(): AppBindings {
|
||||
if (p?.builtIn) p.added = false;
|
||||
else settings.providers = settings.providers.filter((x) => x.name !== name);
|
||||
},
|
||||
async SaveProviderKey(apiKeyEnv: string, _value: string) {
|
||||
settings.providers.forEach((p) => {
|
||||
if (p.apiKeyEnv === apiKeyEnv) p.keySet = true;
|
||||
});
|
||||
return "";
|
||||
},
|
||||
async SetProviderKey(apiKeyEnv: string, _value: string) {
|
||||
settings.providers.forEach((p) => {
|
||||
if (p.apiKeyEnv === apiKeyEnv) p.keySet = true;
|
||||
@@ -2637,8 +3170,10 @@ function makeMockApp(): AppBindings {
|
||||
const k = list as "allow" | "ask" | "deny";
|
||||
settings.permissions[k] = settings.permissions[k].filter((r) => r !== rule);
|
||||
},
|
||||
async ReloadSettings() {},
|
||||
async SetSandbox(bash: string, network: boolean, workspaceRoot: string, allowWrite: string[], shell: string) {
|
||||
settings.sandbox = { bash, network, workspaceRoot, allowWrite, shell };
|
||||
const effectiveWorkspaceRoot = workspaceRoot.trim() || cwd;
|
||||
settings.sandbox = { bash, network, workspaceRoot, allowWrite, effectiveWorkspaceRoot, effectiveWriteRoots: [effectiveWorkspaceRoot, ...allowWrite], shell };
|
||||
},
|
||||
async SetNetwork(n: NetworkView) {
|
||||
settings.network = n;
|
||||
@@ -2712,10 +3247,11 @@ function makeMockApp(): AppBindings {
|
||||
label: domain === "lark" ? "Lark" : domain === "weixin" ? "微信" : "飞书",
|
||||
enabled: true,
|
||||
status: "connected",
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
credential: {
|
||||
model: "",
|
||||
toolApprovalMode: "",
|
||||
workspaceRoot: "",
|
||||
access: { enabled: true, allowAll: false, pairingEnabled: true, users: [provider === "weixin" ? "wxid_mock_user_001" : "ou_mock_user_001"], groups: [], approvers: [], admins: [] },
|
||||
credential: {
|
||||
appId: provider === "feishu" ? "cli_mock" : "",
|
||||
appSecretEnv: provider === "feishu" ? (domain === "lark" ? "LARK_BOT_APP_SECRET" : "FEISHU_BOT_APP_SECRET") : "",
|
||||
accountId: provider === "weixin" ? "mock-account" : "",
|
||||
@@ -2764,6 +3300,15 @@ function makeMockApp(): AppBindings {
|
||||
async SetDesktopLayoutStyle(style: string) {
|
||||
settings.desktopLayoutStyle = style === "workbench" || style === "creation" ? style : "classic";
|
||||
},
|
||||
async SetDesktopZoomFactor(factor: number) {
|
||||
mockDesktopZoomFactor = Math.min(2.0, Math.max(0.5, Number.isFinite(factor) ? factor : 1.0));
|
||||
},
|
||||
async GetDesktopZoomFactor() {
|
||||
return mockDesktopZoomFactor;
|
||||
},
|
||||
async RestartApplication() {
|
||||
// no-op in mock
|
||||
},
|
||||
async SetDesktopCheckUpdates(enabled: boolean) {
|
||||
settings.checkUpdates = enabled;
|
||||
},
|
||||
@@ -2851,7 +3396,7 @@ function makeMockApp(): AppBindings {
|
||||
},
|
||||
async OpenDownloadPage() {
|
||||
if (typeof window !== "undefined") {
|
||||
window.open("https://github.com/esengine/reasonix/releases/latest", "_blank", "noopener");
|
||||
window.open("https://reasonix.io/#start", "_blank", "noopener");
|
||||
}
|
||||
},
|
||||
// Dev seam: drives the overlay flow in the browser until ConnectKey sets the
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* DPI zoom scale service.
|
||||
*
|
||||
* Uses the WebView2 ZoomFactor (Go side) for reliable, layout-safe zooming.
|
||||
* The frontend only saves the user's preference and lets the Go binding persist
|
||||
* it to desktop-zoom.json; on next startup main.go reads the file and applies
|
||||
* ZoomFactor to the WebView2 window options.
|
||||
*
|
||||
* Range: 0.50 – 2.00 (50% – 200%), step 0.05.
|
||||
*/
|
||||
|
||||
export const MIN_ZOOM = 0.5;
|
||||
export const MAX_ZOOM = 2.0;
|
||||
export const ZOOM_STEP = 0.05;
|
||||
|
||||
export type ZoomLevel = number; // 0.5 – 2.0
|
||||
|
||||
export const DEFAULT_ZOOM: ZoomLevel = 1.0;
|
||||
|
||||
const ZOOM_KEY = "reasonix-zoom-restart";
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/** Snap a number to the nearest valid step within [MIN, MAX]. */
|
||||
export function snapZoom(value: number): ZoomLevel {
|
||||
const clamped = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, value));
|
||||
const steps = Math.round((clamped - MIN_ZOOM) / ZOOM_STEP);
|
||||
return parseFloat((MIN_ZOOM + steps * ZOOM_STEP).toFixed(2));
|
||||
}
|
||||
|
||||
/** Convert a zoom value (0.5-2.0) to a percentage integer (50-200). */
|
||||
export function zoomToPercent(zoom: ZoomLevel): number {
|
||||
return Math.round(zoom * 100);
|
||||
}
|
||||
|
||||
/** Convert a percentage integer (50-200) back to a zoom value (0.5-2.0). */
|
||||
export function percentToZoom(pct: number): ZoomLevel {
|
||||
return snapZoom(pct / 100);
|
||||
}
|
||||
|
||||
function readZoom(fallback: ZoomLevel): ZoomLevel {
|
||||
const stored = typeof localStorage !== "undefined" ? localStorage.getItem(ZOOM_KEY) : null;
|
||||
if (stored === null) return fallback;
|
||||
const parsed = parseFloat(stored);
|
||||
if (isNaN(parsed)) return fallback;
|
||||
return snapZoom(parsed);
|
||||
}
|
||||
|
||||
function writeZoom(value: ZoomLevel): void {
|
||||
try {
|
||||
localStorage.setItem(ZOOM_KEY, String(value));
|
||||
} catch {
|
||||
/* private mode / no storage */
|
||||
}
|
||||
}
|
||||
|
||||
// ─── public API ─────────────────────────────────────────────────────
|
||||
|
||||
/** Read the saved zoom level that will be applied on next restart. */
|
||||
export function getRestartZoom(): ZoomLevel {
|
||||
return readZoom(DEFAULT_ZOOM);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a zoom factor that will be picked up by Go on next startup and
|
||||
* applied as the WebView2 `ZoomFactor`. Takes effect after the app is
|
||||
* restarted (no layout issues — ZoomFactor is engine-level).
|
||||
*/
|
||||
export function saveRestartZoom(userZoom: ZoomLevel): void {
|
||||
writeZoom(snapZoom(userZoom));
|
||||
}
|
||||
|
||||
/**
|
||||
* Init: no-op for zoom (the Go-side ZoomFactor is applied at WebView2
|
||||
* creation time).
|
||||
*/
|
||||
export function initDpiScale(): void {
|
||||
/* zoom is handled entirely by the Go side (ZoomFactor) */
|
||||
}
|
||||
@@ -4,11 +4,19 @@ export interface Toast {
|
||||
id: number;
|
||||
text: string;
|
||||
level: "info" | "warn" | "error";
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
export interface ToastOptions {
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export interface ToastContextValue {
|
||||
toasts: Toast[];
|
||||
showToast: (text: string, level?: Toast["level"]) => void;
|
||||
showToast: (text: string, level?: Toast["level"], options?: ToastOptions) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue>({ toasts: [], showToast: () => {} });
|
||||
@@ -23,13 +31,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
|
||||
|
||||
const showToast = useCallback((text: string, level: Toast["level"] = "info") => {
|
||||
const showToast = useCallback((text: string, level: Toast["level"] = "info", options: ToastOptions = {}) => {
|
||||
const id = nextId++;
|
||||
setToasts((prev) => [...prev, { id, text, level }]);
|
||||
setToasts((prev) => [...prev, { id, text, level, actionLabel: options.actionLabel, onAction: options.onAction }]);
|
||||
const timer = setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
timers.current.delete(id);
|
||||
}, 2500);
|
||||
}, options.durationMs ?? (options.actionLabel ? 8000 : 2500));
|
||||
timers.current.set(id, timer);
|
||||
}, []);
|
||||
|
||||
@@ -49,6 +57,19 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
{t.level === "warn" && <span className="toast__icon">⚠️</span>}
|
||||
{t.level === "error" && <span className="toast__icon">❌</span>}
|
||||
<span className="toast__text">{t.text}</span>
|
||||
{t.actionLabel && t.onAction && (
|
||||
<button
|
||||
type="button"
|
||||
className="toast__action"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
dismissToast(t.id);
|
||||
t.onAction?.();
|
||||
}}
|
||||
>
|
||||
{t.actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import type { Todo } from "./tools";
|
||||
|
||||
export interface TodoPanelScopeInput {
|
||||
activeTabId?: string | null;
|
||||
activeTab?: {
|
||||
id?: string | null;
|
||||
scope?: string | null;
|
||||
workspaceRoot?: string | null;
|
||||
topicId?: string | null;
|
||||
sessionPath?: string | null;
|
||||
} | null;
|
||||
eventChannel?: string | null;
|
||||
}
|
||||
|
||||
export function todoDismissalKey(todos: Todo[]): string {
|
||||
if (todos.length === 0) return "";
|
||||
return JSON.stringify(todos.map((todo) => ({
|
||||
@@ -10,6 +22,50 @@ export function todoDismissalKey(todos: Todo[]): string {
|
||||
})));
|
||||
}
|
||||
|
||||
export function todoPanelScope({ activeTab, activeTabId, eventChannel }: TodoPanelScopeInput): string {
|
||||
const tabId = String(activeTabId ?? "").trim();
|
||||
const tab = !tabId || activeTab?.id === tabId ? activeTab : null;
|
||||
const sessionPath = tab?.sessionPath?.trim();
|
||||
if (sessionPath) return `session:${sessionPath}`;
|
||||
if (tabId) return `tab:${tabId}`;
|
||||
const topicId = tab?.topicId?.trim();
|
||||
if (tab && topicId) return `topic:${tab.scope ?? ""}:${tab.workspaceRoot ?? ""}:${topicId}`;
|
||||
const channel = String(eventChannel ?? "").trim();
|
||||
return channel ? `event:${channel}` : "";
|
||||
}
|
||||
|
||||
export function scopedTodoDismissalKey(scope: string | null | undefined, todoKey: string | null | undefined): string {
|
||||
const key = String(todoKey ?? "").trim();
|
||||
if (!key) return "";
|
||||
const prefix = String(scope ?? "").trim();
|
||||
return prefix ? `${prefix}\0${key}` : key;
|
||||
}
|
||||
|
||||
export function dismissedTodoKeyForScope(
|
||||
scope: string | null | undefined,
|
||||
dismissedKeys: ReadonlySet<string> | null | undefined,
|
||||
todoKey: string | null | undefined,
|
||||
): string | null {
|
||||
const scopedKey = scopedTodoDismissalKey(scope, todoKey);
|
||||
if (!scopedKey || !dismissedKeys?.has(scopedKey)) return null;
|
||||
return todoKey ?? null;
|
||||
}
|
||||
|
||||
export function todoBatchKey(todos: Todo[]): string {
|
||||
if (todos.length === 0) return "";
|
||||
return JSON.stringify(todos.map((todo) => ({
|
||||
content: String(todo.content ?? ""),
|
||||
level: typeof todo.level === "number" ? todo.level : 0,
|
||||
})));
|
||||
}
|
||||
|
||||
export function scopedTodoBatchKey(scope: string | null | undefined, batchKey: string | null | undefined): string {
|
||||
const key = String(batchKey ?? "").trim();
|
||||
if (!key) return "";
|
||||
const prefix = String(scope ?? "").trim();
|
||||
return prefix ? `${prefix}\0${key}` : key;
|
||||
}
|
||||
|
||||
export function shouldShowTodoPanel(
|
||||
todoKey: string | null | undefined,
|
||||
dismissedTodoKey: string | null,
|
||||
@@ -20,6 +76,10 @@ export function shouldShowTodoPanel(
|
||||
return todoKey !== dismissedTodoKey;
|
||||
}
|
||||
|
||||
export function shouldOpenTodoPanelByDefault(todos: Todo[]): boolean {
|
||||
return hasIncompleteTodos(todos);
|
||||
}
|
||||
|
||||
function todoStatus(status: unknown): string {
|
||||
const normalized = String(status ?? "").trim();
|
||||
return normalized || "pending";
|
||||
|
||||
@@ -74,6 +74,14 @@ export function questionTurnsById(questions: QuestionAnchor[]): Map<string, numb
|
||||
return turns;
|
||||
}
|
||||
|
||||
export function lastQuestionTurn(questions: readonly QuestionAnchor[], turns: ReadonlyMap<string, number>): number | undefined {
|
||||
for (let i = questions.length - 1; i >= 0; i -= 1) {
|
||||
const turn = turns.get(questions[i].id);
|
||||
if (turn != null) return turn;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function scrollVersion(items: Item[]): string {
|
||||
return items
|
||||
.map((it) => {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export function appendTurnActionCopyText(current: string, next: string): string {
|
||||
if (next.trim() === "") return current;
|
||||
if (current.trim() === "") return next;
|
||||
if (current.endsWith("\n") || next.startsWith("\n")) return current + next;
|
||||
return `${current}\n\n${next}`;
|
||||
}
|
||||
@@ -207,6 +207,11 @@ export interface TabMeta {
|
||||
tokenMode?: TokenMode;
|
||||
goal?: string;
|
||||
goalStatus?: GoalStatus;
|
||||
autoResearch?: AutoResearchCompactView;
|
||||
recovered?: boolean;
|
||||
recoveryReason?: string;
|
||||
recoveryDigest?: string;
|
||||
recoveryParentId?: string;
|
||||
startupErr?: string;
|
||||
active: boolean;
|
||||
cwd: string;
|
||||
@@ -227,6 +232,10 @@ export interface ProjectNode {
|
||||
running?: boolean;
|
||||
status?: ProjectTopicStatus;
|
||||
pinned?: boolean;
|
||||
recovered?: boolean;
|
||||
recoveryReason?: string;
|
||||
recoveryDigest?: string;
|
||||
recoveryParentId?: string;
|
||||
children?: ProjectNode[];
|
||||
}
|
||||
|
||||
@@ -238,6 +247,23 @@ export interface TopicMeta {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface SessionRecoveryEvent {
|
||||
originalPath?: string;
|
||||
recoveryPath: string;
|
||||
scope?: string;
|
||||
workspaceRoot?: string;
|
||||
topicId?: string;
|
||||
topicTitle?: string;
|
||||
recoveryReason?: string;
|
||||
recoveryDigest?: string;
|
||||
recoveryParentId?: string;
|
||||
existing?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionRecoveryFailedEvent {
|
||||
reason?: "lease_held" | "lease_unavailable" | string;
|
||||
}
|
||||
|
||||
export interface ContextPanelInfo {
|
||||
usedTokens: number;
|
||||
windowTokens: number;
|
||||
@@ -355,6 +381,8 @@ export interface CheckpointMeta {
|
||||
turn: number;
|
||||
prompt: string;
|
||||
files: string[];
|
||||
fileCount?: number;
|
||||
filesTruncated?: boolean;
|
||||
turnFileCount?: number;
|
||||
time: number; // unix ms
|
||||
canCode?: boolean;
|
||||
@@ -385,6 +413,7 @@ export interface SessionMeta {
|
||||
userId?: string;
|
||||
threadId?: string;
|
||||
sessionSource?: string;
|
||||
recovered?: boolean; // conflict-recovery copy of another session
|
||||
}
|
||||
|
||||
// SessionReference is a session selected via @ past:chats for context injection.
|
||||
@@ -434,6 +463,7 @@ export interface Meta {
|
||||
tokenMode?: TokenMode;
|
||||
goal?: string;
|
||||
goalStatus?: GoalStatus;
|
||||
autoResearch?: AutoResearchCompactView;
|
||||
}
|
||||
|
||||
export type CollaborationMode = "normal" | "plan" | "goal";
|
||||
@@ -441,6 +471,55 @@ export type ToolApprovalMode = "ask" | "auto" | "yolo";
|
||||
export type TokenMode = "full" | "economy";
|
||||
export type GoalStatus = "running" | "complete" | "blocked" | "stopped";
|
||||
|
||||
export interface AutoResearchCompactView {
|
||||
taskId: string;
|
||||
status: "running" | "blocked" | "complete" | "stopped" | "invalid";
|
||||
iteration: number;
|
||||
pivotRequired: boolean;
|
||||
staleCount: number;
|
||||
}
|
||||
|
||||
export interface AutoResearchCriterionView {
|
||||
id: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
evidenceCount: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AutoResearchStatusView extends AutoResearchCompactView {
|
||||
goal: string;
|
||||
currentDirection: string;
|
||||
pivotCount: number;
|
||||
lastHeartbeatAt: string;
|
||||
findingCount: number;
|
||||
openCriteria: AutoResearchCriterionView[];
|
||||
blocker: string;
|
||||
taskPath: string;
|
||||
nextRequiredAction: string;
|
||||
}
|
||||
|
||||
export interface AutoResearchFindingView {
|
||||
id: string;
|
||||
kind: string;
|
||||
summary: string;
|
||||
source: string;
|
||||
command?: string;
|
||||
paths?: string[];
|
||||
accepted: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AutoResearchEvidenceView {
|
||||
id: string;
|
||||
kind: string;
|
||||
summary: string;
|
||||
source: string;
|
||||
command?: string;
|
||||
paths?: string[];
|
||||
accepted: boolean;
|
||||
}
|
||||
|
||||
export function normalizeCollaborationMode(mode?: string, goal?: string, legacyMode?: Mode): CollaborationMode {
|
||||
if (mode === "plan" || mode === "goal" || mode === "normal") return mode;
|
||||
if (legacyMode && modeHasPlan(legacyMode)) return "plan";
|
||||
@@ -590,6 +669,7 @@ export interface ServerView {
|
||||
tools: number;
|
||||
prompts: number;
|
||||
resources: number;
|
||||
hasTools?: boolean;
|
||||
error?: string;
|
||||
toolList?: MCPToolView[];
|
||||
trustedReadOnlyTools?: string[];
|
||||
@@ -630,11 +710,55 @@ export interface CapabilitiesView {
|
||||
servers: ServerView[];
|
||||
skills: SkillView[];
|
||||
skillRoots: SkillRootView[];
|
||||
plugins: PluginView[];
|
||||
}
|
||||
export interface SkillsSettingsView {
|
||||
skills: SkillView[];
|
||||
skillRoots: SkillRootView[];
|
||||
}
|
||||
export interface PluginView {
|
||||
name: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
source?: string;
|
||||
root: string;
|
||||
manifestKind?: string;
|
||||
enabled: boolean;
|
||||
skills: number;
|
||||
hooks: number;
|
||||
mcpServers: number;
|
||||
skillDetails?: PluginSkillView[];
|
||||
hookDetails?: PluginHookView[];
|
||||
mcpServerDetails?: PluginMCPServerView[];
|
||||
warnings?: string[];
|
||||
error?: string;
|
||||
}
|
||||
export interface PluginSkillView {
|
||||
name: string;
|
||||
description?: string;
|
||||
path?: string;
|
||||
invocation?: string;
|
||||
runAs?: string;
|
||||
}
|
||||
export interface PluginHookView {
|
||||
event: string;
|
||||
match?: string;
|
||||
command?: string;
|
||||
contextFile?: string;
|
||||
description?: string;
|
||||
}
|
||||
export interface PluginMCPServerView {
|
||||
name: string;
|
||||
transport?: string;
|
||||
command?: string;
|
||||
url?: string;
|
||||
}
|
||||
export interface PluginInstallOptions {
|
||||
dryRun?: boolean;
|
||||
link?: boolean;
|
||||
replace?: boolean;
|
||||
name?: string;
|
||||
}
|
||||
export interface MCPServerInput {
|
||||
name: string;
|
||||
transport: string; // stdio | http | sse
|
||||
@@ -738,7 +862,7 @@ export interface MemoryView {
|
||||
}
|
||||
|
||||
// SettingsTab is the top-level navigation item in the Settings Centre modal.
|
||||
export type SettingsTab = "general" | "models" | "providers" | "bots" | "mcp" | "skills" | "memory" | "hooks" | "shortcuts" | "permissions" | "sandbox" | "network" | "appearance" | "updates";
|
||||
export type SettingsTab = "general" | "models" | "providers" | "bots" | "mcp" | "skills" | "plugins" | "memory" | "hooks" | "shortcuts" | "permissions" | "sandbox" | "network" | "appearance" | "updates";
|
||||
|
||||
// Settings panel payloads (desktop/settings_app.go).
|
||||
export interface ProviderView {
|
||||
@@ -754,6 +878,9 @@ export interface ProviderView {
|
||||
modelsUrl: string; // optional override for model discovery; empty derives from baseUrl
|
||||
default: string;
|
||||
apiKeyEnv: string;
|
||||
headers?: Record<string, string> | null; // optional extra request headers for compatible gateways
|
||||
extraBody?: Record<string, unknown> | null; // optional extra top-level request body fields for compatible gateways
|
||||
authHeader?: boolean; // Anthropic-compatible: send Authorization: Bearer instead of x-api-key
|
||||
keySet: boolean; // the env var currently resolves to a value
|
||||
requiresKey?: boolean; // false for explicit no-auth providers
|
||||
configured?: boolean; // selectable: key is set or no key is required
|
||||
@@ -762,8 +889,35 @@ export interface ProviderView {
|
||||
balanceUrl: string; // optional wallet-balance endpoint; "" disables the readout
|
||||
contextWindow: number;
|
||||
reasoningProtocol: string; // auto|deepseek|openai|none; empty = auto/model registry
|
||||
thinking: string; // provider-specific thinking override: ""|enabled|disabled|adaptive
|
||||
supportedEfforts: string[]; // custom /effort levels; empty = use built-in Kind/BaseURL default
|
||||
defaultEffort: string; // /effort level when user picks "auto" or unset; "" = supportedEfforts[0]
|
||||
modelOverrides?: ProviderModelOverrideView[] | null;
|
||||
}
|
||||
|
||||
export interface ProviderPresetView {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
keyEnv: string;
|
||||
providerNames: string[];
|
||||
models: string[];
|
||||
added: boolean;
|
||||
status?: "available" | "installed" | "installed_modified" | "name_conflict" | "similar_existing";
|
||||
statusProviderNames?: string[];
|
||||
keySet: boolean;
|
||||
requiresKey?: boolean;
|
||||
configured?: boolean;
|
||||
keySource?: string;
|
||||
keySourcePath?: string;
|
||||
}
|
||||
|
||||
export interface ProviderModelOverrideView {
|
||||
model: string;
|
||||
reasoningProtocol: string;
|
||||
supportedEfforts: string[];
|
||||
defaultEffort: string;
|
||||
vision?: boolean | null;
|
||||
}
|
||||
|
||||
// BalanceInfo is the wallet-balance readout (desktop/app.go Balance). available
|
||||
@@ -796,6 +950,8 @@ export interface SandboxView {
|
||||
network: boolean;
|
||||
workspaceRoot: string;
|
||||
allowWrite: string[];
|
||||
effectiveWorkspaceRoot: string;
|
||||
effectiveWriteRoots: string[];
|
||||
shell: string; // "auto" | "bash" | "powershell" | "pwsh"
|
||||
}
|
||||
|
||||
@@ -818,6 +974,7 @@ export interface AgentView {
|
||||
temperature: number;
|
||||
maxSteps: number;
|
||||
plannerMaxSteps: number;
|
||||
maxSubagentDepth: number;
|
||||
systemPrompt: string;
|
||||
coldResumePrune: boolean;
|
||||
reasoningLanguage: string; // "auto" | "zh" | "en"
|
||||
@@ -829,17 +986,67 @@ export interface BotAllowlistView {
|
||||
qqUsers: string[];
|
||||
feishuUsers: string[];
|
||||
weixinUsers: string[];
|
||||
qqApprovers: string[];
|
||||
feishuApprovers: string[];
|
||||
weixinApprovers: string[];
|
||||
qqAdmins: string[];
|
||||
feishuAdmins: string[];
|
||||
weixinAdmins: string[];
|
||||
qqGroups: string[];
|
||||
feishuGroups: string[];
|
||||
weixinGroups: string[];
|
||||
}
|
||||
|
||||
export interface BotAccessView {
|
||||
enabled: boolean;
|
||||
allowAll: boolean;
|
||||
pairingEnabled: boolean;
|
||||
users: string[];
|
||||
groups: string[];
|
||||
approvers: string[];
|
||||
admins: string[];
|
||||
}
|
||||
|
||||
export interface BotSelfUserIDsView {
|
||||
qq: string[];
|
||||
feishu: string[];
|
||||
weixin: string[];
|
||||
}
|
||||
|
||||
export interface BotPairingView {
|
||||
enabled: boolean;
|
||||
requestTtlMinutes: number;
|
||||
maxPendingPerPlatform: number;
|
||||
}
|
||||
|
||||
export interface BotControlView {
|
||||
enabled: boolean;
|
||||
addr: string;
|
||||
tokenEnv: string;
|
||||
}
|
||||
|
||||
export interface BotRouteView {
|
||||
connectionId: string;
|
||||
platform: string;
|
||||
chatType: string;
|
||||
chatId: string;
|
||||
userId: string;
|
||||
threadId: string;
|
||||
model: string;
|
||||
toolApprovalMode: ToolApprovalMode | "" | string;
|
||||
workspaceRoot: string;
|
||||
}
|
||||
|
||||
export interface QQBotView {
|
||||
enabled: boolean;
|
||||
appId: string;
|
||||
appSecretEnv: string;
|
||||
secretSet: boolean;
|
||||
sandbox: boolean;
|
||||
model: string;
|
||||
toolApprovalMode: ToolApprovalMode | "" | string;
|
||||
workspaceRoot: string;
|
||||
access: BotAccessView;
|
||||
}
|
||||
|
||||
export interface FeishuBotView {
|
||||
@@ -892,6 +1099,7 @@ export interface BotConnectionView {
|
||||
model: string;
|
||||
toolApprovalMode: ToolApprovalMode | "" | string;
|
||||
workspaceRoot: string;
|
||||
access: BotAccessView;
|
||||
credential: BotConnectionCredentialView;
|
||||
sessionMappings: BotConnectionSessionMappingView[];
|
||||
lastError: string;
|
||||
@@ -905,6 +1113,14 @@ export interface BotSettingsView {
|
||||
toolApprovalMode: ToolApprovalMode | "" | string;
|
||||
maxSteps: number;
|
||||
debounceMs: number;
|
||||
queueMode: string;
|
||||
queueCap: number;
|
||||
queueDrop: string;
|
||||
ignoreSelfMessages: boolean;
|
||||
selfUserIds: BotSelfUserIDsView;
|
||||
control: BotControlView;
|
||||
pairing: BotPairingView;
|
||||
routes: BotRouteView[];
|
||||
allowlist: BotAllowlistView;
|
||||
qq: QQBotView;
|
||||
feishu: FeishuBotView;
|
||||
@@ -980,6 +1196,7 @@ export interface SettingsView {
|
||||
autoPlan: string;
|
||||
providers: ProviderView[];
|
||||
officialProviders: ProviderView[];
|
||||
providerPresets: ProviderPresetView[];
|
||||
permissions: PermissionsView;
|
||||
sandbox: SandboxView;
|
||||
network: NetworkView;
|
||||
|
||||
@@ -10,7 +10,7 @@ import { app, onEvent, onReady } from "./bridge";
|
||||
import { invalidateCache } from "./composerHistory";
|
||||
import { formatGuardianAssessmentNotice } from "./guardianEvents";
|
||||
import { createRafBatch } from "./rafBatch";
|
||||
import { t } from "./i18n";
|
||||
import { t, type DictKey } from "./i18n";
|
||||
import { fileDiffFromWire, summarize, summarizeFileDiff, type ToolFileDiff } from "./tools";
|
||||
import { modeHasAutoApproveTools, normalizeMode, normalizeToolApprovalMode } from "./types";
|
||||
import type {
|
||||
@@ -43,6 +43,9 @@ export type LiveStream = { id: string; text: string; reasoning: string; reasonin
|
||||
export type MessageActionScope = "fork" | "summ-from" | "summ-upto" | "conversation" | "code" | "both";
|
||||
export type MessageActionState = { turn: number; scope: MessageActionScope };
|
||||
export type HydrateReason = "switch-tab" | "new-session" | "resume-session" | "open-topic" | "startup";
|
||||
type SyncActiveTabOptions = {
|
||||
preserveCachedHistory?: boolean;
|
||||
};
|
||||
|
||||
const HISTORY_PAGE_TURNS = 60;
|
||||
|
||||
@@ -165,6 +168,7 @@ type RuntimeMetaSnapshot = {
|
||||
running: boolean;
|
||||
pendingPrompt?: boolean;
|
||||
backgroundJobs?: number;
|
||||
cancelRequested?: boolean;
|
||||
cancellable?: boolean;
|
||||
};
|
||||
|
||||
@@ -240,6 +244,8 @@ export function sameMeta(a?: Meta, b?: Meta): boolean {
|
||||
|
||||
const STALE_TURN_RECONCILE_MS = 30_000;
|
||||
const CANCEL_RECONCILE_DELAYS_MS = [0, 100, 300, 1_000] as const;
|
||||
const STARTUP_READY_META_RECONCILE_MS = 250;
|
||||
const STARTUP_READY_META_RECONCILE_ATTEMPTS = 60;
|
||||
|
||||
export function shouldReconcileStaleTurn(
|
||||
state: Pick<State, "running" | "turnActive"> | undefined,
|
||||
@@ -351,6 +357,18 @@ type Action =
|
||||
| { type: "clearAsk" }
|
||||
| { type: "reset" };
|
||||
|
||||
function backendStatusFromRuntimeMeta(meta: RuntimeMetaSnapshot): Extract<Action, { type: "backend_status" }> {
|
||||
const foregroundRunning = foregroundRunningFromRuntimeMeta(meta);
|
||||
return {
|
||||
type: "backend_status",
|
||||
running: foregroundRunning,
|
||||
pendingPrompt: Boolean(meta.pendingPrompt),
|
||||
backgroundJobs: meta.backgroundJobs ?? 0,
|
||||
cancelRequested: Boolean(meta.cancelRequested),
|
||||
cancellable: foregroundRunning,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- reducer helpers (unchanged logic) ----
|
||||
|
||||
export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: string, startSeq = 0): { items: Item[]; seq: number } {
|
||||
@@ -363,7 +381,7 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str
|
||||
const positionalResults = positionalToolResults(messages);
|
||||
const consumedPositionalToolIndexes = new Set(Array.from(positionalResults.values(), (result) => result.index));
|
||||
|
||||
const items: Item[] = [];
|
||||
let items: Item[] = [];
|
||||
let seq = startSeq;
|
||||
const consumedToolIDs = new Set<string>();
|
||||
for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
|
||||
@@ -378,8 +396,9 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str
|
||||
}
|
||||
if (m.role === "notice") {
|
||||
if (m.content.trim() !== "") {
|
||||
items.push({ kind: "notice", id: `${idPrefix}${seq}`, level: m.level === "warn" ? "warn" : "info", text: m.content });
|
||||
seq++;
|
||||
const next = appendNoticeItem(items, seq, `${idPrefix}${seq}`, m.level === "warn" ? "warn" : "info", m.content);
|
||||
items = next.items;
|
||||
seq = next.seq;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -709,7 +728,7 @@ function applyEvent(s: State, e: WireEvent): State {
|
||||
return { ...s, usage, context: { ...s.context, used, sessionTokens }, turnTokens, turnTotalTokens, turnCost, sessionTokens, sessionCost, sessionCurrency };
|
||||
}
|
||||
case "notice":
|
||||
return { ...s, running: s.turnActive ? s.running : false, seq: s.seq + 1, items: [...s.items, { kind: "notice", id: `n${s.seq}`, level: e.level ?? "info", text: e.text ?? "" }] };
|
||||
return appendNoticeToState(s, e.level ?? "info", e.text ?? "");
|
||||
case "phase":
|
||||
return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "phase", id: `p${s.seq}`, text: e.text ?? "" }] };
|
||||
case "compaction_started":
|
||||
@@ -937,6 +956,181 @@ function errorMessage(err: unknown): string {
|
||||
return String(err || "");
|
||||
}
|
||||
|
||||
export function effortSwitchNoticeText(err: unknown): string {
|
||||
return settingSwitchNoticeText(err, "effort", {
|
||||
busy: "status.effortSwitchBusy",
|
||||
leaseHeld: "status.effortSwitchLeaseHeld",
|
||||
starting: "status.effortSwitchStarting",
|
||||
startupFailed: "status.effortSwitchStartupFailed",
|
||||
retry: "status.effortSwitchRetry",
|
||||
failed: "status.effortSwitchFailed",
|
||||
});
|
||||
}
|
||||
|
||||
export function modelSwitchNoticeText(err: unknown): string {
|
||||
const msg = errorMessage(err).trim() || "unknown error";
|
||||
const unknownModel = /^unknown model (.+)$/i.exec(msg);
|
||||
if (unknownModel) {
|
||||
return t("status.modelSwitchUnknown", { model: unknownModel[1] });
|
||||
}
|
||||
const unavailable = /^model (.+) is not available because provider (.+) is not added$/i.exec(msg);
|
||||
if (unavailable) {
|
||||
return t("status.modelSwitchProviderUnavailable", { model: unavailable[1], provider: unavailable[2] });
|
||||
}
|
||||
return settingSwitchNoticeText(msg, "model", {
|
||||
busy: "status.modelSwitchBusy",
|
||||
leaseHeld: "status.modelSwitchLeaseHeld",
|
||||
starting: "status.modelSwitchStarting",
|
||||
startupFailed: "status.modelSwitchStartupFailed",
|
||||
retry: "status.modelSwitchRetry",
|
||||
failed: "status.modelSwitchFailed",
|
||||
});
|
||||
}
|
||||
|
||||
export function tokenModeSwitchNoticeText(err: unknown): string {
|
||||
return settingSwitchNoticeText(err, "token mode", {
|
||||
busy: "status.tokenModeSwitchBusy",
|
||||
leaseHeld: "status.tokenModeSwitchLeaseHeld",
|
||||
starting: "status.tokenModeSwitchStarting",
|
||||
startupFailed: "status.tokenModeSwitchStartupFailed",
|
||||
retry: "status.tokenModeSwitchRetry",
|
||||
failed: "status.tokenModeSwitchFailed",
|
||||
});
|
||||
}
|
||||
|
||||
export function localizedBackendNoticeText(text: string): string {
|
||||
const msg = text.trim();
|
||||
const autosave = /^Session autosave failed: (.+)$/s.exec(msg);
|
||||
if (autosave) {
|
||||
return t("status.sessionAutosaveFailed", { err: autosave[1] });
|
||||
}
|
||||
const saveBefore = /^Session save failed before (.+?): (.+)$/s.exec(msg);
|
||||
if (saveBefore) {
|
||||
return t("status.sessionSaveFailedBefore", { action: localizedSessionAction(saveBefore[1]), err: saveBefore[2] });
|
||||
}
|
||||
const modelFallback = /^model (.+) is no longer available; switched to (.+)$/s.exec(msg);
|
||||
if (modelFallback) {
|
||||
return t("status.modelFallbackSwitched", { model: modelFallback[1], fallback: modelFallback[2] });
|
||||
}
|
||||
if (
|
||||
/^session changed on disk; unsaved local transcript was saved as a conflict copy$/i.test(msg) ||
|
||||
/^session changed on disk; unsaved local transcript was saved as recovery branch\b/i.test(msg)
|
||||
) {
|
||||
return t("recovery.noticeSavedCopy");
|
||||
}
|
||||
if (
|
||||
/^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) ||
|
||||
/^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg)
|
||||
) {
|
||||
return t("recovery.noticeKeptCurrent");
|
||||
}
|
||||
if (/^session changed on disk; adopted the newer transcript \(local changes already covered\)$/i.test(msg)) {
|
||||
return t("recovery.noticeAdoptedCovered");
|
||||
}
|
||||
if (/^session changed on disk; adopted the newer transcript$/i.test(msg)) {
|
||||
return t("recovery.noticeAdopted");
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
function recoveryNoticeDedupeKey(text: string): string {
|
||||
const msg = text.trim();
|
||||
if (
|
||||
/^session changed on disk; unsaved local transcript was saved as a conflict copy$/i.test(msg) ||
|
||||
/^session changed on disk; unsaved local transcript was saved as recovery branch\b/i.test(msg) ||
|
||||
msg === t("recovery.noticeSavedCopy")
|
||||
) {
|
||||
return "recovery:saved-copy";
|
||||
}
|
||||
if (
|
||||
/^repeated save conflicts were detected; saved the current conflict copy in place$/i.test(msg) ||
|
||||
/^session conflicts kept recurring; kept the transcript on the current recovery branch$/i.test(msg) ||
|
||||
msg === t("recovery.noticeKeptCurrent")
|
||||
) {
|
||||
return "recovery:kept-current";
|
||||
}
|
||||
if (
|
||||
/^session changed on disk; adopted the newer transcript \(local changes already covered\)$/i.test(msg) ||
|
||||
msg === t("recovery.noticeAdoptedCovered")
|
||||
) {
|
||||
return "recovery:adopted-covered";
|
||||
}
|
||||
if (
|
||||
/^session changed on disk; adopted the newer transcript$/i.test(msg) ||
|
||||
msg === t("recovery.noticeAdopted")
|
||||
) {
|
||||
return "recovery:adopted";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string): { items: Item[]; seq: number } {
|
||||
const text = localizedBackendNoticeText(rawText);
|
||||
const key = recoveryNoticeDedupeKey(rawText) || recoveryNoticeDedupeKey(text);
|
||||
if (key && items.some((item) => item.kind === "notice" && recoveryNoticeDedupeKey(item.text) === key)) {
|
||||
return { items, seq };
|
||||
}
|
||||
return { items: [...items, { kind: "notice", id, level, text }], seq: seq + 1 };
|
||||
}
|
||||
|
||||
function appendNoticeToState(s: State, level: "info" | "warn", text: string): State {
|
||||
const next = appendNoticeItem(s.items, s.seq, `n${s.seq}`, level, text);
|
||||
return { ...s, running: s.turnActive ? s.running : false, seq: next.seq, items: next.items };
|
||||
}
|
||||
|
||||
function localizedSessionAction(action: string): string {
|
||||
switch (action.trim()) {
|
||||
case "changing model":
|
||||
return t("status.actionChangingModel");
|
||||
case "changing effort":
|
||||
return t("status.actionChangingEffort");
|
||||
case "changing token mode":
|
||||
return t("status.actionChangingTokenMode");
|
||||
case "rebuilding settings":
|
||||
return t("status.actionRebuildingSettings");
|
||||
case "switching sessions":
|
||||
return t("status.actionSwitchingSessions");
|
||||
case "switching tabs":
|
||||
return t("status.actionSwitchingTabs");
|
||||
case "autosave":
|
||||
return t("status.actionAutosave");
|
||||
default:
|
||||
return action.trim() || t("status.actionCurrentSession");
|
||||
}
|
||||
}
|
||||
|
||||
function settingSwitchNoticeText(
|
||||
err: unknown,
|
||||
setting: "effort" | "model" | "token mode",
|
||||
keys: {
|
||||
busy: DictKey;
|
||||
leaseHeld: DictKey;
|
||||
starting: DictKey;
|
||||
startupFailed: DictKey;
|
||||
retry: DictKey;
|
||||
failed: DictKey;
|
||||
},
|
||||
): string {
|
||||
const msg = errorMessage(err).trim() || "unknown error";
|
||||
const lower = msg.toLowerCase();
|
||||
if (lower.includes("finish or cancel") && lower.includes(`before changing ${setting}`)) {
|
||||
return t(keys.busy);
|
||||
}
|
||||
if (lower.includes("already open in another reasonix window") || lower.includes("session lease held")) {
|
||||
return t(keys.leaseHeld);
|
||||
}
|
||||
if (lower.includes("workspace is still starting")) {
|
||||
return t(keys.starting);
|
||||
}
|
||||
if (lower.startsWith("workspace failed to start")) {
|
||||
return t(keys.startupFailed, { err: msg });
|
||||
}
|
||||
if (lower.includes(`changed while switching ${setting}`) || (lower.includes("tab ") && lower.includes("not found"))) {
|
||||
return t(keys.retry);
|
||||
}
|
||||
return t(keys.failed, { err: msg });
|
||||
}
|
||||
|
||||
async function refreshMetaForTab(tabId: string, dispatchTo: (tabId: string, action: Action) => void): Promise<void> {
|
||||
try {
|
||||
dispatchTo(tabId, { type: "meta", meta: await app.MetaForTab(tabId) });
|
||||
@@ -947,6 +1141,16 @@ async function refreshMetaForTab(tabId: string, dispatchTo: (tabId: string, acti
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshMetaOnlyForTab(tabId: string, dispatchTo: (tabId: string, action: Action) => void): Promise<Meta | undefined> {
|
||||
try {
|
||||
const meta = await app.MetaForTab(tabId);
|
||||
dispatchTo(tabId, { type: "meta", meta });
|
||||
return meta;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function replayPendingPromptsForActiveTab(activeTabId: string | undefined, replay: () => Promise<void> = () => app.ReplayPendingPrompts()): void {
|
||||
if (!activeTabId) return;
|
||||
void replay().catch(() => {});
|
||||
@@ -968,6 +1172,8 @@ export function useController() {
|
||||
const stateRef = useRef(activeState);
|
||||
const backendActiveTabIdRef = useRef<string | undefined>(undefined);
|
||||
const backendActivationPromises = useRef(new Map<string, Promise<boolean>>());
|
||||
const readyMetaReconcileSeq = useRef(0);
|
||||
const readyMetaReconcileActive = useRef<{ tabId: string; seq: number } | undefined>(undefined);
|
||||
activeTabIdRef.current = activeTabId;
|
||||
stateRef.current = activeState;
|
||||
|
||||
@@ -1057,23 +1263,31 @@ export function useController() {
|
||||
if (reset && sessionLoadCurrent(tabId, seq)) dispatchTo(tabId, { type: "reset" });
|
||||
|
||||
const stillCurrent = () => sessionLoadCurrent(tabId, seq);
|
||||
const requiresVisibleTab = reason === "startup" || reason === "switch-tab" || reason === "open-topic";
|
||||
const stillVisible = () => !requiresVisibleTab || activeTabIdRef.current === tabId;
|
||||
const noteFailure = (label: string, err: unknown) => {
|
||||
addBreadcrumb("tab.hydrate", `${label} failed ${tabId}: ${errorMessage(err)}`);
|
||||
};
|
||||
|
||||
const historyStartedAt = Date.now();
|
||||
const loadTimed = async <T,>(label: string, load: () => Promise<T>): Promise<T | undefined> => {
|
||||
const startedAt = Date.now();
|
||||
addBreadcrumb("tab.hydrate", `${label} start ${reason} ${tabId}`);
|
||||
try {
|
||||
const value = await load();
|
||||
addBreadcrumb("tab.hydrate", `${label} done ${reason} ${tabId} ms=${Date.now() - startedAt}`);
|
||||
return value;
|
||||
} catch (err) {
|
||||
noteFailure(label, err);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 1: dispatch fast metadata immediately so the transcript can render
|
||||
// without waiting for slower ancillary calls (e.g. BalanceForTab network).
|
||||
const [meta, historyPage] = await Promise.all([
|
||||
app.MetaForTab(tabId).catch((err) => { noteFailure("meta", err); return undefined; }),
|
||||
skipHistory
|
||||
? Promise.resolve(undefined)
|
||||
: app.HistoryPageForTab(tabId, 0, HISTORY_PAGE_TURNS).catch((err) => { noteFailure("history", err); return undefined; }),
|
||||
]);
|
||||
const historyStartedAt = Date.now();
|
||||
const historyPage = skipHistory
|
||||
? undefined
|
||||
: await loadTimed("history", () => app.HistoryPageForTab(tabId, 0, HISTORY_PAGE_TURNS));
|
||||
|
||||
if (!stillCurrent()) return;
|
||||
if (meta !== undefined) dispatchTo(tabId, { type: "meta", meta });
|
||||
if (!skipHistory && historyPage !== undefined) {
|
||||
const messages = asArray(historyPage.messages);
|
||||
dispatchTo(tabId, { type: "history_page", page: historyPage, mode: "replace" });
|
||||
@@ -1104,26 +1318,51 @@ export function useController() {
|
||||
// in a loading state.
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
|
||||
if (!stillCurrent()) return;
|
||||
if (activeTabIdRef.current !== tabId && (reason === "startup" || reason === "switch-tab" || reason === "open-topic")) {
|
||||
if (!stillVisible()) {
|
||||
addBreadcrumb("tab.hydrate", `ancillary skipped inactive ${reason} ${tabId}`);
|
||||
return;
|
||||
}
|
||||
const meta = await loadTimed("meta", () => app.MetaForTab(tabId));
|
||||
if (!stillCurrent()) return;
|
||||
if (!stillVisible()) {
|
||||
addBreadcrumb("tab.hydrate", `meta ignored inactive ${reason} ${tabId}`);
|
||||
return;
|
||||
}
|
||||
if (meta !== undefined) dispatchTo(tabId, { type: "meta", meta });
|
||||
const ancillaryStartedAt = Date.now();
|
||||
const [effort, jobs, checkpoints, context] = await Promise.all([
|
||||
app.EffortForTab(tabId).catch((err) => { noteFailure("effort", err); return undefined; }),
|
||||
app.JobsForTab(tabId).catch((err) => { noteFailure("jobs", err); return undefined; }),
|
||||
app.CheckpointsForTab(tabId).catch((err) => { noteFailure("checkpoints", err); return undefined; }),
|
||||
app.ContextUsageForTab(tabId).catch((err) => { noteFailure("context", err); return undefined; }),
|
||||
const loadAncillary = async <T,>(label: string, load: () => Promise<T>): Promise<T | undefined> => {
|
||||
return loadTimed(`ancillary ${label}`, load);
|
||||
};
|
||||
const [effort, jobs, context] = await Promise.all([
|
||||
loadAncillary("effort", () => app.EffortForTab(tabId)),
|
||||
loadAncillary("jobs", () => app.JobsForTab(tabId)),
|
||||
loadAncillary("context", () => app.ContextUsageForTab(tabId)),
|
||||
]);
|
||||
if (!stillCurrent()) return;
|
||||
if (!stillVisible()) {
|
||||
addBreadcrumb("tab.hydrate", `ancillary ignored inactive ${reason} ${tabId}`);
|
||||
return;
|
||||
}
|
||||
if (effort !== undefined) dispatchTo(tabId, { type: "effort", effort });
|
||||
if (jobs !== undefined) dispatchTo(tabId, { type: "jobs", jobs: asArray(jobs) });
|
||||
if (checkpoints !== undefined) dispatchTo(tabId, { type: "checkpoints", checkpoints: asArray(checkpoints) });
|
||||
if (context !== undefined) dispatchTo(tabId, { type: "context", context });
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
|
||||
if (!stillCurrent()) return;
|
||||
if (!stillVisible()) {
|
||||
addBreadcrumb("tab.hydrate", `checkpoints skipped inactive ${reason} ${tabId}`);
|
||||
return;
|
||||
}
|
||||
const checkpoints = await loadAncillary("checkpoints", () => app.CheckpointsForTab(tabId));
|
||||
if (!stillCurrent()) return;
|
||||
if (!stillVisible()) {
|
||||
addBreadcrumb("tab.hydrate", `checkpoints ignored inactive ${reason} ${tabId}`);
|
||||
return;
|
||||
}
|
||||
if (checkpoints !== undefined) dispatchTo(tabId, { type: "checkpoints", checkpoints: asArray(checkpoints) });
|
||||
addBreadcrumb("tab.hydrate", `ancillary ${reason} ${tabId} ms=${Date.now() - ancillaryStartedAt}`);
|
||||
app.BalanceForTab(tabId)
|
||||
.then((balance) => {
|
||||
if (sessionLoadCurrent(tabId, seq)) dispatchTo(tabId, { type: "balance", balance });
|
||||
if (sessionLoadCurrent(tabId, seq) && stillVisible()) dispatchTo(tabId, { type: "balance", balance });
|
||||
})
|
||||
.catch((err) => { noteFailure("balance", err); });
|
||||
})();
|
||||
@@ -1171,6 +1410,22 @@ export function useController() {
|
||||
return tabs.find((tab) => tab.active) ?? tabs[0];
|
||||
}, []);
|
||||
|
||||
const dispatchRuntimeStatusForTab = useCallback((tabId: string, tab: RuntimeMetaSnapshot) => {
|
||||
const foregroundRunning = foregroundRunningFromRuntimeMeta(tab);
|
||||
dispatchTo(tabId, {
|
||||
type: "backend_status",
|
||||
running: foregroundRunning,
|
||||
pendingPrompt: Boolean(tab.pendingPrompt),
|
||||
backgroundJobs: tab.backgroundJobs ?? 0,
|
||||
cancelRequested: Boolean(tab.cancelRequested),
|
||||
cancellable: foregroundRunning,
|
||||
});
|
||||
// backend_status reconciliation can clear a live prompt from frontend state.
|
||||
// If the backend is still blocked, ask it to replay the approval/ask event.
|
||||
if (tab.pendingPrompt) replayPendingPromptsForActiveTab(tabId);
|
||||
return foregroundRunning;
|
||||
}, [dispatchTo]);
|
||||
|
||||
const waitForTabReady = useCallback(async (tabId: string): Promise<void> => {
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
const tabs = asArray(await app.ListTabs().catch(() => [] as TabMeta[]));
|
||||
@@ -1180,7 +1435,7 @@ export function useController() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const syncActiveTabFromBackend = useCallback(async (reset = false, guard = false): Promise<string | undefined> => {
|
||||
const syncActiveTabFromBackend = useCallback(async (reset = false, guard = false, options: SyncActiveTabOptions = {}): Promise<string | undefined> => {
|
||||
const active = await activeTabFromBackend();
|
||||
if (!active) return undefined;
|
||||
// When guard is true, skip if the frontend already settled on a
|
||||
@@ -1194,9 +1449,15 @@ export function useController() {
|
||||
activeTabIdRef.current = active.id;
|
||||
confirmBackendActiveTab(active.id);
|
||||
dispatchTo(active.id, { type: "optimistic_meta", meta: metaFromTab(active, statesRef.current.get(active.id)?.meta) });
|
||||
await loadSessionDataForTab(active.id, reset, "startup");
|
||||
const preserveCachedHistory = options.preserveCachedHistory ?? !reset;
|
||||
if (!reset) dispatchRuntimeStatusForTab(active.id, active);
|
||||
await loadSessionDataForTab(active.id, reset, "startup", {
|
||||
preserveCachedHistory,
|
||||
sessionPath: active.sessionPath,
|
||||
});
|
||||
if (reset) dispatchRuntimeStatusForTab(active.id, active);
|
||||
return active.id;
|
||||
}, [activeTabFromBackend, confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
|
||||
}, [activeTabFromBackend, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab]);
|
||||
|
||||
const reconcileTabRuntime = useCallback(async (
|
||||
tabId: string,
|
||||
@@ -1208,19 +1469,8 @@ export function useController() {
|
||||
if (!tab) return undefined;
|
||||
const local = statesRef.current.get(tabId);
|
||||
const needsInitialLoad = !local?.meta;
|
||||
const foregroundRunning = foregroundRunningFromRuntimeMeta(tab);
|
||||
const foregroundRunning = dispatchRuntimeStatusForTab(tabId, tab);
|
||||
const missedTurnDone = Boolean(local?.running && !foregroundRunning);
|
||||
dispatchTo(tabId, {
|
||||
type: "backend_status",
|
||||
running: foregroundRunning,
|
||||
pendingPrompt: Boolean(tab.pendingPrompt),
|
||||
backgroundJobs: tab.backgroundJobs ?? 0,
|
||||
cancelRequested: Boolean(tab.cancelRequested),
|
||||
cancellable: foregroundRunning,
|
||||
});
|
||||
// backend_status reconciliation can clear a live prompt from frontend state.
|
||||
// If the backend is still blocked, ask it to replay the approval/ask event.
|
||||
if (tab.pendingPrompt) replayPendingPromptsForActiveTab(tabId);
|
||||
if (hydrateSessionData && (needsInitialLoad || missedTurnDone)) {
|
||||
await loadSessionDataForTab(tabId, missedTurnDone, "startup");
|
||||
return tabs;
|
||||
@@ -1234,7 +1484,7 @@ export function useController() {
|
||||
if (effort) dispatchTo(tabId, { type: "effort", effort });
|
||||
if (balance) dispatchTo(tabId, { type: "balance", balance });
|
||||
return tabs;
|
||||
}, [dispatchTo, loadSessionDataForTab]);
|
||||
}, [dispatchRuntimeStatusForTab, loadSessionDataForTab]);
|
||||
|
||||
const clearCancelReconcileTimer = useCallback((tabId: string) => {
|
||||
const timer = cancelReconcileTimers.current.get(tabId);
|
||||
@@ -1290,7 +1540,6 @@ export function useController() {
|
||||
.then((turns) => dispatchTo(targetTabId, { type: "history_checkpoint_turns", turns: asArray(turns) }))
|
||||
.catch(() => {});
|
||||
}
|
||||
void refreshMetaForTab(targetTabId, dispatchTo);
|
||||
app
|
||||
.ContextUsageForTab(targetTabId)
|
||||
.then((context) => dispatchTo(targetTabId, { type: "context", context }))
|
||||
@@ -1298,6 +1547,7 @@ export function useController() {
|
||||
app.BalanceForTab(targetTabId).then((balance) => dispatchTo(targetTabId, { type: "balance", balance })).catch(() => {});
|
||||
app.EffortForTab(targetTabId).then((effort) => dispatchTo(targetTabId, { type: "effort", effort })).catch(() => {});
|
||||
void refreshCheckpoints(targetTabId);
|
||||
void refreshMetaForTab(targetTabId, dispatchTo);
|
||||
}
|
||||
if (e.kind === "turn_done" || e.kind === "notice") {
|
||||
app.JobsForTab(targetTabId).then((jobs) => dispatchTo(targetTabId, { type: "jobs", jobs: asArray(jobs) })).catch(() => {});
|
||||
@@ -1310,11 +1560,9 @@ export function useController() {
|
||||
addBreadcrumb("tab.hydrate", `ready ignored ${readyTabId}`);
|
||||
return;
|
||||
}
|
||||
if (activeId) {
|
||||
void loadSessionDataForTab(activeId, false, "startup", { preserveCachedHistory: true });
|
||||
return;
|
||||
}
|
||||
void syncActiveTabFromBackend(false, true);
|
||||
// A ready event can race the initial hydrate. Refresh the tab metadata
|
||||
// first so a stale ready=false snapshot does not keep the composer locked.
|
||||
void syncActiveTabFromBackend(false, true, { preserveCachedHistory: true });
|
||||
});
|
||||
|
||||
void syncActiveTabFromBackend(false, true);
|
||||
@@ -1335,6 +1583,51 @@ export function useController() {
|
||||
};
|
||||
}, [dispatchTo, loadSessionDataForTab, refreshCheckpoints, syncActiveTabFromBackend]);
|
||||
|
||||
// If the startup ready event is missed, keep the composer lock in sync with
|
||||
// the active tab's backend metadata without kicking off tab activation work.
|
||||
useEffect(() => {
|
||||
const tabId = activeTabId;
|
||||
const meta = activeState.meta;
|
||||
if (!tabId || !meta || meta.ready || meta.startupErr || activeState.backendActivationPending) {
|
||||
readyMetaReconcileSeq.current += 1;
|
||||
readyMetaReconcileActive.current = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let timer: number | undefined;
|
||||
const seq = readyMetaReconcileSeq.current + 1;
|
||||
readyMetaReconcileSeq.current = seq;
|
||||
readyMetaReconcileActive.current = { tabId, seq };
|
||||
|
||||
const stillCurrent = () => {
|
||||
const active = readyMetaReconcileActive.current;
|
||||
return !cancelled && active?.tabId === tabId && active.seq === seq && activeTabIdRef.current === tabId;
|
||||
};
|
||||
|
||||
const schedule = (attempt: number) => {
|
||||
timer = window.setTimeout(() => {
|
||||
void tick(attempt);
|
||||
}, STARTUP_READY_META_RECONCILE_MS);
|
||||
};
|
||||
|
||||
const tick = async (attempt: number) => {
|
||||
if (!stillCurrent()) return;
|
||||
const current = statesRef.current.get(tabId);
|
||||
if (!current?.meta || current.meta.ready || current.meta.startupErr || current.backendActivationPending) return;
|
||||
const nextMeta = await refreshMetaOnlyForTab(tabId, dispatchTo);
|
||||
if (!stillCurrent()) return;
|
||||
if (nextMeta?.ready || nextMeta?.startupErr || attempt + 1 >= STARTUP_READY_META_RECONCILE_ATTEMPTS) return;
|
||||
schedule(attempt + 1);
|
||||
};
|
||||
|
||||
schedule(0);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
};
|
||||
}, [activeTabId, activeState.meta?.ready, activeState.meta?.startupErr, activeState.backendActivationPending, dispatchTo]);
|
||||
|
||||
// Stale-turn watchdog: if the frontend thinks the agent is running but the
|
||||
// turn stream has gone quiet, reconcile with the backend. This catches cases
|
||||
// where the Wails event channel silently drops turn_done after the final
|
||||
@@ -1367,7 +1660,7 @@ export function useController() {
|
||||
}, [activeTabId]);
|
||||
|
||||
const sendToTab = useCallback(async (tabId: string, displayText: string, submitText = displayText, originalText?: string) => {
|
||||
if (!tabId) throw new Error("workspace is still starting");
|
||||
if (!tabId) throw new Error(t("composer.workspaceStarting"));
|
||||
const seq = getOrCreateState(statesRef.current, tabId).seq;
|
||||
const display = displayText.trim();
|
||||
const submit = submitText.trim();
|
||||
@@ -1375,9 +1668,12 @@ export function useController() {
|
||||
dispatchTo(tabId, { type: "user", text: displayText, submitText: display !== submit ? submit : undefined, seq });
|
||||
invalidateCache();
|
||||
try {
|
||||
await (original
|
||||
const submitPromise = original
|
||||
? app.SubmitEditedDisplayToTab(tabId, display, submit, original)
|
||||
: display !== submit ? app.SubmitDisplayToTab(tabId, display, submit) : app.SubmitToTab(tabId, submit));
|
||||
: display !== submit ? app.SubmitDisplayToTab(tabId, display, submit) : app.SubmitToTab(tabId, submit);
|
||||
void submitPromise.catch((error) => {
|
||||
dispatchTo(tabId, { type: "send_failed", error: `Send failed: ${error instanceof Error ? error.message : String(error)}` });
|
||||
});
|
||||
} catch (error) {
|
||||
dispatchTo(tabId, { type: "send_failed", error: `Send failed: ${error instanceof Error ? error.message : String(error)}` });
|
||||
throw error;
|
||||
@@ -1390,16 +1686,17 @@ export function useController() {
|
||||
return sendToTab(tabId, displayText, submitText);
|
||||
}
|
||||
return activeTabFromBackend().then((active) => {
|
||||
if (!active?.id) throw new Error("workspace is still starting");
|
||||
if (!active?.id) throw new Error(t("composer.workspaceStarting"));
|
||||
setActiveTabId(active.id);
|
||||
activeTabIdRef.current = active.id;
|
||||
confirmBackendActiveTab(active.id);
|
||||
dispatchRuntimeStatusForTab(active.id, active);
|
||||
return sendToTab(active.id, displayText, submitText);
|
||||
});
|
||||
}, [activeTabFromBackend, activeTabId, confirmBackendActiveTab, sendToTab]);
|
||||
}, [activeTabFromBackend, activeTabId, confirmBackendActiveTab, dispatchRuntimeStatusForTab, sendToTab]);
|
||||
|
||||
const runShell = useCallback(async (command: string) => {
|
||||
if (!activeTabId) throw new Error("workspace is still starting");
|
||||
if (!activeTabId) throw new Error(t("composer.workspaceStarting"));
|
||||
dispatchTo(activeTabId, { type: "user", text: `!${command}`, seq: getOrCreateState(statesRef.current, activeTabId).seq });
|
||||
try {
|
||||
await app.RunShellForTab(activeTabId, command);
|
||||
@@ -1410,7 +1707,7 @@ export function useController() {
|
||||
}, [activeTabId, dispatchTo]);
|
||||
|
||||
const steer = useCallback(async (text: string) => {
|
||||
if (!activeTabId) throw new Error("workspace is still starting");
|
||||
if (!activeTabId) throw new Error(t("composer.workspaceStarting"));
|
||||
// No optimistic user bubble: rewind/fork map turns by counting user items,
|
||||
// and a steer is not a backend turn — the Steer event's ↪ notice is the
|
||||
// visible confirmation (#3660).
|
||||
@@ -1648,7 +1945,7 @@ export function useController() {
|
||||
try {
|
||||
await app.SetModelForTab(activeTabId, name);
|
||||
} catch (err) {
|
||||
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: t("status.modelSwitchFailed", { err: errorMessage(err) }) });
|
||||
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: modelSwitchNoticeText(err) });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -1660,7 +1957,12 @@ export function useController() {
|
||||
|
||||
const setEffort = useCallback(async (level: string) => {
|
||||
if (!activeTabId) return;
|
||||
await app.SetEffortForTab(activeTabId, level).catch(() => {});
|
||||
try {
|
||||
await app.SetEffortForTab(activeTabId, level);
|
||||
} catch (err) {
|
||||
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: effortSwitchNoticeText(err) });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
dispatchTo(activeTabId, { type: "meta", meta: await app.MetaForTab(activeTabId) });
|
||||
dispatchTo(activeTabId, { type: "context", context: await app.ContextUsageForTab(activeTabId) });
|
||||
@@ -1673,7 +1975,7 @@ export function useController() {
|
||||
try {
|
||||
await app.SetTokenModeForTab(activeTabId, mode);
|
||||
} catch (err) {
|
||||
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: t("status.tokenModeSwitchFailed", { err: errorMessage(err) }) });
|
||||
dispatchTo(activeTabId, { type: "local_notice", level: "warn", text: tokenModeSwitchNoticeText(err) });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -1703,11 +2005,13 @@ export function useController() {
|
||||
setActiveTabId(tab.id);
|
||||
activeTabIdRef.current = tab.id;
|
||||
confirmBackendActiveTab(tab.id);
|
||||
dispatchRuntimeStatusForTab(tab.id, tab);
|
||||
// The fork's controller builds in a background goroutine: an immediate
|
||||
// load reads empty history, and the ready-event fallback can still
|
||||
// target the source tab, leaving the fork blank (#3742).
|
||||
await waitForTabReady(tab.id);
|
||||
await loadSessionDataForTab(tab.id, true);
|
||||
await reconcileTabRuntime(tab.id, { hydrateSessionData: false });
|
||||
} else {
|
||||
await syncActiveTabFromBackend(true);
|
||||
}
|
||||
@@ -1730,11 +2034,12 @@ export function useController() {
|
||||
} finally {
|
||||
dispatchTo(sourceTabId, { type: "message_action_done" });
|
||||
}
|
||||
}, [activeTabId, confirmBackendActiveTab, dispatchTo, loadSessionDataForTab, syncActiveTabFromBackend, waitForBackendActiveTab, waitForTabReady]);
|
||||
}, [activeTabId, confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime, syncActiveTabFromBackend, waitForBackendActiveTab, waitForTabReady]);
|
||||
|
||||
// Tab management: switch preserves per-tab state; open creates it.
|
||||
const switchTab = useCallback(async (tabId: string, optimisticTab?: TabMeta): Promise<TabMeta[] | undefined> => {
|
||||
const startedAt = Date.now();
|
||||
const previousTabId = activeTabIdRef.current;
|
||||
const targetSessionPath = optimisticTab?.sessionPath ?? statesRef.current.get(tabId)?.meta?.sessionPath;
|
||||
const preserveCachedHistory = hasReusableCachedTranscript(statesRef.current.get(tabId), targetSessionPath);
|
||||
addBreadcrumb("tab.switch", `click ${tabId}`);
|
||||
@@ -1743,11 +2048,25 @@ export function useController() {
|
||||
dispatchTo(tabId, { type: "backend_activation_start" });
|
||||
if (optimisticTab) {
|
||||
dispatchTo(tabId, { type: "optimistic_meta", meta: metaFromTab(optimisticTab, statesRef.current.get(tabId)?.meta) });
|
||||
const optimisticStatus = backendStatusFromRuntimeMeta(optimisticTab);
|
||||
if (optimisticStatus.running) dispatchTo(tabId, optimisticStatus);
|
||||
}
|
||||
dispatchTo(tabId, { type: "hydrate_start", reason: "switch-tab" });
|
||||
addBreadcrumb("tab.switch", `active-rendered ${tabId} ms=${Date.now() - startedAt}`);
|
||||
const backendActivation = app.SetActiveTab(tabId)
|
||||
.then(() => {
|
||||
.then(async () => {
|
||||
if (activeTabIdRef.current !== tabId) {
|
||||
const currentTabId = activeTabIdRef.current;
|
||||
if (currentTabId) {
|
||||
await app.SetActiveTab(currentTabId).then(() => {
|
||||
if (activeTabIdRef.current === currentTabId) confirmBackendActiveTab(currentTabId);
|
||||
}).catch((err) => {
|
||||
addBreadcrumb("tab.switch", `set-active-stale-reassert-failed ${currentTabId}: ${errorMessage(err)}`);
|
||||
});
|
||||
}
|
||||
addBreadcrumb("tab.switch", `set-active-stale ${tabId} current=${currentTabId ?? ""} ms=${Date.now() - startedAt}`);
|
||||
return false;
|
||||
}
|
||||
confirmBackendActiveTab(tabId);
|
||||
addBreadcrumb("tab.switch", `set-active-done ${tabId} ms=${Date.now() - startedAt}`);
|
||||
return true;
|
||||
@@ -1755,6 +2074,11 @@ export function useController() {
|
||||
.catch((err) => {
|
||||
dispatchTo(tabId, { type: "backend_activation_done" });
|
||||
dispatchTo(tabId, { type: "hydrate_error", reason: "switch-tab", error: errorMessage(err) });
|
||||
if (previousTabId && activeTabIdRef.current === tabId) {
|
||||
setActiveTabId(previousTabId);
|
||||
activeTabIdRef.current = previousTabId;
|
||||
addBreadcrumb("tab.switch", `set-active-failed-reverted ${tabId} -> ${previousTabId} ms=${Date.now() - startedAt}`);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
trackBackendActivation(tabId, backendActivation);
|
||||
@@ -1786,13 +2110,16 @@ export function useController() {
|
||||
activeTabIdRef.current = meta.id;
|
||||
confirmBackendActiveTab(meta.id);
|
||||
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
|
||||
void loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
|
||||
dispatchRuntimeStatusForTab(meta.id, meta);
|
||||
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
|
||||
placeholderItems: isNewTab ? prevItems : undefined,
|
||||
preserveCachedHistory,
|
||||
sessionPath: meta.sessionPath,
|
||||
});
|
||||
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
|
||||
else void load;
|
||||
return meta;
|
||||
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
|
||||
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
|
||||
|
||||
const openGlobalTab = useCallback(async (topicId: string): Promise<TabMeta> => {
|
||||
const meta = await app.OpenGlobalTab(topicId);
|
||||
@@ -1804,13 +2131,16 @@ export function useController() {
|
||||
activeTabIdRef.current = meta.id;
|
||||
confirmBackendActiveTab(meta.id);
|
||||
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
|
||||
void loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
|
||||
dispatchRuntimeStatusForTab(meta.id, meta);
|
||||
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
|
||||
placeholderItems: isNewTab ? prevItems : undefined,
|
||||
preserveCachedHistory,
|
||||
sessionPath: meta.sessionPath,
|
||||
});
|
||||
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
|
||||
else void load;
|
||||
return meta;
|
||||
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
|
||||
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
|
||||
|
||||
const openTopicSession = useCallback(async (scope: string, workspaceRoot: string, topicId: string, sessionPath: string): Promise<TabMeta> => {
|
||||
const meta = await app.OpenTopicSession(scope, workspaceRoot, topicId, sessionPath);
|
||||
@@ -1822,13 +2152,16 @@ export function useController() {
|
||||
activeTabIdRef.current = meta.id;
|
||||
confirmBackendActiveTab(meta.id);
|
||||
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
|
||||
void loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
|
||||
dispatchRuntimeStatusForTab(meta.id, meta);
|
||||
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic", {
|
||||
placeholderItems: isNewTab ? prevItems : undefined,
|
||||
preserveCachedHistory,
|
||||
sessionPath: meta.sessionPath,
|
||||
});
|
||||
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
|
||||
else void load;
|
||||
return meta;
|
||||
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
|
||||
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
|
||||
|
||||
const activateTopic = useCallback(async (scope: string, workspaceRoot: string, topicId: string, sessionPath = ""): Promise<TabMeta> => {
|
||||
const meta = await app.ActivateTopic(scope, workspaceRoot, topicId, sessionPath);
|
||||
@@ -1842,21 +2175,28 @@ export function useController() {
|
||||
activeTabIdRef.current = meta.id;
|
||||
confirmBackendActiveTab(meta.id);
|
||||
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
|
||||
void loadSessionDataForTab(meta.id, true, "open-topic", { placeholderItems: prevItems });
|
||||
dispatchRuntimeStatusForTab(meta.id, meta);
|
||||
void loadSessionDataForTab(meta.id, true, "open-topic", { placeholderItems: prevItems })
|
||||
.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false }))
|
||||
.catch(() => {});
|
||||
return meta;
|
||||
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
|
||||
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
|
||||
|
||||
// Ensure a blank tab exists for the given scope — reuses an existing one
|
||||
// or creates a new tab, then loads its session data.
|
||||
const ensureBlankTab = useCallback(async (scope: string, workspaceRoot: string): Promise<TabMeta> => {
|
||||
const meta = await app.EnsureBlankTab(scope, workspaceRoot);
|
||||
const isNewTab = !statesRef.current.has(meta.id);
|
||||
setActiveTabId(meta.id);
|
||||
activeTabIdRef.current = meta.id;
|
||||
confirmBackendActiveTab(meta.id);
|
||||
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
|
||||
void loadSessionDataForTab(meta.id, !statesRef.current.has(meta.id), "open-topic");
|
||||
dispatchRuntimeStatusForTab(meta.id, meta);
|
||||
const load = loadSessionDataForTab(meta.id, isNewTab, "open-topic");
|
||||
if (isNewTab) void load.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false })).catch(() => {});
|
||||
else void load;
|
||||
return meta;
|
||||
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
|
||||
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
|
||||
|
||||
const ensureBlankSurface = useCallback(async (scope: string, workspaceRoot: string): Promise<TabMeta> => {
|
||||
const meta = await app.EnsureBlankSurface(scope, workspaceRoot);
|
||||
@@ -1867,16 +2207,19 @@ export function useController() {
|
||||
activeTabIdRef.current = meta.id;
|
||||
confirmBackendActiveTab(meta.id);
|
||||
dispatchTo(meta.id, { type: "optimistic_meta", meta: metaFromTab(meta, statesRef.current.get(meta.id)?.meta) });
|
||||
void loadSessionDataForTab(meta.id, true, "open-topic");
|
||||
dispatchRuntimeStatusForTab(meta.id, meta);
|
||||
void loadSessionDataForTab(meta.id, true, "open-topic")
|
||||
.then(() => reconcileTabRuntime(meta.id, { hydrateSessionData: false }))
|
||||
.catch(() => {});
|
||||
return meta;
|
||||
}, [confirmBackendActiveTab, dispatchTo, loadSessionDataForTab]);
|
||||
}, [confirmBackendActiveTab, dispatchRuntimeStatusForTab, dispatchTo, loadSessionDataForTab, reconcileTabRuntime]);
|
||||
|
||||
const closeTab = useCallback(async (tabId: string) => {
|
||||
try {
|
||||
await app.CloseTab(tabId);
|
||||
statesRef.current.delete(tabId);
|
||||
bump();
|
||||
if (tabId === activeTabId) await syncActiveTabFromBackend(true);
|
||||
if (tabId === activeTabId) await syncActiveTabFromBackend(false);
|
||||
} catch { /* ignore */ }
|
||||
}, [activeTabId, bump, syncActiveTabFromBackend]);
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* WailsWailsFlags mirrors the `window.wails.flags` object injected by the
|
||||
* Wails v2 runtime (see internal/frontend/runtime/desktop/main.js).
|
||||
*/
|
||||
interface WailsFlags {
|
||||
enableResize: boolean;
|
||||
resizeEdge: string | undefined;
|
||||
borderThickness: number;
|
||||
defaultCursor: string | null;
|
||||
cssDragProperty: string;
|
||||
cssDragValue: string;
|
||||
cssDropProperty: string;
|
||||
cssDropValue: string;
|
||||
shouldDrag: boolean;
|
||||
deferDragToMouseMove: boolean;
|
||||
disableScrollbarDrag: boolean;
|
||||
disableDefaultContextMenu: boolean;
|
||||
enableWailsDragAndDrop: boolean;
|
||||
}
|
||||
|
||||
interface WailsWindow {
|
||||
wails: {
|
||||
flags: WailsFlags;
|
||||
Callback: (msg: string) => void;
|
||||
EventsNotify: (msg: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
wails?: WailsWindow["wails"];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* useWailsResizeFix
|
||||
*
|
||||
* Workaround for a Wails v2.12 frameless-window resize detection bug where
|
||||
* `window.outerWidth/outerHeight` (device-independent pixels) is compared
|
||||
* with `e.clientX/e.clientY` (CSS pixels). When WebView2 ZoomFactor ≠ 1
|
||||
* these coordinate spaces diverge, causing the right/bottom resize zone to
|
||||
* extend far inward or disappear entirely.
|
||||
*
|
||||
* This hook disables the built-in mousemove handler (which uses the wrong
|
||||
* coordinate space) and replaces it with one that uses `window.innerWidth`
|
||||
* and `window.innerHeight` — both in CSS pixels, matching `e.clientX/Y`.
|
||||
*
|
||||
* The Wails mousedown handler still works because it only reads
|
||||
* `window.wails.flags.resizeEdge`, which we continue to set here.
|
||||
*
|
||||
* Upstream fix: https://github.com/wailsapp/wails/issues/4590 (Wails v3
|
||||
* sidestepped by clamping zoom ≥ 1.0). Once Wails v2 ships a proper fix
|
||||
* this hook can be deleted.
|
||||
*
|
||||
* @example
|
||||
* // In App.tsx or any component mounted for the app's lifetime:
|
||||
* useWailsResizeFix(desktopPlatform === "windows");
|
||||
*/
|
||||
export function useWailsResizeFix(enabled: boolean): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const wails = window.wails;
|
||||
if (!wails) return; // not inside a Wails webview → no-op
|
||||
|
||||
const flags = wails.flags;
|
||||
const bt = flags.borderThickness ?? 6;
|
||||
const previousEnableResize = flags.enableResize;
|
||||
const previousResizeEdge = flags.resizeEdge;
|
||||
const previousCursor = document.documentElement.style.cursor;
|
||||
|
||||
// Restore the default cursor when we're done — memoize the initial value.
|
||||
const defaultCursor = previousCursor;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
// Both operands in CSS pixels — the bug fix.
|
||||
const iw = window.innerWidth;
|
||||
const ih = window.innerHeight;
|
||||
const cx = e.clientX;
|
||||
const cy = e.clientY;
|
||||
|
||||
const right = iw - cx < bt;
|
||||
const left = cx < bt;
|
||||
const top = cy < bt;
|
||||
const bottom = ih - cy < bt;
|
||||
|
||||
let edge: string | undefined;
|
||||
if (right && bottom) edge = "se-resize";
|
||||
else if (left && bottom) edge = "sw-resize";
|
||||
else if (right && top) edge = "ne-resize";
|
||||
else if (left && top) edge = "nw-resize";
|
||||
else if (right) edge = "e-resize";
|
||||
else if (left) edge = "w-resize";
|
||||
else if (top) edge = "n-resize";
|
||||
else if (bottom) edge = "s-resize";
|
||||
|
||||
if (edge !== flags.resizeEdge) {
|
||||
flags.resizeEdge = edge;
|
||||
document.documentElement.style.cursor = edge ?? (defaultCursor || "");
|
||||
}
|
||||
};
|
||||
|
||||
// Disable Wails' built-in mousemove handler (the one that uses outerWidth).
|
||||
flags.enableResize = false;
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
flags.enableResize = previousEnableResize;
|
||||
flags.resizeEdge = previousResizeEdge;
|
||||
document.documentElement.style.cursor = previousCursor;
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export const en = {
|
||||
"common.busyHint": "Finish or stop the current turn first",
|
||||
"common.loading": "Loading…",
|
||||
"composer.readOnlyChannel": "Channel conversation is read-only",
|
||||
"composer.workspaceStarting": "Workspace is still starting",
|
||||
"app.splashSubtitle": "Agent Workspace",
|
||||
|
||||
// top bar
|
||||
@@ -205,6 +206,7 @@ export const en = {
|
||||
"caps.mcpSummary": "{connected} connected · {failed} issues · {tools} tools",
|
||||
"caps.connectorsTab": "MCP servers",
|
||||
"caps.skillsTab": "Agent skills",
|
||||
"caps.pluginsTab": "Plugins",
|
||||
"caps.servers": "MCP servers",
|
||||
"caps.noServers": "No MCP servers configured. Add one with /mcp add.",
|
||||
"caps.availableServers": "Available MCPs",
|
||||
@@ -311,6 +313,62 @@ export const en = {
|
||||
"caps.trustedReadOnlyTitle": "Remembered as read-only for future planning and research",
|
||||
"caps.searchSkills": "Search skills…",
|
||||
"caps.noSkillMatches": "No matching skills.",
|
||||
"caps.pluginRefresh": "Refresh plugins",
|
||||
"caps.pluginInstallTitle": "Install plugin",
|
||||
"caps.pluginInstallHint": "Add a local plugin folder or install from a Git repository.",
|
||||
"caps.pluginInstallMethod": "Plugin install method",
|
||||
"caps.pluginInstallLocal": "Local folder",
|
||||
"caps.pluginInstallGit": "Git repository",
|
||||
"caps.pluginSource": "Plugin source",
|
||||
"caps.pluginGitSource": "Git repository URL",
|
||||
"caps.pluginSourcePlaceholder": "git:github.com/obra/superpowers",
|
||||
"caps.pluginChooseLocalFolder": "Choose plugin folder",
|
||||
"caps.pluginLocalFolder": "Local plugin folder",
|
||||
"caps.pluginNoLocalFolder": "No folder selected",
|
||||
"caps.pluginInstallName": "Install name (optional)",
|
||||
"caps.pluginInstallNamePlaceholder": "install name (optional)",
|
||||
"caps.pluginLink": "Developer mode: link source folder",
|
||||
"caps.pluginLinkHint": "Do not copy the plugin; use the selected folder directly. Useful for plugin debugging. Moving or deleting the folder breaks the plugin.",
|
||||
"caps.pluginReplace": "Overwrite same-name plugin",
|
||||
"caps.pluginReplaceHint": "When a plugin with the same name already exists, allow this source to replace it.",
|
||||
"caps.pluginPreview": "Preview",
|
||||
"caps.pluginInstall": "Install plugin",
|
||||
"caps.noPluginsTitle": "No plugins installed",
|
||||
"caps.noPluginsHint": "Install a plugin package from Git or a local path to add skills, hooks, and MCP servers through one managed unit.",
|
||||
"caps.installedPlugins": "Installed plugins",
|
||||
"caps.pluginsSummary": "{enabled} enabled · {total} installed · {issues} issues",
|
||||
"caps.pluginCounts": "{skills} skills · {hooks} hooks · {mcps} MCPs",
|
||||
"caps.pluginNoCapabilities": "No exported capabilities",
|
||||
"caps.pluginWarnings": "{count} warnings",
|
||||
"caps.pluginEnable": "Enable plugin",
|
||||
"caps.pluginDisable": "Disable plugin",
|
||||
"caps.pluginEnabled": "Enabled",
|
||||
"caps.pluginDisabled": "Disabled",
|
||||
"caps.pluginVersion": "Version",
|
||||
"caps.pluginRoot": "Install path",
|
||||
"caps.pluginUpdate": "Update",
|
||||
"caps.pluginDoctor": "Doctor",
|
||||
"caps.pluginRemove": "Remove plugin",
|
||||
"caps.pluginConfirmRemove": "Confirm remove",
|
||||
"caps.pluginPlanError": "Install check failed",
|
||||
"caps.pluginPlanReady": "Install plan",
|
||||
"caps.pluginAction": "Action",
|
||||
"caps.pluginPlanStatus": "Plugin operation: {status}",
|
||||
"caps.pluginPlanComplete": "Plugin operation complete",
|
||||
"caps.pluginPlanInstalled": "Plugin installed. Expand it below to see how to use the exported skills, hooks, and MCP servers.",
|
||||
"caps.pluginUsageTitle": "How to use",
|
||||
"caps.pluginUsageEnabledHint": "Enabled plugins load into new sessions. Use /skills to browse skills, invoke /<skill>, or ask naturally; hooks and MCP servers run through the normal session lifecycle.",
|
||||
"caps.pluginUsageDisabledHint": "Enable this plugin before its skills, hooks, or MCP servers can participate in sessions.",
|
||||
"caps.pluginNoCapabilityDetails": "No detailed capability inventory is available for this plugin.",
|
||||
"caps.pluginSkillsTitle": "Skills",
|
||||
"caps.pluginSkillsHint": "Browse with /skills, invoke directly, or ask for the workflow by name.",
|
||||
"caps.pluginHooksTitle": "Hooks",
|
||||
"caps.pluginHooksHint": "Hooks trigger automatically on matching lifecycle events.",
|
||||
"caps.pluginMCPTitle": "MCP servers",
|
||||
"caps.pluginMCPHint": "Tools from these servers are available through the normal MCP/tool flow.",
|
||||
"caps.pluginNoDescription": "No description",
|
||||
"caps.pluginHookNoTarget": "No hook target",
|
||||
"caps.pluginMCPNoTarget": "No launch target",
|
||||
"caps.sources": "Sources",
|
||||
"caps.sourcesSummaryActive": "{active} active",
|
||||
"caps.sourcesSummaryMissing": "{missing} missing",
|
||||
@@ -411,6 +469,17 @@ export const en = {
|
||||
"composer.removeCurrentProjectDisabled": "Current project cannot be removed",
|
||||
"composer.resize": "Drag to resize composer, double-click to reset",
|
||||
"composer.send": "Send (Enter)",
|
||||
"composer.queueGuidance": "Add to guidance queue (Enter)",
|
||||
"composer.steer": "Add guidance to current turn (Enter)",
|
||||
"composer.steerPlaceholder": "Add guidance to the queue...",
|
||||
"composer.guidanceQueue": "Queued guidance",
|
||||
"composer.guidanceCount": "Queued guidance {n}",
|
||||
"composer.guidanceRemaining": "{n} more queued",
|
||||
"composer.guidanceCollapse": "Collapse",
|
||||
"composer.guidanceMode": "Guide",
|
||||
"composer.guidanceSend": "Send this guidance to the transcript",
|
||||
"composer.guidanceDismiss": "Dismiss queued guidance",
|
||||
"composer.guidanceMore": "More guidance options",
|
||||
"composer.shellMode": "Shell mode: prefix input with !",
|
||||
"composer.shellModeOn": "Shell mode on: click to remove !",
|
||||
"composer.stop": "Stop (Esc)",
|
||||
@@ -421,6 +490,9 @@ export const en = {
|
||||
"composer.pastedExpand": "Expand",
|
||||
"composer.pastedRemove": "Remove pasted text",
|
||||
"composer.removeImage": "Remove image",
|
||||
"imageViewer.clickToPreview": "Click to preview",
|
||||
"imageViewer.closePreview": "Close preview",
|
||||
"imageViewer.title": "Image preview",
|
||||
"composer.attachImageFailed": "Image paste failed",
|
||||
"composer.attachFileFailed": "File attach failed",
|
||||
"composer.attachDropFailed": "Dropped file attach failed",
|
||||
@@ -460,8 +532,37 @@ export const en = {
|
||||
"modelSwitcher.searchPlaceholder": "Search models…",
|
||||
"modelSwitcher.noMatches": "No matching models",
|
||||
"status.noModels": "no switchable models",
|
||||
"status.modelSwitchBusy": "The model cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
|
||||
"status.modelSwitchLeaseHeld": "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing models.",
|
||||
"status.modelSwitchStarting": "This session is still starting. Try changing models again in a moment.",
|
||||
"status.modelSwitchStartupFailed": "This session did not start, so the model cannot change: {err}",
|
||||
"status.modelSwitchRetry": "The current session changed while switching models. Try once more.",
|
||||
"status.modelSwitchUnknown": "Unknown model {model}.",
|
||||
"status.modelSwitchProviderUnavailable": "Model {model} is unavailable because provider {provider} is not added.",
|
||||
"status.modelSwitchFailed": "Model switch failed: {err}",
|
||||
"status.modelFallbackSwitched": "Model {model} is no longer available; switched to {fallback}.",
|
||||
"status.effortSwitchBusy": "Reasoning effort cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
|
||||
"status.effortSwitchLeaseHeld": "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing effort.",
|
||||
"status.effortSwitchStarting": "This session is still starting. Try changing reasoning effort again in a moment.",
|
||||
"status.effortSwitchStartupFailed": "This session did not start, so reasoning effort cannot change: {err}",
|
||||
"status.effortSwitchRetry": "The current session changed while switching reasoning effort. Try once more.",
|
||||
"status.effortSwitchFailed": "Reasoning effort switch failed: {err}",
|
||||
"status.tokenModeSwitchBusy": "Token saver cannot change yet. Stop the current answer, handle pending prompts, or wait for background jobs to finish.",
|
||||
"status.tokenModeSwitchLeaseHeld": "This session is open in another Reasonix window or still running in the background. Close that window, stop the background run, or open a copy before changing token saver.",
|
||||
"status.tokenModeSwitchStarting": "This session is still starting. Try changing token saver again in a moment.",
|
||||
"status.tokenModeSwitchStartupFailed": "This session did not start, so token saver cannot change: {err}",
|
||||
"status.tokenModeSwitchRetry": "The current session changed while switching token saver. Try once more.",
|
||||
"status.tokenModeSwitchFailed": "Token saver switch failed: {err}",
|
||||
"status.sessionAutosaveFailed": "Session autosave failed: {err}",
|
||||
"status.sessionSaveFailedBefore": "Session save failed before {action}: {err}",
|
||||
"status.actionChangingModel": "changing models",
|
||||
"status.actionChangingEffort": "changing reasoning effort",
|
||||
"status.actionChangingTokenMode": "changing token saver",
|
||||
"status.actionRebuildingSettings": "refreshing settings",
|
||||
"status.actionSwitchingSessions": "switching sessions",
|
||||
"status.actionSwitchingTabs": "switching tabs",
|
||||
"status.actionAutosave": "autosave",
|
||||
"status.actionCurrentSession": "saving the current session",
|
||||
"status.effort": "effort {level}",
|
||||
"status.effortTitle": "Reasoning effort",
|
||||
"status.effortAutoTitle": "Reasoning effort: auto (model default: {def})",
|
||||
@@ -545,6 +646,11 @@ export const en = {
|
||||
"heartbeat.approvalModeAskHint": "Each tool call needs manual confirmation",
|
||||
"heartbeat.approvalModeAutoHint": "Auto-approve safe tools, keep deny rules",
|
||||
"heartbeat.approvalModeYoloHint": "Skip all permission prompts, tasks run automatically",
|
||||
"heartbeat.notifyChannels": "Push to bot channels",
|
||||
"heartbeat.notifyChannelsOn": "On",
|
||||
"heartbeat.notifyChannelsOff": "Off",
|
||||
"heartbeat.notifyChannelsOnHint": "AI output is streamed to connected Feishu/QQ bots",
|
||||
"heartbeat.notifyChannelsOffHint": "Only create a desktop conversation; do not push to bots",
|
||||
"heartbeat.fieldNewConversation": "Conversation mode",
|
||||
"heartbeat.newConversationEachRunOn": "New conversation each run",
|
||||
"heartbeat.newConversationEachRunOff": "Reuse same conversation",
|
||||
@@ -581,6 +687,29 @@ export const en = {
|
||||
"approval.allowRulePersistent": "Always allow (save)",
|
||||
"approval.allowEditsSession": "Allow edits for session",
|
||||
"approval.allowFilePersistent": "Always allow this file (save)",
|
||||
"approval.allowSandboxEscapeSession": "Use real environment for this session",
|
||||
"approval.toolLabelBash": "bash",
|
||||
"approval.toolLabelEditFile": "edit file",
|
||||
"approval.toolLabelWriteFile": "write file",
|
||||
"approval.toolLabelMultiEdit": "multi edit",
|
||||
"approval.toolLabelMoveFile": "move file",
|
||||
"approval.toolLabelWebFetch": "web fetch",
|
||||
"approval.toolLabelRunSkill": "run skill",
|
||||
"approval.toolLabelRemember": "remember",
|
||||
"approval.toolLabelForget": "forget",
|
||||
"approval.toolLabelSandboxEscape": "bash sandbox escape",
|
||||
"approval.toolLabelPlanModeReadOnly": "plan-mode read-only command",
|
||||
"approval.memorySaveUpdate": "Save/update memory",
|
||||
"approval.memoryBodyLabel": "body",
|
||||
"approval.memoryArchivePrefix": "Archive memory ",
|
||||
"approval.planModeMcpTrustSubject": "MCP {target} as read-only for planning and research",
|
||||
"approval.planModeMcpTrustReason": "This MCP tool reports read-only, but external read-only hints need your confirmation before plan mode can use them. Choose always allow to remember this trust for future planning and read-only research.",
|
||||
"approval.planModeBashTrustSubject": "Trust {prefix} as a read-only command prefix while planning\nCommand: {command}",
|
||||
"approval.planModeBashTrustReason": "This bash command is not in Reasonix's built-in read-only set. Confirm only if this exact prefix is read-only for planning and research. Auto/YOLO approval cannot answer this trust prompt.",
|
||||
"approval.sandboxEscapeSubjectFallback": "run shell command unconfined once",
|
||||
"approval.sandboxEscapeSubjectPrefix": "run unconfined once: ",
|
||||
"approval.sandboxEscapeWrapReason": "Windows sandbox could not wrap this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
|
||||
"approval.sandboxEscapeRuntimeReason": "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
|
||||
|
||||
// ask card
|
||||
"ask.title": "Need your decision",
|
||||
@@ -589,6 +718,7 @@ export const en = {
|
||||
"ask.hideDetails": "Hide",
|
||||
"ask.back": "Back",
|
||||
"ask.next": "Next",
|
||||
"ask.customAnswer": "Other answer",
|
||||
"ask.customPlaceholder": "Type your own answer…",
|
||||
"ask.justChat": "Just chat",
|
||||
|
||||
@@ -649,11 +779,25 @@ export const en = {
|
||||
"history.failedOpenProject": "Cannot open session, \"{name}\" does not exist: {path}",
|
||||
"history.failedOpenSession": "Cannot open session",
|
||||
"history.failedOpenGlobal": "Cannot open global session",
|
||||
"history.missingWorkspaceRoot": "Cannot open session because its workspace path is missing.",
|
||||
"history.selectSession": "Select a session to preview.",
|
||||
"history.trashSessionActions": "Trash session actions",
|
||||
"history.historySessionActions": "History session actions",
|
||||
"history.trashActions": "Trash actions",
|
||||
|
||||
// session recovery
|
||||
"recovery.branch": "Conflict copy",
|
||||
"recovery.badge": "Copy",
|
||||
"recovery.open": "Open conflict copy",
|
||||
"recovery.toast": "A save conflict was detected, so a conflict copy was kept for {title}.",
|
||||
"recovery.failedLease": "The conflict copy is in use by another runtime, so this tab did not switch to it.",
|
||||
"recovery.failedUnavailable": "The conflict copy could not be opened, so this tab stayed on the original session.",
|
||||
"recovery.banner": "This session is a save-conflict copy. Compare it with the original before continuing.",
|
||||
"recovery.noticeSavedCopy": "The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
|
||||
"recovery.noticeKeptCurrent": "Repeated save conflicts were detected, so the current conflict copy was saved in place.",
|
||||
"recovery.noticeAdopted": "The session changed on disk, so Reasonix adopted the newer transcript.",
|
||||
"recovery.noticeAdoptedCovered": "The session changed on disk, so Reasonix adopted the newer transcript; the local changes were already covered.",
|
||||
|
||||
// project tree
|
||||
"projectTree.workspaceTitle": "Projects",
|
||||
"projectTree.pinnedTitle": "Pinned",
|
||||
@@ -668,6 +812,7 @@ export const en = {
|
||||
"projectTree.renameProjectWorkbench": "Rename project",
|
||||
"projectTree.projectHistory": "View project history",
|
||||
"projectTree.justNow": "just now",
|
||||
"projectTree.previously": "previously",
|
||||
"projectTree.running": "responding",
|
||||
"projectTree.status.thinking": "thinking",
|
||||
"projectTree.status.streaming": "responding",
|
||||
@@ -827,6 +972,7 @@ export const en = {
|
||||
"settings.tab.capabilities": "MCP & Skills",
|
||||
"settings.tab.mcp": "MCP & Tools",
|
||||
"settings.tab.skills": "Skills",
|
||||
"settings.tab.plugins": "Plugins",
|
||||
"settings.tab.memory": "Memory",
|
||||
"settings.tab.hooks": "Hooks",
|
||||
"settings.tab.shortcuts": "Shortcuts",
|
||||
@@ -842,6 +988,7 @@ export const en = {
|
||||
"settings.tabSub.capabilities": "Servers, tools & skills",
|
||||
"settings.tabSub.mcp": "Servers & tools",
|
||||
"settings.tabSub.skills": "Sources & enablement",
|
||||
"settings.tabSub.plugins": "Packages & diagnostics",
|
||||
"settings.tabSub.memory": "Project & global",
|
||||
"settings.tabSub.hooks": "Shell automation",
|
||||
"settings.tabSub.shortcuts": "Keys & help",
|
||||
@@ -923,6 +1070,10 @@ export const en = {
|
||||
"settings.subagentEffort": "Subagent effort",
|
||||
"settings.subagentEffortDefault": "auto (provider default)",
|
||||
"settings.subagentHint": "Applies to task and runAs=subagent skills unless a tool call or skill-specific config overrides it.",
|
||||
"settings.subagentDepth": "Subagent nesting depth",
|
||||
"settings.subagentDepthHint": "Controls whether a first-layer subagent may dispatch one more reviewer or worker subagent.",
|
||||
"settings.subagentDepthOne": "1 layer",
|
||||
"settings.subagentDepthTwo": "2 layers",
|
||||
"settings.defaultToolApprovalMode": "New session approval",
|
||||
"settings.defaultToolApprovalModeHint": "Saved to your global settings file and used only for new sessions; switching or restoring an existing session will not overwrite its Ask / Auto / Yolo state.",
|
||||
"settings.defaultToolApprovalMode.ask": "Ask",
|
||||
@@ -945,7 +1096,7 @@ export const en = {
|
||||
"settings.coldResumePrune.on": "On",
|
||||
"settings.coldResumePrune.off": "Off",
|
||||
"settings.reasoningLanguage": "Thinking language",
|
||||
"settings.reasoningLanguageHint": "Affects visible reasoning only. Auto follows the conversation language without injecting a language instruction.",
|
||||
"settings.reasoningLanguageHint": "Affects visible reasoning only. Auto anchors clear Chinese prompts; referenced context is ignored.",
|
||||
"settings.reasoningLanguage.auto": "Auto",
|
||||
"settings.reasoningLanguage.zh": "中文",
|
||||
"settings.reasoningLanguage.en": "English",
|
||||
@@ -1006,7 +1157,7 @@ export const en = {
|
||||
"settings.providerModelsUrlPlaceholder": "e.g. https://api.openai.com/v1/models",
|
||||
"settings.providerModelsUrlHint": "Optional. Use when model discovery needs a different URL than the chat endpoint.",
|
||||
"settings.providerModels": "models (comma-separated)",
|
||||
"settings.providerApiKeyEnv": "api_key_env (e.g. DEEPSEEK_API_KEY)",
|
||||
"settings.providerApiKeyEnv": "api_key_env (optional)",
|
||||
"settings.providerBalanceUrl": "Balance URL (optional)",
|
||||
"settings.balanceUrlPlaceholder": "balance endpoint (e.g. https://api.deepseek.com/user/balance)",
|
||||
"settings.balanceUrlHint": "Optional. If set, the wallet balance is queried and shown in the status bar.",
|
||||
@@ -1016,12 +1167,22 @@ export const en = {
|
||||
"settings.providerReasoningProtocol": "reasoning protocol",
|
||||
"settings.providerReasoningProtocolAuto": "auto (model and endpoint detection)",
|
||||
"settings.providerReasoningProtocolHint": "For OpenAI-compatible reasoning models. deepseek uses DeepSeek reasoning fields, openai uses ordinary reasoning_effort, and none disables it.",
|
||||
"settings.effortBehavior": "Effort behavior",
|
||||
"settings.effortUseProtocolDefaults": "Protocol defaults",
|
||||
"settings.effortUseCustom": "Customize levels",
|
||||
"settings.effortProtocolDefault.auto": "Protocol defaults stay stored as empty supported_efforts. Reasonix will infer known DeepSeek models/endpoints as high|max, OpenAI reasoning as low|medium|high, and unsupported providers as no /effort.",
|
||||
"settings.effortProtocolDefault.deepseek": "DeepSeek defaults expose auto|high|max. Reasonix keeps thinking enabled and uses reasoning_effort for depth.",
|
||||
"settings.effortProtocolDefault.openai": "OpenAI reasoning defaults expose auto|low|medium|high and send ordinary reasoning_effort.",
|
||||
"settings.effortProtocolDefault.minimax": "MiniMax-style endpoints use provider-specific thinking levels such as adaptive|disabled. Add those as custom levels only when the endpoint supports them.",
|
||||
"settings.effortProtocolDefault.none": "Effort is disabled for this provider; Reasonix will not send reasoning_effort or thinking controls.",
|
||||
"settings.supportedEfforts": "Supported efforts",
|
||||
"settings.supportedEffortsCustomPlaceholder": "Custom level (press Enter)",
|
||||
"settings.supportedEffortsHint": "Toggle the /effort levels this provider supports, or add a custom name. Empty uses the built-in default (DeepSeek: high|max, Anthropic: low|medium|high|xhigh|max).",
|
||||
"settings.supportedEffortsNoPresets": "No standard presets",
|
||||
"settings.supportedEffortsNoPresetsHint": "Use custom levels only for endpoints that document their own effort names.",
|
||||
"settings.supportedEffortsHint": "Custom levels override protocol defaults. Pick only levels the endpoint documents; unsupported combinations can make the model request fail.",
|
||||
"settings.defaultEffort": "Default effort",
|
||||
"settings.defaultEffortAuto": "auto (use built-in default)",
|
||||
"settings.defaultEffortHint": "Used when /effort is \"auto\" or unset. Must be one of the levels above. Empty falls back to the first level (or the built-in default when none are selected).",
|
||||
"settings.defaultEffortAuto": "unset (use protocol/custom default)",
|
||||
"settings.defaultEffortHint": "Used when /effort is \"auto\" or unset. Leave unset to use the protocol default, or the first custom level when levels are customized.",
|
||||
"settings.proxyMode": "Proxy mode",
|
||||
"settings.proxyMode.auto": "auto",
|
||||
"settings.proxyMode.env": "env",
|
||||
@@ -1042,11 +1203,76 @@ export const en = {
|
||||
"settings.toggleOff": "Off",
|
||||
"settings.botSetup": "Bot entry",
|
||||
"settings.botSetupHint": "Enable the entry, then connect the chat channels you need.",
|
||||
"settings.botConnectPhoneTitle": "Add IM bot",
|
||||
"settings.botConnectPhoneSubtitle": "Manual setup for QQ, QR connection for Feishu, Lark, and WeChat.",
|
||||
"settings.botGateway": "Bot gateway",
|
||||
"settings.botGatewayHint": "Manage the IM bot gateway, model, and runtime parameters from the desktop app.",
|
||||
"settings.botGatewayHint": "Manage the IM bot gateway master switch and connection status from the desktop app.",
|
||||
"settings.botBrowserPreviewWarning": "This is a browser preview. It saves mock settings only and does not write local config or start the QQ/IM bot gateway. Configure it in the desktop app or CLI before testing from QQ.",
|
||||
"settings.botSimpleEyebrow": "Guided setup",
|
||||
"settings.botSimpleTitle": "Mobile access and chat bots",
|
||||
"settings.botSimpleHint": "Scan from a phone to control the current workspace. For longer-lived access from chat apps, pick a Bot Channel and configure it.",
|
||||
"settings.botMobileRemoteEyebrow": "Separate entry",
|
||||
"settings.botMobileRemoteTitle": "Mobile remote control",
|
||||
"settings.botMobileRemoteHint": "Scan or copy a link to open the current workspace control entry from a mobile device. This is configured separately from IM bot channels.",
|
||||
"settings.botMobileRemoteControlApi": "Control link",
|
||||
"settings.botMobileRemoteDisabled": "Enable it to generate the QR code and link",
|
||||
"settings.botMobileRemoteEnableFirst": "Enable to generate QR",
|
||||
"settings.botMobileRemoteReady": "Waiting for phone connection",
|
||||
"settings.botMobileRemoteWaiting": "Connect by phone scan",
|
||||
"settings.botMobileRemoteLoopbackNotice": "The control API is still bound to local loopback, so this is for local preview for now. Cross-device relay can reuse this entry later.",
|
||||
"settings.botMobileRemoteCopyLink": "Copy link",
|
||||
"settings.botMobileRemoteCopied": "Copied",
|
||||
"settings.botMobileRemoteCopyFailed": "Copy failed",
|
||||
"settings.botChannelEntryTitle": "Use Bot Channel",
|
||||
"settings.botChannelEntryHint": "Connect chat bots for longer mobile sessions.",
|
||||
"settings.botManageBots": "Bot management",
|
||||
"settings.botManageBotsHint": "Choose a channel on the left. If it is not configured, scan or add keys; once configured, manage that bot's runtime settings on the right.",
|
||||
"settings.botBackToOverview": "Back to entry",
|
||||
"settings.botChannelOpenConfigured": "Configured. You can continue editing settings.",
|
||||
"settings.botChannelNeedsSetup": "Not configured",
|
||||
"settings.botChannelTabsLabel": "Bot channels",
|
||||
"settings.botChannelSetupTitle": "Configure {provider}",
|
||||
"settings.botChannelSetupHint": "Before setup, only credentials or scan binding are needed. After it succeeds, this becomes the bot's settings page.",
|
||||
"settings.botSetupStatus": "Bot setup status",
|
||||
"settings.botSetupStatusConnected": "{online}/{total} online",
|
||||
"settings.botSetupStatusAccessTrusted": "Trusted list {count}",
|
||||
"settings.botSetupStatusAccessEveryone": "Everyone allowed",
|
||||
"settings.botSetupStatusAccessPerBot": "{configured}/{total} bots configured",
|
||||
"settings.botMode": "Bot settings mode",
|
||||
"settings.botModeSimple": "Simple",
|
||||
"settings.botModeAdvanced": "Advanced",
|
||||
"settings.botShowAdvancedSettings": "Advanced settings",
|
||||
"settings.botHideAdvancedSettings": "Hide advanced settings",
|
||||
"settings.botAdvancedSettingsHint": "Lower-frequency controls: routes, queue details, and bot echo filtering.",
|
||||
"settings.botShowAddBot": "Add channel",
|
||||
"settings.botStepConnectTitle": "Connect bots",
|
||||
"settings.botStepConnectHint": "Manual keys for QQ; scan a QR code for Feishu, Lark, and WeChat.",
|
||||
"settings.botStepConnectEmpty": "Not connected yet",
|
||||
"settings.botSharedAllowlistHint": "Every channel shares the trusted list and pairing settings from step 2. Edit once and it applies everywhere.",
|
||||
"settings.botSharedAllowlistGo": "Edit in step 2",
|
||||
"settings.botGlobalAllowlist": "Legacy global allowlist",
|
||||
"settings.botGlobalAllowlistHint": "Compatibility for older configs; new bots should set access in their own detail panel.",
|
||||
"settings.botAccessShowAllPlatforms": "Show all platforms",
|
||||
"settings.botAccessShowConnectedOnly": "Show connected platforms only",
|
||||
"settings.botListUsers": "Users",
|
||||
"settings.botListGroups": "Groups",
|
||||
"settings.botListApprovers": "Approvers",
|
||||
"settings.botListAdmins": "Admins",
|
||||
"settings.botPlatformFeishuLark": "Feishu / Lark",
|
||||
"settings.botQueueDropLabel": "When the queue is full",
|
||||
"settings.botQueueDropHint": "How overflow messages are handled once the queue hits its cap.",
|
||||
"settings.botPairingDetailHint": "The pairing switch lives in step 2 (Who can use it). Tune only the code lifetime and pending cap here.",
|
||||
"settings.botSimpleAccessTitle": "Who can use it",
|
||||
"settings.botSimpleAccessTrustedSummary": "Trusted list is active with {count} member or group entries.",
|
||||
"settings.botSimpleAccessEveryoneSummary": "Everyone can use it right now. Best for short tests only.",
|
||||
"settings.botAccessTrusted": "Trusted list",
|
||||
"settings.botAccessTrustedHint": "Only listed users or groups can call the bot.",
|
||||
"settings.botAccessEveryone": "Everyone",
|
||||
"settings.botAccessEveryoneHint": "Convenient for quick testing, but not recommended long term.",
|
||||
"settings.botAccessPairing": "Pair private chats",
|
||||
"settings.botAccessPairingHint": "Unknown direct messages get a code and wait for local approval.",
|
||||
"settings.botRoleAccess": "Admins and approvers",
|
||||
"settings.botRoleAccessHint": "Optional. Leave this empty unless remote approval or management is needed.",
|
||||
"settings.botQueueModeSimple": "When a new message arrives",
|
||||
"settings.botQueueModeSimpleHint": "Choose what happens when the bot is already handling a previous message.",
|
||||
"settings.saveBotSettings": "Save bot settings",
|
||||
"settings.botEnableBot": "Enable bot",
|
||||
"settings.botGatewayMasterSwitch": "Master switch",
|
||||
@@ -1057,8 +1283,48 @@ export const en = {
|
||||
"settings.botModelHint": "Leave empty to use the default model.",
|
||||
"settings.botModelDefault": "Use default model",
|
||||
"settings.botRuntime": "Runtime limits",
|
||||
"settings.botRuntimeHint": "Gateway-level limits that control how far each bot task can run.",
|
||||
"settings.botMaxSteps": "Max steps",
|
||||
"settings.botDebounceMs": "Debounce ms",
|
||||
"settings.botQueueCap": "Queue cap",
|
||||
"settings.botQueueMode.steer": "Steer running turn",
|
||||
"settings.botQueueMode.followup": "Follow up",
|
||||
"settings.botQueueMode.collect": "Collect",
|
||||
"settings.botQueueMode.interrupt": "Interrupt",
|
||||
"settings.botQueueDrop.summarize": "Summarize overflow",
|
||||
"settings.botQueueDrop.old": "Drop oldest",
|
||||
"settings.botQueueDrop.new": "Drop newest",
|
||||
"settings.botIgnoreSelfMessages": "Ignore bot echoes",
|
||||
"settings.botIgnoreSelfMessagesHint": "Prevents the gateway from reacting to messages sent by configured bot identities.",
|
||||
"settings.botSelfUserIds": "Bot self IDs",
|
||||
"settings.botSelfUserIdsHint": "Optional user/open IDs for each platform, used to filter bot echo messages.",
|
||||
"settings.botPairing": "Private chat pairing",
|
||||
"settings.botPairingTTL": "TTL minutes",
|
||||
"settings.botPairingMaxPending": "Max pending",
|
||||
"settings.botControlApi": "Local control API",
|
||||
"settings.botControlApiHint": "Loopback HTTP API for status and controlled outbound sends; requires a Bearer token from the named env var.",
|
||||
"settings.botControlAddr": "Control address",
|
||||
"settings.botControlTokenEnv": "Token env",
|
||||
"settings.botGatewayDefaults": "Gateway runtime settings",
|
||||
"settings.botGatewayDefaultsHint": "Tune queueing, pairing, and self-message filtering without editing TOML.",
|
||||
"settings.botRoutes": "Bind chats to projects",
|
||||
"settings.botRoutesHint": "Pin a group, contact, or thread to a workspace, model, or approval mode.",
|
||||
"settings.botRoutesEmpty": "No bindings yet. Add one to bind a chat, user, or thread to a project or model.",
|
||||
"settings.botRouteTitle": "Route {n}",
|
||||
"settings.botAddRoute": "Add route",
|
||||
"settings.botRouteConnection": "Bot source",
|
||||
"settings.botRouteAny": "Any",
|
||||
"settings.botRoutePlatform": "Platform",
|
||||
"settings.botRouteChatType": "Chat type",
|
||||
"settings.botRouteChatType.any": "Any",
|
||||
"settings.botRouteChatType.dm": "DM",
|
||||
"settings.botRouteChatType.group": "Group",
|
||||
"settings.botRouteChatType.guild": "Guild",
|
||||
"settings.botRouteChatType.direct": "Direct",
|
||||
"settings.botRouteChatType.thread": "Thread",
|
||||
"settings.botRouteChatId": "Group/chat ID",
|
||||
"settings.botRouteUserId": "Contact ID",
|
||||
"settings.botRouteThreadId": "Thread ID",
|
||||
"settings.botChannels": "Connect channels",
|
||||
"settings.botChannelsHint": "Pick a channel; QQ uses manual setup, while Feishu, Lark, and WeChat connect by QR.",
|
||||
"settings.botQQHint": "QQ official Bot API v2.",
|
||||
@@ -1113,8 +1379,7 @@ export const en = {
|
||||
"settings.botInstallLarkHint": "Scan to create a Lark PersonalAgent.",
|
||||
"settings.botInstallWeixinHint": "Scan WeChat and save a local token.",
|
||||
"settings.botConnectedBots": "Connected bots",
|
||||
"settings.botConnectedBotsSummary": "{online}/{total} online",
|
||||
"settings.botConnectionsEmpty": "No bot channel is connected yet.",
|
||||
"settings.botSelectBotHint": "Select a bot on the left to configure it.",
|
||||
"settings.botConnectionColumnChannel": "Channel",
|
||||
"settings.botConnectionColumnName": "Bot",
|
||||
"settings.botConnectionColumnRemote": "Remote ID",
|
||||
@@ -1132,16 +1397,17 @@ export const en = {
|
||||
"settings.botConnectionConnected": "Connected",
|
||||
"settings.botConnectionConfigured": "Configured",
|
||||
"settings.botConnectionDisconnected": "Disconnected",
|
||||
"settings.botManage": "Advanced settings",
|
||||
"settings.botManage": "Edit config",
|
||||
"settings.deleteBot": "Delete bot",
|
||||
"settings.deleteBotShort": "Delete",
|
||||
"settings.confirmDeleteBot": "Confirm delete",
|
||||
"settings.deleteBotHint": "Removes this bot from connected bots; saved secrets are not cleared automatically.",
|
||||
"settings.botChannelModel": "Model",
|
||||
"settings.botChannelModelHint": "Leave empty to use the bot default model.",
|
||||
"settings.botChannelModelAuto": "Auto",
|
||||
"settings.botChannelModelHint": "The model this bot uses when handling messages.",
|
||||
"settings.botChannelModelAuto": "Use current desktop model",
|
||||
"settings.botToolApprovalMode": "Tool approval",
|
||||
"settings.botToolApprovalModeHint": "Controls whether this channel asks before running tools; plan confirmation and ask questions still wait for a reply.",
|
||||
"settings.botToolApprovalMode.inherit": "Inherit",
|
||||
"settings.botToolApprovalMode.inherit": "Follow bot",
|
||||
"settings.botToolApprovalMode.ask": "Ask",
|
||||
"settings.botToolApprovalMode.auto": "Auto",
|
||||
"settings.botToolApprovalMode.yolo": "YOLO",
|
||||
@@ -1164,26 +1430,17 @@ export const en = {
|
||||
"settings.botDiagnosticReportFailed": "Failed to send diagnostic report.",
|
||||
"settings.botDiagnosticPrivacy": "Only sanitized connection metadata is included.",
|
||||
"settings.botAccessControl": "Access control",
|
||||
"settings.botAccessControlHint": "Whitelist is the default; switch to everyone for quick testing.",
|
||||
"settings.botAccessMode": "Who can use it",
|
||||
"settings.botAccessAll": "Everyone",
|
||||
"settings.botAccessWhitelist": "Whitelist only",
|
||||
"settings.botEditAllowlist": "Edit allowlist",
|
||||
"settings.botAllowlist": "Allowlist",
|
||||
"settings.botAllowlistHint": "Restrict which users or groups can invoke the bot.",
|
||||
"settings.botAllowlistEnabled": "Enable allowlist",
|
||||
"settings.botAllowAll": "Allow everyone",
|
||||
"settings.botAllowAllWarn": "When enabled, every incoming channel message can invoke the bot.",
|
||||
"settings.botQQAccessRequired": "Add a QQ user/group allowlist, or temporarily allow everyone in Advanced settings, before enabling QQ Bot.",
|
||||
"settings.botAllowlistEntries": "Users and groups",
|
||||
"settings.botQQAccessRequired": "Add QQ users/groups in step 2 (Who can use it), or temporarily allow everyone, before enabling QQ Bot.",
|
||||
"settings.botQQUsers": "QQ users",
|
||||
"settings.botQQGroups": "QQ groups",
|
||||
"settings.botFeishuUsers": "Feishu users",
|
||||
"settings.botFeishuGroups": "Feishu groups",
|
||||
"settings.botFeishuLarkUsers": "Feishu / Lark users",
|
||||
"settings.botFeishuLarkGroups": "Feishu / Lark groups",
|
||||
"settings.botWeixinUsers": "WeChat users",
|
||||
"settings.botWeixinGroups": "WeChat groups",
|
||||
"settings.botListPlaceholder": "One ID per line, or comma-separated",
|
||||
"settings.permissions": "Permissions",
|
||||
"settings.writerMode": "Writer mode",
|
||||
@@ -1195,17 +1452,23 @@ export const en = {
|
||||
"settings.yoloHint": "YOLO-approves approval-gated tool calls (writers, bash). Deny rules still block, and ask questions plus plan approvals still wait for you. Runtime-only — resets on relaunch.",
|
||||
"settings.addRule": "add {list} rule…",
|
||||
"settings.sandboxTitle": "Sandbox & workspace",
|
||||
"settings.sandboxBoundaryHint": "Sandbox write roots limit where approved Bash and file tools can write; they do not auto-approve commands. If Bash sandbox is enforced but no OS backend is available, shell commands are refused; switch Bash sandbox off only for trusted workspaces.",
|
||||
"settings.reloadSessionConfig": "Apply manual config changes",
|
||||
"settings.reloadSessionConfigHint": "Changes made in Settings apply automatically; use this only after manually editing config.toml or reasonix.toml.",
|
||||
"settings.shellInterpreter": "Shell interpreter",
|
||||
"settings.shellAuto": "auto (bash, else PowerShell on Windows)",
|
||||
"settings.shellAuto": "auto (bash first)",
|
||||
"settings.shellBash": "bash",
|
||||
"settings.shellPowershell": "PowerShell (Windows PowerShell)",
|
||||
"settings.shellPwsh": "pwsh (PowerShell 7+)",
|
||||
"settings.bashSandbox": "Bash sandbox",
|
||||
"settings.bashEnforce": "enforce (jail bash)",
|
||||
"settings.bashEnforce": "enforce (jail bash; fail closed)",
|
||||
"settings.bashOff": "off (run unconfined)",
|
||||
"settings.allowNetwork": "Allow network egress from sandboxed bash",
|
||||
"settings.workspaceRoot": "Workspace root",
|
||||
"settings.workspaceDefault": "(default: cwd)",
|
||||
"settings.effectiveWriteRoots": "Effective write roots",
|
||||
"settings.effectiveWriteRootsHint": "Expanded for the active workspace. Settings changes apply automatically; use the button above after manually editing config files.",
|
||||
"settings.noEffectiveWriteRoots": "No active write roots",
|
||||
"settings.appearance": "Appearance",
|
||||
"settings.theme": "Theme",
|
||||
"settings.themeStyle": "Visual style",
|
||||
@@ -1290,6 +1553,10 @@ export const en = {
|
||||
"settings.textSizeLarge": "Large",
|
||||
"settings.textSizeXLarge": "Extra large",
|
||||
"settings.textSizeXXLarge": "Huge",
|
||||
"settings.displayZoom": "Display zoom (restart required)",
|
||||
"settings.displayZoomDecrease": "Decrease display zoom",
|
||||
"settings.displayZoomIncrease": "Increase display zoom",
|
||||
"settings.displayZoomReset": "Reset display zoom to 100%",
|
||||
"settings.fontFamily": "Interface font",
|
||||
"settings.fontFamilySystem": "System",
|
||||
"settings.fontFamilyYaHei": "YaHei",
|
||||
@@ -1312,6 +1579,7 @@ export const en = {
|
||||
"settings.pageDesc.bots": "Configure QQ, Feishu, Lark, and WeChat bot channels, including each bot's model and runtime parameters.",
|
||||
"settings.pageDesc.mcp": "Manage MCP server connections and tool discovery.",
|
||||
"settings.pageDesc.skills": "Browse, enable, and manage agent skills and skill sources.",
|
||||
"settings.pageDesc.plugins": "Install and manage plugin packages that can provide skills, hooks, and MCP servers.",
|
||||
"settings.pageDesc.memory": "Manage preferences, project conventions, and instruction files Reasonix keeps referencing.",
|
||||
"settings.pageDesc.hooks": "Configure shell hooks around turns, tool calls, compaction, and session lifecycle.",
|
||||
"settings.pageDesc.shortcuts": "Customize keyboard shortcuts and keep the help sheet in sync.",
|
||||
@@ -1323,10 +1591,19 @@ export const en = {
|
||||
"settings.modelUsage": "Model usage",
|
||||
"settings.modelUnavailable": "{ref} is not available. Pick a configured model or add provider access.",
|
||||
"settings.modelNeedsKey": "{provider} needs an API key before this model can run.",
|
||||
"settings.errorUnknown": "Operation failed.",
|
||||
"settings.errorUnknownModel": "Unknown model {model}.",
|
||||
"settings.errorModelProviderMissing": "Model {model} is unavailable because provider {provider} is not added.",
|
||||
"settings.errorModelProviderNoKey": "Model {model} is unavailable because provider {provider} has no API key.",
|
||||
"settings.errorRemoveAccessBusy": "Stop active work using {provider} before removing provider access.",
|
||||
"settings.errorDeleteProviderBusy": "Stop active work using {provider} before deleting the provider.",
|
||||
"settings.errorSaveBeforeRemoveAccess": "Could not save the current session before removing provider access: {err}",
|
||||
"settings.errorSaveBeforeDeleteProvider": "Could not save the current session before deleting the provider: {err}",
|
||||
"settings.errorRemoveProviderNoFallback": "Provider {provider} is used by open tabs, and no other configured provider is available.",
|
||||
"settings.customProviders": "Custom providers",
|
||||
"settings.customProvidersHint": "OpenAI-compatible or proxy providers you add yourself. These can be edited, deleted, and updated with fetched model lists.",
|
||||
"settings.customProvidersEmpty": "No custom providers yet. Prefer the built-in access above for common services.",
|
||||
"settings.providerAccessEmptyHint": "Click “Add provider” to choose DeepSeek or a custom OpenAI-compatible access.",
|
||||
"settings.providerAccessEmptyHint": "Use Recommended preset to quickly add common providers, usually with only an API key. Use Custom provider for OpenAI-compatible or Anthropic-compatible endpoints.",
|
||||
"settings.builtinProviderBadge": "built-in",
|
||||
"settings.customProviderBadge": "custom",
|
||||
"settings.configureProvider": "Configure",
|
||||
@@ -1338,24 +1615,77 @@ export const en = {
|
||||
"settings.searchModels": "Search models...",
|
||||
"settings.noMatchingModels": "No matching models",
|
||||
"settings.addProvider.chooseTitle": "Add provider",
|
||||
"settings.addProvider.chooseHint": "Choose an official preset, or add a custom OpenAI-compatible access.",
|
||||
"settings.addProvider.officialChoice": "Official provider",
|
||||
"settings.addProvider.officialChoiceHint": "Choose from Reasonix presets for DeepSeek.",
|
||||
"settings.addProvider.chooseHint": "Choose a recommended preset, or add a custom provider manually.",
|
||||
"settings.addProvider.officialChoice": "Recommended preset",
|
||||
"settings.addProvider.officialChoiceHint": "Choose DeepSeek or curated custom-provider presets.",
|
||||
"settings.addProvider.customChoice": "Custom provider",
|
||||
"settings.addProvider.customChoiceHint": "Configure base_url, models, key env, and related parameters manually.",
|
||||
"settings.addProvider.officialTitle": "Add official provider",
|
||||
"settings.addProvider.officialHint": "Official presets use fixed endpoints and defaults; choose the provider and set a key.",
|
||||
"settings.addProvider.officialHint": "Recommended presets fill endpoints, models, vision, and reasoning defaults; usually only the key is needed.",
|
||||
"settings.addProvider.customTitle": "Add custom provider",
|
||||
"settings.addProvider.customHint": "Use this for proxies, aggregators, or other OpenAI-compatible services.",
|
||||
"settings.addProvider.official.deepseek": "DeepSeek Official",
|
||||
"settings.addProvider.official.deepseekDesc": "Official API key access with DeepSeek V4 Flash / Pro.",
|
||||
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat Platform OpenAI-compatible endpoint with LongCat-2.0 default.",
|
||||
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat Platform Anthropic-compatible Messages endpoint using Bearer auth.",
|
||||
"settings.addProvider.preset.kimiCnDesc": "Kimi China API via Moonshot CN endpoint with K2 vision models.",
|
||||
"settings.addProvider.preset.kimiGlobalDesc": "Kimi international API via Moonshot global endpoint with K2 vision models.",
|
||||
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan via the dedicated Anthropic-compatible endpoint; not a CN/Global variant.",
|
||||
"settings.addProvider.preset.mimoApiDesc": "Xiaomi MiMo direct API for v2.5 pay-as-you-go models.",
|
||||
"settings.addProvider.preset.mimoAnthropicDesc": "Xiaomi MiMo direct Anthropic-compatible endpoint with MiMo v2.5 models.",
|
||||
"settings.addProvider.preset.mimoTokenPlanCnDesc": "Xiaomi MiMo token-plan China endpoint.",
|
||||
"settings.addProvider.preset.mimoTokenPlanCnAnthropicDesc": "Xiaomi MiMo token-plan China Anthropic-compatible endpoint.",
|
||||
"settings.addProvider.preset.mimoTokenPlanSgpDesc": "Xiaomi MiMo token-plan Singapore endpoint.",
|
||||
"settings.addProvider.preset.mimoTokenPlanSgpAnthropicDesc": "Xiaomi MiMo token-plan Singapore Anthropic-compatible endpoint.",
|
||||
"settings.addProvider.preset.mimoTokenPlanAmsDesc": "Xiaomi MiMo token-plan Amsterdam endpoint.",
|
||||
"settings.addProvider.preset.mimoTokenPlanAmsAnthropicDesc": "Xiaomi MiMo token-plan Amsterdam Anthropic-compatible endpoint.",
|
||||
"settings.addProvider.preset.minimaxCnApiDesc": "MiniMax China OpenAI-compatible M-series API endpoint.",
|
||||
"settings.addProvider.preset.minimaxGlobalApiDesc": "MiniMax international OpenAI-compatible M-series API endpoint.",
|
||||
"settings.addProvider.preset.minimaxCnAnthropicDesc": "MiniMax China Anthropic-compatible M-series endpoint.",
|
||||
"settings.addProvider.preset.minimaxGlobalAnthropicDesc": "MiniMax international Anthropic-compatible endpoint using Bearer auth.",
|
||||
"settings.addProvider.preset.glmCnDesc": "Zhipu GLM China API with GLM thinking controls.",
|
||||
"settings.addProvider.preset.zaiGlobalDesc": "Z.AI international GLM API.",
|
||||
"settings.addProvider.preset.glmCodingPlanCnDesc": "Zhipu GLM China coding-plan endpoint with GLM-5.2 default.",
|
||||
"settings.addProvider.preset.glmCodingPlanCnAnthropicDesc": "Zhipu GLM China Anthropic-compatible coding-plan endpoint with GLM-5.2[1m] default.",
|
||||
"settings.addProvider.preset.zaiCodingPlanGlobalDesc": "Z.AI international coding-plan endpoint with GLM-5.2 default.",
|
||||
"settings.addProvider.preset.zaiCodingPlanGlobalAnthropicDesc": "Z.AI international Anthropic-compatible coding-plan endpoint with GLM-5.2[1m] default.",
|
||||
"settings.addProvider.preset.opencodeGoDesc": "OpenCode Go relay with mixed-model capability overrides.",
|
||||
"settings.addProvider.preset.opencodeGoAnthropicDesc": "OpenCode Go Anthropic route for Qwen and MiniMax models.",
|
||||
"settings.addProvider.preset.opencodeZenAnthropicDesc": "OpenCode Zen Anthropic route for Claude and Qwen models.",
|
||||
"settings.addProvider.preset.qwenCnDesc": "Qwen/DashScope China standard API endpoint.",
|
||||
"settings.addProvider.preset.qwenGlobalDesc": "Qwen/DashScope international standard API endpoint.",
|
||||
"settings.addProvider.preset.qwenCodingPlanCnDesc": "Qwen Coding Plan China OpenAI-compatible endpoint with qwen3.7-plus default.",
|
||||
"settings.addProvider.preset.qwenCodingPlanCnAnthropicDesc": "Qwen Coding Plan China Anthropic-compatible endpoint with qwen3.7-plus default.",
|
||||
"settings.addProvider.preset.qwenCodingPlanGlobalDesc": "Qwen Coding Plan international OpenAI-compatible endpoint with qwen3.7-plus default.",
|
||||
"settings.addProvider.preset.qwenCodingPlanGlobalAnthropicDesc": "Qwen Coding Plan international Anthropic-compatible endpoint with qwen3.7-plus default.",
|
||||
"settings.addProvider.preset.stepfunDesc": "StepFun coding-plan OpenAI-compatible endpoint with step-3.7-flash default.",
|
||||
"settings.addProvider.preset.stepfunAnthropicDesc": "StepFun coding-plan Anthropic-compatible endpoint with step-3.7-flash default.",
|
||||
"settings.addProvider.preset.novitaDesc": "NovitaAI multi-model gateway with Kimi, MiniMax, GLM, DeepSeek, and Qwen.",
|
||||
"settings.addProvider.preset.gmiDesc": "GMI Cloud direct multi-model gateway with GLM, DeepSeek, Kimi, Gemini, Claude, and GPT.",
|
||||
"settings.addProvider.preset.vercelAiGatewayDesc": "Vercel AI Gateway through Anthropic-compatible Messages API.",
|
||||
"settings.addProvider.preset.huggingfaceDesc": "HuggingFace Inference Router with Qwen and DeepSeek defaults.",
|
||||
"settings.addProvider.preset.nvidiaDesc": "NVIDIA NIM accelerated inference endpoint with Nemotron defaults.",
|
||||
"settings.addProvider.preset.kilocodeDesc": "Kilo Code gateway with kilo/auto routing defaults.",
|
||||
"settings.addProvider.preset.ollamaCloudDesc": "Hosted Ollama Cloud endpoint with none-to-max reasoning effort.",
|
||||
"settings.addProvider.addedBadge": "Added",
|
||||
"settings.addProvider.modifiedBadge": "Modified",
|
||||
"settings.addProvider.nameConflictBadge": "Name taken",
|
||||
"settings.addProvider.similarExistingBadge": "Similar setup",
|
||||
"settings.addProvider.confirm": "Add provider",
|
||||
"settings.addProvider.alreadyAddedAction": "Already added",
|
||||
"settings.addProvider.nameConflictAction": "Resolve name first",
|
||||
"settings.addProvider.viewPresetProvider": "View config",
|
||||
"settings.addProvider.viewConflictProvider": "View same-name config",
|
||||
"settings.addProvider.resetPreset": "Reset to preset",
|
||||
"settings.addProvider.confirmResetPreset": "Confirm reset",
|
||||
"settings.customProviderName": "Custom provider name",
|
||||
"settings.customProviderNamePlaceholder": "",
|
||||
"settings.providerNamePlaceholder": "Provider name (e.g. deepseek-proxy)",
|
||||
"settings.providerProtocol": "Connection protocol",
|
||||
"settings.providerProtocolOpenAI": "OpenAI-compatible",
|
||||
"settings.providerProtocolOpenAIHint": "For proxies, aggregators, and self-hosted services compatible with the OpenAI API.",
|
||||
"settings.providerProtocolAnthropic": "Anthropic-compatible",
|
||||
"settings.providerProtocolAnthropicHint": "For Claude-style Messages API services such as some coding-plan endpoints.",
|
||||
"settings.providerBaseUrlLabel": "API address",
|
||||
"settings.providerChatUrlLabel": "Chat request URL",
|
||||
"settings.providerChatUrlPlaceholder": "e.g. https://api.openai.com/v1/chat/completions",
|
||||
@@ -1369,12 +1699,31 @@ export const en = {
|
||||
"settings.testFetchModels": "Test and fetch models",
|
||||
"settings.testFetchModelsHint": "Uses the API address and key above to verify the connection and fill the model list.",
|
||||
"settings.manualModels": "Model list",
|
||||
"settings.manualModelsHint": "If the endpoint cannot list models, enter model names manually, separated by commas.",
|
||||
"settings.manualModelsHint": "If the endpoint cannot list models, enter model names manually; mark image-capable models below.",
|
||||
"settings.visionModels": "Image-capable models",
|
||||
"settings.visionModelsHint": "Subset of enabled models that accept image input, separated by commas.",
|
||||
"settings.providerAdvancedSettings": "Advanced settings",
|
||||
"settings.providerAdvancedSettings": "Compatibility settings (usually leave unchanged)",
|
||||
"settings.providerAdvancedExpandHint": "Click to open details",
|
||||
"settings.providerAdvancedCollapseHint": "Click to collapse",
|
||||
"settings.providerHeaders": "Extra request headers (optional)",
|
||||
"settings.providerHeadersPlaceholder": "HTTP-Referer: https://example.com\nX-Title: Reasonix",
|
||||
"settings.providerHeadersHint": "For gateways such as OpenRouter that require site headers. Use one Header: value per line; the API key still belongs in the key field above.",
|
||||
"settings.providerExtraBody": "Extra request body (optional)",
|
||||
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
|
||||
"settings.providerExtraBodyHint": "Top-level JSON fields merged into the chat request body. Core fields such as model, messages, tools, stream, and thinking stay controlled by Reasonix.",
|
||||
"settings.providerExtraBodyError": "Enter a JSON object without null values, for example { \"enable_thinking\": true }.",
|
||||
"settings.providerExtraBodyNull": "{path} cannot contain null.",
|
||||
"settings.providerExtraBodyObjectRequired": "Extra body must be a JSON object.",
|
||||
"settings.providerAuthHeader": "Use Authorization: Bearer for this key",
|
||||
"settings.providerAuthHeaderHint": "For Anthropic-compatible gateways such as MiniMax Global or Vercel AI Gateway that do not use x-api-key.",
|
||||
"settings.fetchingModels": "Refreshing…",
|
||||
"settings.fetchModelsSuccess": "Fetched {n} models",
|
||||
"settings.fetchModelsManualFallbackAuth": "The model discovery endpoint rejected the key or permission. Check the API key; if this service does not expose model discovery, enter model IDs manually below.",
|
||||
"settings.fetchModelsManualFallbackUnsupported": "This service may not support automatic model discovery. Enter model IDs manually below.",
|
||||
"settings.fetchModelsManualFallbackNetwork": "Could not reach the model discovery endpoint. Check the API address or proxy; if you know the model IDs, enter them manually below.",
|
||||
"settings.fetchModelsManualFallbackDecode": "The model discovery endpoint returned an unknown format. Enter model IDs manually below.",
|
||||
"settings.fetchModelsManualFallbackEmpty": "No models were discovered automatically. Enter model IDs manually below.",
|
||||
"settings.fetchModelsManualFallbackGeneric": "Could not fetch models automatically: {err}. Enter model IDs manually below.",
|
||||
"settings.fetchModelsUpdatedForProvider": "Updated available models for {provider}, {n} total",
|
||||
"settings.fetchModelsReadyForProvider": "Found {n} models for {provider}. Review and save the enabled list.",
|
||||
"settings.enabledModelsSavedForProvider": "Saved {n} enabled models for {provider}",
|
||||
@@ -1384,12 +1733,18 @@ export const en = {
|
||||
"settings.fetchModelsAfterKeyFailed": "Key saved, but available models could not be refreshed automatically: {err}",
|
||||
"settings.fetchModelsAfterKeyFailedForProvider": "Key saved, but available models for {provider} could not be refreshed: {err}",
|
||||
"settings.newProviderDraftName": "new provider",
|
||||
"settings.reasoningProtocol": "Reasoning protocol",
|
||||
"settings.reasoningProtocol.auto": "Auto (detect from model)",
|
||||
"settings.reasoningProtocol.deepseek": "DeepSeek",
|
||||
"settings.reasoningProtocol.openai": "OpenAI",
|
||||
"settings.reasoningProtocol.none": "None (off)",
|
||||
"settings.reasoningProtocolHint": "Controls the request shape behind effort. Auto uses the model capability registry and official endpoints; switch to OpenAI or None if a proxy is incompatible.",
|
||||
"settings.reasoningProtocol": "Model capability mode",
|
||||
"settings.reasoningProtocol.auto": "Auto-detect (recommended)",
|
||||
"settings.reasoningProtocol.deepseek": "DeepSeek thinking (thinking + depth)",
|
||||
"settings.reasoningProtocol.openai": "OpenAI reasoning (reasoning_effort)",
|
||||
"settings.reasoningProtocol.none": "Plain chat (no reasoning fields)",
|
||||
"settings.reasoningProtocolHint": "Auto-detect is best for most gateways. Switch only when a proxy is incompatible or the model documentation requires a specific reasoning shape.",
|
||||
"settings.thinkingMode": "Thinking override",
|
||||
"settings.thinkingMode.auto": "Auto (provider default)",
|
||||
"settings.thinkingMode.enabled": "Enabled",
|
||||
"settings.thinkingMode.disabled": "Disabled",
|
||||
"settings.thinkingMode.adaptive": "Adaptive (self-adjusting)",
|
||||
"settings.thinkingModeHint": "Provider-specific override. Use enabled/disabled for compatible gateways, adaptive only for providers that document it.",
|
||||
"settings.updateKey": "update {env} (saved globally)",
|
||||
"settings.updateKeyAction": "Update key",
|
||||
"settings.keyStatus": "Key",
|
||||
@@ -1441,6 +1796,10 @@ export const en = {
|
||||
"settings.hooksJsonApplied": "JSON validated. Save to persist it.",
|
||||
"settings.hooksJsonFormatted": "JSON formatted. Save to persist it.",
|
||||
"settings.hooksJsonInvalid": "Invalid JSON: {error}",
|
||||
"settings.hooksJsonExpectedObjectArray": "Expected an object or array.",
|
||||
"settings.hooksJsonItemObject": "Hook item must be an object.",
|
||||
"settings.hooksJsonUnknownEvent": "Unknown hook event {event}.",
|
||||
"settings.hooksJsonEventItemObject": "Hook {event} item must be an object.",
|
||||
"settings.hooksJsonClipboardEmpty": "Clipboard is empty",
|
||||
"settings.hooksJsonClipboardUnavailable": "Clipboard is unavailable; copy the textarea manually.",
|
||||
"settings.hooksJsonPasteFailed": "Paste failed: {error}",
|
||||
@@ -1504,6 +1863,8 @@ export const en = {
|
||||
"msg.imageAttachment": "Image",
|
||||
"msg.workspaceReference": "Workspace reference",
|
||||
"msg.folderReference": "Folder · Workspace reference",
|
||||
"msg.pastedExpandTooltip": "Expand pasted text",
|
||||
"msg.pastedCollapseTooltip": "Collapse pasted text",
|
||||
"turnActions.summary": "Summary",
|
||||
"turnActions.rewind": "Rewind",
|
||||
"transcript.jumpToBottom": "Jump to bottom",
|
||||
@@ -1528,6 +1889,7 @@ export const en = {
|
||||
"rewind.disabledNoBoundary": "Conversation boundary is unavailable for this message",
|
||||
"rewind.disabledNoCode": "No code changes were captured after this message",
|
||||
"rewind.disabledNoEarlier": "There is nothing before this message to summarize",
|
||||
"rewind.disabledNoLater": "There is nothing after this message to summarize",
|
||||
"rewind.filesChanged": "{count} files",
|
||||
"rewind.turnFiles": "{count} this turn",
|
||||
"rewind.both": "Rewind code and conversation",
|
||||
@@ -1548,6 +1910,7 @@ export const en = {
|
||||
"rewind.busyConversation": "Rewinding conversation…",
|
||||
"rewind.busyCode": "Rewinding code…",
|
||||
"rewind.busyBoth": "Rewinding code and conversation…",
|
||||
"rewind.failed": "Rewind failed",
|
||||
"undoRewind.turns": "Rolled back {n} turn(s)",
|
||||
"undoRewind.filesRestored": "{n} file(s) restored",
|
||||
"undoRewind.filesRemoved": "{n} file(s) removed",
|
||||
@@ -1585,7 +1948,7 @@ export const en = {
|
||||
"settings.telemetryLabel": "Anonymous usage ping",
|
||||
"settings.telemetryHint": "On launch, send a random install id plus version and OS to count active installs, and — if the app crashed on a previous run — resend that crash report. Never includes conversations, keys, or file contents.",
|
||||
"settings.metricsLabel": "Share aggregate quality metrics",
|
||||
"settings.metricsHint": "On by default. Sends anonymous turn-end counts, settings preference snapshots, and Memory v5 aggregate buckets such as injection on/off, size buckets, IR count buckets, and memory graph size buckets. Includes the random install id used to de-duplicate DAU. Buckets may include normalized custom provider and model names — never conversations, prompts, keys, paths, base URLs, memory text, tool outputs, or file contents.",
|
||||
"settings.metricsHint": "On by default. Sends anonymous turn-end counts, updater error categories, settings preference snapshots, and Memory v5 aggregate buckets such as injection on/off, size buckets, IR count buckets, and memory graph size buckets. Includes the random install id used to de-duplicate DAU. Buckets may include normalized custom provider and model names — never conversations, prompts, keys, paths, base URLs, memory text, tool outputs, or file contents.",
|
||||
"updater.currentVersion": "Current version: {v}",
|
||||
"updater.channelLabel": "Update channel: {channel}",
|
||||
"updater.checkButton": "Check for updates",
|
||||
@@ -1628,10 +1991,20 @@ export const en = {
|
||||
"context.scopeGlobal": "Scope: Global",
|
||||
"context.scopeProject": "Project scope · {name}",
|
||||
"context.windowTitle": "Context window",
|
||||
"context.windowSubtitle": "Current context window usage",
|
||||
"context.windowStatusHealthy": "Context healthy",
|
||||
"context.windowStatusWatch": "Compression soon",
|
||||
"context.windowStatusPastCompact": "Compression threshold reached",
|
||||
"context.windowStatusNearLimit": "Near context limit",
|
||||
"context.windowUsageSummary": "{pct}% used",
|
||||
"context.windowCompactRemaining": "{used} / {window} · {tokens} until compression",
|
||||
"context.windowCompactDistance": "To compact",
|
||||
"context.windowUsedLabel": "Used",
|
||||
"context.windowUsed": "Used context",
|
||||
"context.windowCompactThreshold": "Compression threshold",
|
||||
"context.windowCompactHeadroom": "Before compression",
|
||||
"context.sessionMetrics": "Session metrics",
|
||||
"context.runtimeMetrics": "Runtime",
|
||||
"context.costMetrics": "Cost",
|
||||
"context.sessionStatus": "Session status",
|
||||
"context.sessionChanges": "Session changes",
|
||||
"context.referencedFiles": "Dependency files",
|
||||
"context.noChanges": "No changed files in this session yet",
|
||||
@@ -1639,34 +2012,28 @@ export const en = {
|
||||
"context.changedMeta": "{count} change records",
|
||||
"context.readMeta": "{count} read records",
|
||||
"context.viewAll": "View all",
|
||||
"context.health": "Context health",
|
||||
"context.healthGood": "Context healthy",
|
||||
"context.healthGoodShort": "Healthy",
|
||||
"context.healthGoodBody": "Usage, source files, and session changes are traceable.",
|
||||
"context.healthNearLimit": "Context near limit",
|
||||
"context.healthNearLimitShort": "Near limit",
|
||||
"context.healthNearLimitBody": "{pct}% used, keep critical sources and consider compaction.",
|
||||
"context.healthManyFiles": "Many source files",
|
||||
"context.healthManyFilesShort": "Many files",
|
||||
"context.healthManyFilesBody": "{count} file records read, verify sources are still relevant.",
|
||||
"context.healthLowCache": "Low cache hit",
|
||||
"context.healthLowCacheShort": "Low cache",
|
||||
"context.healthLowCacheBody": "Cache hit at {pct}%, watch context stability on long tasks.",
|
||||
"context.prompt": "Prompt",
|
||||
"context.completion": "Completion",
|
||||
"context.reasoning": "Reasoning",
|
||||
"context.other": "Other",
|
||||
"context.total": "Total",
|
||||
"context.usageAnalysis": "Usage analysis",
|
||||
"context.usageAnalysisView": "Usage analysis view",
|
||||
"context.usageAnalysisSource": "By source",
|
||||
"context.usageAnalysisType": "By type",
|
||||
"context.tokenBreakdown": "Token composition",
|
||||
"context.cacheHit": "Cache hit",
|
||||
"context.inputCache": "Input cache",
|
||||
"context.inputCacheHit": "Input (cache hit)",
|
||||
"context.inputCacheMiss": "Input (cache miss)",
|
||||
"context.outputTokens": "Output tokens",
|
||||
"context.sessionTokens": "Session tokens",
|
||||
"context.sessionTokens": "Session total tokens",
|
||||
"context.sessionTokensShort": "Total tokens",
|
||||
"context.tokensValue": "{value} tokens",
|
||||
"context.sessionCost": "Session cost",
|
||||
"context.costBreakdown": "Cost breakdown",
|
||||
"context.sourceBreakdown": "Model usage breakdown",
|
||||
"context.sourceShareTokens": "By tokens",
|
||||
"context.sourceBreakdown": "Usage source",
|
||||
"context.sourceShareTitle": "Source share",
|
||||
"context.sourceInput": "Input",
|
||||
"context.sourceOutput": "Output",
|
||||
"context.sourceCacheHit": "Hit",
|
||||
@@ -1682,9 +2049,9 @@ export const en = {
|
||||
"context.sourceClassifier": "Classifier",
|
||||
"context.sourceTitle": "Title",
|
||||
"context.sourceRequests": "{count} req",
|
||||
"context.moreSources": "{count} more sources",
|
||||
"context.requests": "Requests",
|
||||
"context.time": "Time",
|
||||
"context.compaction": "Compaction",
|
||||
"context.time": "Runtime",
|
||||
"context.durationSeconds": "{seconds}s",
|
||||
"context.durationMinutesSeconds": "{minutes}m {seconds}s",
|
||||
|
||||
@@ -1726,6 +2093,9 @@ export const en = {
|
||||
"mock.topicAi": "AI engineering best practices",
|
||||
"mock.topicLab": "Exploratory experiments",
|
||||
"mock.approvalSubject": "npm run build\n\nRun the build command to verify frontend artifacts and style packaging.",
|
||||
"mock.sandboxEscapeReasoning": "Windows sandbox helper failed; requesting a one-time unconfined rerun approval.",
|
||||
"mock.sandboxEscapeSubject": "run unconfined once: go test ./...",
|
||||
"mock.sandboxEscapeReason": "Windows sandbox failed while starting this command. Run it unconfined one time? This bypasses the OS sandbox for this command only.",
|
||||
"mock.askQ1Header": "Approach",
|
||||
"mock.askQ1Prompt": "How do you want to handle the git pull conflicts?",
|
||||
"mock.askQ1Opt1Label": "git stash then pull",
|
||||
|
||||
Loaded 100 of 619 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user