mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d68fb1a93 | ||
|
|
5ea5593f40 | ||
|
|
bdeeb644c4 | ||
|
|
6c9671aa57 | ||
|
|
030a4d5363 | ||
|
|
9087238c7a | ||
|
|
704b15f4d2 | ||
|
|
38ec4093fe | ||
|
|
079293f0d1 | ||
|
|
4c5e5a9572 | ||
|
|
a22eba4a86 | ||
|
|
99c1539142 | ||
|
|
4524016932 | ||
|
|
10e637bce6 | ||
|
|
0af75a1939 | ||
|
|
344895e496 | ||
|
|
0c2efcd5ae | ||
|
|
3cb37b5c9c | ||
|
|
eb9e3e8e0f | ||
|
|
d0cc98d9d3 | ||
|
|
c3ef4bfc92 | ||
|
|
7cca6e9770 | ||
|
|
431dddb1ef | ||
|
|
8178abc0bf | ||
|
|
009a2359cb | ||
|
|
972f052277 | ||
|
|
f501f3dba0 | ||
|
|
80cf5274bf | ||
|
|
4317a6176b | ||
|
|
3716462392 | ||
|
|
2f4a6995f1 | ||
|
|
1401a07a49 | ||
|
|
d39a7694d3 | ||
|
|
fb1840f22d | ||
|
|
8652137e92 | ||
|
|
75c858e543 | ||
|
|
9c058ef04f | ||
|
|
a665c88192 | ||
|
|
fa646b75c9 | ||
|
|
2d489f5c5f | ||
|
|
4098c4d98f | ||
|
|
07c65c2222 | ||
|
|
8357312237 | ||
|
|
0403d1d1a4 | ||
|
|
824f232478 | ||
|
|
14801d3118 | ||
|
|
64253776a8 | ||
|
|
2a3543adca | ||
|
|
cd9023ecf8 | ||
|
|
78133b1a26 | ||
|
|
8b0492525a | ||
|
|
43b147e4f2 | ||
|
|
30ecaf4354 | ||
|
|
9dce3d05fc | ||
|
|
8be5af9f6f | ||
|
|
c9ea166b04 | ||
|
|
5992b2ef25 | ||
|
|
bc556ce363 | ||
|
|
b98d4277c5 | ||
|
|
c009c1bfdf | ||
|
|
7dd84c5d25 | ||
|
|
7ac91a41ec | ||
|
|
53bd6bf901 | ||
|
|
d743a99ae8 | ||
|
|
3cfc430341 | ||
|
|
b07876d2c2 | ||
|
|
4b764dae33 | ||
|
|
741f8e58d5 | ||
|
|
54f11756af | ||
|
|
c0d0235274 | ||
|
|
eb8f4b82ff | ||
|
|
7c437b2d9a | ||
|
|
17074c1ede | ||
|
|
089a3144fb | ||
|
|
b2dae608ad | ||
|
|
65cded04a9 | ||
|
|
2fbce2834a | ||
|
|
9535e411e3 | ||
|
|
7eeb5c2125 | ||
|
|
725e10e560 | ||
|
|
c0100dd509 | ||
|
|
6a68799e18 | ||
|
|
60735b4f02 | ||
|
|
c070dc7432 | ||
|
|
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 | ||
|
|
c146456d8a |
No files matched your search
@@ -1,2 +1,28 @@
|
||||
.gitattributes text eol=lf
|
||||
.gitignore text eol=lf
|
||||
.npmrc text eol=lf
|
||||
*.astro text eol=lf
|
||||
*.css text eol=lf
|
||||
*.go text eol=lf
|
||||
*.html text eol=lf
|
||||
*.js text eol=lf
|
||||
*.json text eol=lf
|
||||
*.md text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.mod text eol=lf
|
||||
*.nsi text eol=lf
|
||||
*.plist text eol=lf
|
||||
*.py text eol=lf
|
||||
*.rs text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.sql text eol=lf
|
||||
*.sum text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.tsx text eol=lf
|
||||
*.txt text eol=lf
|
||||
*.xml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
.githooks/** text eol=lf
|
||||
benchmarks/**/*.sh text eol=lf
|
||||
@@ -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:
|
||||
@@ -149,6 +163,53 @@ jobs:
|
||||
- name: test
|
||||
run: go test ./...
|
||||
|
||||
# Desktop project-root matching is case-insensitive only on Windows
|
||||
# (sameDesktopPath folds case when os.PathSeparator is '\'), so the
|
||||
# regression tests for that contract are named *OnWindows and can never
|
||||
# run on the ubuntu leg above.
|
||||
desktop-windows:
|
||||
runs-on: windows-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: desktop/go.mod
|
||||
cache: true
|
||||
cache-dependency-path: desktop/go.sum
|
||||
|
||||
- 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: desktop/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install Wails CLI
|
||||
run: |
|
||||
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
|
||||
go install github.com/wailsapp/wails/v2/cmd/[email protected]
|
||||
|
||||
# go:embed of frontend/dist needs a built frontend before the package
|
||||
# compiles, same as the ubuntu desktop leg.
|
||||
- name: Build frontend
|
||||
run: |
|
||||
wails generate module
|
||||
pnpm --dir frontend install --frozen-lockfile
|
||||
pnpm --dir frontend build
|
||||
|
||||
- name: test (Windows-only path semantics)
|
||||
timeout-minutes: 10
|
||||
run: go test . -run 'OnWindows$' -v
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -9,7 +9,7 @@ name: Release desktop
|
||||
# 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, then the crash worker
|
||||
# release gateway; GitHub's repository-wide "latest" is reserved for the CLI line).
|
||||
# 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/
|
||||
@@ -269,8 +269,14 @@ jobs:
|
||||
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
|
||||
@@ -367,6 +373,10 @@ 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:
|
||||
@@ -374,8 +384,11 @@ jobs:
|
||||
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
|
||||
@@ -384,7 +397,8 @@ jobs:
|
||||
all(type == "string" and startswith($prefix) and (contains("/releases/latest/") | not)))
|
||||
' "$f" >/dev/null
|
||||
|
||||
curl --retry 5 --retry-delay 2 -fsSL "https://dl.reasonix.io/${TAG}/latest.json" >/tmp/reasonix-desktop-tag-latest.json
|
||||
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
|
||||
@@ -393,14 +407,62 @@ jobs:
|
||||
pointer="latest"
|
||||
fi
|
||||
if [ -n "$pointer" ]; then
|
||||
curl --retry 5 --retry-delay 2 -fsSL "https://dl.reasonix.io/${pointer}/latest.json" >/tmp/reasonix-desktop-pointer-latest.json
|
||||
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
|
||||
curl --retry 5 --retry-delay 2 -fsSI "$asset" >/dev/null
|
||||
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:
|
||||
|
||||
@@ -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:
|
||||
@@ -53,6 +55,9 @@ jobs:
|
||||
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
|
||||
@@ -61,10 +66,108 @@ jobs:
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
curl --retry 5 --retry-delay 2 -fsSL https://dl.reasonix.io/latest/latest.json -o latest.raw.json
|
||||
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 重写版。
|
||||
|
||||
+24
-5
@@ -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,9 +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, 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.
|
||||
`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+.
|
||||
|
||||
@@ -115,8 +134,8 @@ 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, because plain `v*` tags are
|
||||
the CLI release line. Self-update behavior by platform:
|
||||
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
|
||||
|
||||
+1173
-391
File diff suppressed because it is too large.
Load diff
@@ -93,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
|
||||
@@ -248,8 +247,8 @@ func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
|
||||
if tab.SessionPath != recoveryPath {
|
||||
t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
|
||||
}
|
||||
if tab.TopicID == "" || tab.TopicID == originalTopic {
|
||||
t.Fatalf("tab topic ID = %q, want fresh recovery topic", tab.TopicID)
|
||||
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 {
|
||||
@@ -263,22 +262,22 @@ func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
|
||||
t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
|
||||
}
|
||||
nodes := app.ListProjectTree()
|
||||
found := false
|
||||
foundOriginal := false
|
||||
var walk func([]ProjectNode)
|
||||
walk = func(list []ProjectNode) {
|
||||
for _, node := range list {
|
||||
if node.TopicID == tab.TopicID {
|
||||
found = true
|
||||
if !node.Recovered || node.RecoveryDigest != meta.RecoveryDigest || node.RecoveryParentID != string(meta.ParentID) {
|
||||
t.Fatalf("project tree recovery node = %+v, want digest %q parent %q", node, meta.RecoveryDigest, meta.ParentID)
|
||||
}
|
||||
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 !found {
|
||||
t.Fatalf("project tree did not include recovery topic %q: %#v", tab.TopicID, nodes)
|
||||
if !foundOriginal {
|
||||
t.Fatalf("project tree did not include original topic %q: %#v", originalTopic, nodes)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -168,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) {
|
||||
@@ -588,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) {
|
||||
|
||||
+1777
-10
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")
|
||||
}
|
||||
}
|
||||
@@ -138,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
|
||||
|
||||
+50
-23
@@ -23,10 +23,15 @@ 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 {
|
||||
@@ -65,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 {
|
||||
@@ -77,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
|
||||
}
|
||||
@@ -105,11 +117,11 @@ 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
|
||||
}
|
||||
|
||||
@@ -176,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()
|
||||
@@ -194,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{
|
||||
@@ -203,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
|
||||
}
|
||||
|
||||
@@ -274,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()
|
||||
|
||||
@@ -302,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)
|
||||
}
|
||||
@@ -310,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)
|
||||
@@ -351,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)
|
||||
}
|
||||
@@ -359,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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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__/message-pasted-blocks.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-run-strip.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__/recovery-quiet-notifications.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__/sound.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__/history-recovery-copies.test.tsx && 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__/message-pasted-blocks.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-run-strip.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__/recovery-quiet-notifications.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__/sound.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__/history-recovery-copies.test.tsx && 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",
|
||||
|
||||
+136
-55
@@ -35,16 +35,17 @@ import {
|
||||
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, onSessionRecovered, onSessionRecoveryFailed } from "./lib/bridge";
|
||||
import { localizedBackendNoticeText, useController, type Item, type LiveStream } from "./lib/useController";
|
||||
import { app, onEvent, onProjectTreeChanged, onReady, onRuntimeRebuilt, onSessionRecovered } from "./lib/bridge";
|
||||
import { generativeMusic, isGenerativeMusicEnabled } from "./lib/generative-music";
|
||||
import { playSuccessChime } from "./lib/sound";
|
||||
import { clearAttentionChimeKeys, playAttentionChime, playSuccessChime, shouldPlayAttentionChimeForEvent } from "./lib/sound";
|
||||
import { Transcript } from "./components/Transcript";
|
||||
import { Composer } from "./components/Composer";
|
||||
import { TodoPanel } from "./components/TodoPanel";
|
||||
import { ApprovalModal } from "./components/ApprovalModal";
|
||||
import { ApprovalModal, approvalToolLabel } from "./components/ApprovalModal";
|
||||
import { AskCard } from "./components/AskCard";
|
||||
import { UndoRewindBanner } from "./components/UndoRewindBanner";
|
||||
import { ClearContextCard } from "./components/ClearContextCard";
|
||||
@@ -160,6 +161,83 @@ import { topicShortcutIndexFromEvent, useTopicShortcuts, type TopicShortcutEntry
|
||||
import { composerDraftKeyForTab } from "./lib/composerDraftKey";
|
||||
import logoWordmark from "./assets/logo-wordmark.svg";
|
||||
|
||||
function noticePreviewMockEnabled(): boolean {
|
||||
const value = browserMockScenarioParam();
|
||||
return value === "notice" || value === "notices" || value === "notice-preview";
|
||||
}
|
||||
|
||||
function noticePreviewItems(): Item[] {
|
||||
const notice = (index: number, level: "info" | "warn", text: string, detail: string): Item => ({
|
||||
kind: "notice",
|
||||
id: `notice-preview-${index}`,
|
||||
level,
|
||||
text: localizedBackendNoticeText(text),
|
||||
detail,
|
||||
});
|
||||
return [
|
||||
notice(0, "info", "Task status needs one more check; asking the assistant to finish or explain what is blocking it.", "final-answer readiness blocked: latest successful todo_write still has incomplete items: UI spinner in_progress, settings persistence pending"),
|
||||
notice(1, "info", "No visible answer was produced; asking the assistant to respond again.", "empty final answer blocked: qwen3.7-plus returned no visible answer text (finish=stop, reasoning=2314 chars); retrying"),
|
||||
notice(2, "info", "The assistant answered before taking action; asking it to use the required tools.", "executor handoff: assistant produced a proposal before running required repository commands; nudged to execute"),
|
||||
notice(3, "info", "Tool round limit reached; asking the assistant to summarize progress.", "tool budget reached after 128 tool calls; requesting a progress summary before continuing"),
|
||||
notice(4, "info", "The assistant is stuck retrying a blocked action; asking it to change approach.", "loop guard: repeated command failure matched the same stderr signature across 3 attempts"),
|
||||
notice(5, "info", "Context is getting large; preserving cache until cleanup is needed.", "context window 82% full; deferred cleanup to preserve reusable prompt cache"),
|
||||
notice(6, "info", "Context cleanup skipped for now.", "cleanup skipped: recent turn included unresolved user approval state"),
|
||||
notice(7, "info", "Automatic context cleanup paused because the context window is too small.", "configured compact threshold exceeds current model context window; auto cleanup paused for this model"),
|
||||
notice(8, "info", "Context was compacted without a generated summary.", "compaction completed after upstream summary generation returned empty content; retained transcript checkpoint"),
|
||||
notice(9, "info", "Planning mode enabled for this multi-step task.", "auto plan detector matched implementation task with repository edits and verification steps"),
|
||||
notice(10, "info", "Plan detection requested a plan.", "plan detector confidence=0.83; task includes multiple dependent UI/backend changes"),
|
||||
notice(11, "info", "Plan detection was uncertain; using the fallback planner heuristic.", "plan detector confidence=0.48; fallback heuristic selected because files and tests are involved"),
|
||||
notice(12, "info", "Goal is not ready to complete yet; continuing the remaining work.", "goal completion check found pending validation: desktop/frontend typecheck"),
|
||||
notice(13, "info", "Goal still has unfinished task state; continuing the remaining work.", "active goal has open task state: implement preview, verify browser, report result"),
|
||||
notice(14, "warn", "AutoResearch status update failed.", "autoresearch task completion update failed: write .reasonix/autoresearch/task-42/state/task_spec.json: permission denied"),
|
||||
notice(15, "warn", "AutoResearch task marked blocked.", "autoresearch task blocked: task-42\nreason: missing accepted verification evidence after three turns"),
|
||||
notice(16, "warn", "background export failed: needs attention", "background export failed: session archive upload returned 503 after 3 retries"),
|
||||
notice(17, "warn", "Job artifact migration failed.", "artifact migration failed for job job_123: checksum mismatch while moving output.zip"),
|
||||
notice(18, "warn", "Background job teardown timed out.", "job job_123 did not stop within 10s; process is still marked running by the supervisor"),
|
||||
notice(19, "warn", "Some plan-mode tool settings were ignored.", "plan-mode tool settings ignored: unsupported tool allowlist entry \"browser.screenshot\""),
|
||||
notice(20, "warn", "Some plan-mode command settings were ignored.", "plan-mode command settings ignored: invalid read-only prefix \"npm && test\""),
|
||||
notice(21, "warn", "Config migration did not complete.", "config migration failed at providers.defaultModel: unknown provider reference \"old/deepseek\""),
|
||||
notice(22, "warn", "Selected model is missing its API key.", "selected model deepseek/deepseek-v4-pro requires DEEPSEEK_API_KEY, but no key is configured"),
|
||||
notice(23, "warn", "An MCP server failed to start.", "mcp server \"github\" failed to start: command not found: mcp-server-github"),
|
||||
notice(24, "warn", "Some MCP servers failed to start; run /mcp for details.", "mcp startup failures: github(command not found), linear(authentication expired)"),
|
||||
notice(25, "warn", "Guardian was disabled because its model was not found.", "guardian model \"glm-5-guard\" is not present in the configured provider catalog"),
|
||||
notice(26, "warn", "Guardian was disabled because it could not start.", "guardian startup failed: provider returned 401 unauthorized"),
|
||||
];
|
||||
}
|
||||
|
||||
function NoticePreviewPanel({ detailsLabel }: { detailsLabel: string }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
minHeight: 0,
|
||||
overflow: "auto",
|
||||
padding: "44px 24px 128px",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 920, margin: "0 auto" }}>
|
||||
{noticePreviewItems().map((item) => {
|
||||
if (item.kind !== "notice") return null;
|
||||
return (
|
||||
<div key={item.id} className={`notice-line notice-line--${item.level}`} data-entrance="true">
|
||||
<span className="notice-line__icon">{item.level === "warn" ? "⚠ " : "ℹ "}</span>
|
||||
<div className="notice-line__text">
|
||||
{item.text}
|
||||
{item.detail ? (
|
||||
<details className="notice-line__details">
|
||||
<summary>{detailsLabel}</summary>
|
||||
<div>{item.detail}</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const HistoryPanel = lazy(() => import("./components/HistoryPanel").then((module) => ({ default: module.HistoryPanel })));
|
||||
const SettingsPanel = lazy(() => import("./components/SettingsPanel").then((module) => ({ default: module.SettingsPanel })));
|
||||
|
||||
@@ -853,6 +931,9 @@ function sessionItemsToMarkdown(title: string, items: Item[], live?: LiveStream)
|
||||
break;
|
||||
case "notice":
|
||||
lines.push(`### ${item.level === "warn" ? "Warning" : "Notice"}`, "", item.text.trim(), "");
|
||||
if (item.detail?.trim()) {
|
||||
lines.push("Details:", "", item.detail.trim(), "");
|
||||
}
|
||||
break;
|
||||
case "compaction":
|
||||
lines.push("### Context Compaction", "");
|
||||
@@ -1009,6 +1090,7 @@ export default function App() {
|
||||
const setRightDockPreviewWidth = useLayoutStore((s) => s.setRightDockPreviewWidth);
|
||||
const workspacePreviewActive = useLayoutStore((s) => s.workspacePreviewActive);
|
||||
const setWorkspacePreviewActive = useLayoutStore((s) => s.setWorkspacePreviewActive);
|
||||
const attentionChimeEvents = useRef(new Set<string>());
|
||||
// Bump dockRefreshKey after each turn so WorkspacePanel/ContextPanel re-fetch
|
||||
// workspace changes, git history, and session metadata after AI tool writes.
|
||||
useEffect(() => {
|
||||
@@ -1016,11 +1098,31 @@ export default function App() {
|
||||
if (e.kind === "turn_done") {
|
||||
setDockRefreshKey((v) => v + 1);
|
||||
}
|
||||
if (shouldPlayAttentionChimeForEvent(e, attentionChimeEvents.current)) {
|
||||
playAttentionChime();
|
||||
}
|
||||
if (e.kind === "turn_done") {
|
||||
if (!e.err) playSuccessChime();
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
// Runtime rebuilds (model/effort/settings switch) replace the controller,
|
||||
// whose approval/ask ids restart from "1" — stale dedupe keys would mute
|
||||
// the first prompt after a rebuild. agent:ready fires when a (re)build
|
||||
// completes; clear that tab's keys (or all, for tab-less ready events).
|
||||
const unsubReady = onReady((readyTabId) => {
|
||||
clearAttentionChimeKeys(attentionChimeEvents.current, readyTabId);
|
||||
});
|
||||
// Model/effort/token-mode switches and clear-while-running replace the
|
||||
// controller WITHOUT an agent:ready — they signal runtime:rebuilt instead
|
||||
// (a ready here would trigger a full session reload the UI already did).
|
||||
const unsubRebuilt = onRuntimeRebuilt((rebuiltTabId) => {
|
||||
clearAttentionChimeKeys(attentionChimeEvents.current, rebuiltTabId);
|
||||
});
|
||||
return () => {
|
||||
unsub();
|
||||
unsubReady();
|
||||
unsubRebuilt();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [workspacePanelResizing, setWorkspacePanelResizing] = useState(false);
|
||||
@@ -1041,6 +1143,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);
|
||||
@@ -2264,7 +2367,6 @@ export default function App() {
|
||||
if (!rewindState) return transcriptItems;
|
||||
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];
|
||||
@@ -2277,8 +2379,8 @@ export default function App() {
|
||||
|
||||
// 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;
|
||||
@@ -2294,7 +2396,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") {
|
||||
@@ -2304,7 +2406,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;
|
||||
@@ -2546,7 +2648,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);
|
||||
@@ -2591,36 +2693,10 @@ 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]);
|
||||
useEffect(() => onSessionRecovered(() => {
|
||||
setProjectRevision((value) => value + 1);
|
||||
void refreshTabMetas();
|
||||
}), [refreshTabMetas]);
|
||||
|
||||
const handleNewTab = useCallback(async () => {
|
||||
closeTransientOverlays();
|
||||
@@ -2745,6 +2821,19 @@ export default function App() {
|
||||
},
|
||||
[state.running, deleteSession, refreshHistoryView],
|
||||
);
|
||||
const onDeleteManySessions = useCallback(
|
||||
async (paths: string[]) => {
|
||||
if (state.running) return;
|
||||
const uniquePaths = Array.from(new Set(paths));
|
||||
for (const path of uniquePaths) {
|
||||
// Best effort per path: one locked/missing file must not abandon the
|
||||
// rest of the sweep. The final refresh reconciles whatever happened.
|
||||
await deleteSession(path).catch(() => undefined);
|
||||
}
|
||||
await refreshHistoryView();
|
||||
},
|
||||
[state.running, deleteSession, refreshHistoryView],
|
||||
);
|
||||
const onRenameSession = useCallback(
|
||||
async (path: string, title: string) => {
|
||||
if (state.running) return;
|
||||
@@ -2882,13 +2971,6 @@ export default function App() {
|
||||
: [topicbarWorkspacePath || topicbarWorkspaceLabel, topicbarImSourceLabel].filter(Boolean).join(" · ");
|
||||
const topicbarCanRename = !sidebarImDetailConnection && Boolean(activeTab?.topicId);
|
||||
const topicbarTitleEditSize = Math.min(56, Math.max(4, topicTitleDraft.length || topicbarTitle.length || 1));
|
||||
const recoveryBannerTitle = activeTab?.recovered
|
||||
? [
|
||||
activeTab.recoveryReason ? t("recovery.reason", { reason: activeTab.recoveryReason }) : "",
|
||||
activeTab.recoveryDigest ? t("recovery.digest", { digest: activeTab.recoveryDigest.slice(0, 12) }) : "",
|
||||
activeTab.recoveryParentId ? t("recovery.parent", { parent: activeTab.recoveryParentId }) : "",
|
||||
].filter(Boolean).join(" · ")
|
||||
: "";
|
||||
const sidebarWorkbench = desktopLayoutStyle === "workbench";
|
||||
const windowsFramelessChrome = desktopPlatform === "windows";
|
||||
const handleWindowsTitlebarDoubleClick = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
@@ -3440,13 +3522,6 @@ 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" title={recoveryBannerTitle || undefined}>
|
||||
<span className="banner__badge">{t("recovery.branch")}</span>
|
||||
<span>{t("recovery.banner")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<UpdateBanner enabled={startupUpdateChecksEnabled === true} />
|
||||
|
||||
<main className="main">
|
||||
@@ -3458,6 +3533,8 @@ export default function App() {
|
||||
onManageAllowlist={() => openBotAllowlistSettings(sidebarImDetailConnection.connectionId)}
|
||||
onOpenSession={() => void openSidebarImConnectionSession(sidebarImDetailConnection)}
|
||||
/>
|
||||
) : noticePreviewMockEnabled() ? (
|
||||
<NoticePreviewPanel detailsLabel={t("notice.details")} />
|
||||
) : (
|
||||
<Transcript
|
||||
items={displayItems}
|
||||
@@ -3533,6 +3610,7 @@ export default function App() {
|
||||
onStop={() => {
|
||||
cancel();
|
||||
}}
|
||||
toolApprovalMode={toolApprovalMode}
|
||||
/>
|
||||
)}
|
||||
{state.ask && (
|
||||
@@ -3584,6 +3662,8 @@ export default function App() {
|
||||
turnStartAt={state.turnStartAt}
|
||||
turnTokens={state.turnTokens}
|
||||
retry={state.retry}
|
||||
pendingApprovalLabel={state.approval ? approvalToolLabel(state.approval.tool, t) : null}
|
||||
pendingAsk={state.ask != null}
|
||||
transientDismissSignal={transientOverlayDismissSignal}
|
||||
sessionKey={composerSessionKey}
|
||||
fileRefRefreshKey={composerFileRefRefreshKey}
|
||||
@@ -3729,6 +3809,7 @@ export default function App() {
|
||||
onRestore={onRestoreTrashedSession}
|
||||
onPurge={onPurgeTrashedSession}
|
||||
onPurgeAll={onPurgeAllTrashedSessions}
|
||||
onDeleteMany={onDeleteManySessions}
|
||||
onClose={closeHistory}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
@@ -54,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/",
|
||||
@@ -62,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;
|
||||
@@ -129,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 () => [],
|
||||
@@ -178,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({
|
||||
@@ -231,6 +312,25 @@ console.log("\napproval modal file references");
|
||||
);
|
||||
ok(document.body.textContent?.includes("Hide") === true, "tool approval details start expanded");
|
||||
|
||||
// Consequence preview row: follows the keyboard-selected action by default,
|
||||
// arrow navigation, and hover; each pill carries a native title fallback.
|
||||
const consequence = () => document.querySelector(".approval-consequence__text")?.textContent ?? null;
|
||||
eq(consequence(), "Allow this call only; the next one asks again.", "consequence row previews the selected action by default");
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(consequence(), "Allow matching calls until this session ends; resets on restart.", "arrow navigation moves the consequence preview");
|
||||
|
||||
const pills = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
|
||||
eq(pills[2]?.getAttribute("title"), "Save a persistent allow rule; future sessions stop asking too.", "action pills carry a native title fallback");
|
||||
await act(async () => {
|
||||
pills[3].dispatchEvent(new window.MouseEvent("mouseover", { bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(consequence(), "Reject this call; the model sees the refusal and continues.", "hover overrides the consequence preview");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
@@ -260,8 +360,8 @@ console.log("\napproval modal file references");
|
||||
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|4Deny",
|
||||
"fresh-human approval keeps allow/deny shortcut keys",
|
||||
"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;
|
||||
@@ -280,5 +380,108 @@ console.log("\napproval modal file references");
|
||||
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);
|
||||
@@ -111,6 +111,29 @@ console.log("\nask card layout");
|
||||
eq(computed.overflowWrap, "anywhere", "long unspaced ask questions can break within the shelf");
|
||||
ok(card.getAttribute("role") === "dialog", "ask prompt shelf keeps dialog semantics");
|
||||
|
||||
// Detail preview row: shows the first described option by default, follows
|
||||
// hover, and each option carries a native-title fallback for truncation.
|
||||
const detail = document.querySelector(".ask-shelf__detail") as HTMLElement | null;
|
||||
if (!detail) throw new Error("ask detail preview row did not render");
|
||||
eq(
|
||||
detail.querySelector(".ask-shelf__detail-text")?.textContent,
|
||||
ask.questions[0].options[0].description,
|
||||
"detail row previews the first described option by default",
|
||||
);
|
||||
|
||||
const optionButtons = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
|
||||
eq(optionButtons[1]?.getAttribute("title"), ask.questions[0].options[1].description, "options carry a native title fallback");
|
||||
|
||||
await act(async () => {
|
||||
optionButtons[1].dispatchEvent(new window.MouseEvent("mouseover", { bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(
|
||||
document.querySelector(".ask-shelf__detail-text")?.textContent,
|
||||
ask.questions[0].options[1].description,
|
||||
"detail row follows the hovered option",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// Stand-in for bundler-handled asset imports (.svg) in tsx-run tests; see the
|
||||
// resolve hook in history-recovery-copies.test.tsx.
|
||||
export default "";
|
||||
@@ -0,0 +1,85 @@
|
||||
// Run: tsx src/__tests__/bridge-breadcrumbs.test.ts
|
||||
|
||||
import { snapshotBreadcrumbs } from "../lib/breadcrumbs";
|
||||
import { app } from "../lib/bridge";
|
||||
|
||||
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 ensureWindow() {
|
||||
if (typeof window === "undefined") {
|
||||
(globalThis as Record<string, unknown>).window = {} as Window & typeof globalThis;
|
||||
}
|
||||
}
|
||||
|
||||
function newBreadcrumbMessages(since: number, cat: string): string[] {
|
||||
return snapshotBreadcrumbs()
|
||||
.slice(since)
|
||||
.filter((crumb) => crumb.cat === cat)
|
||||
.map((crumb) => crumb.msg);
|
||||
}
|
||||
|
||||
console.log("\nbridge breadcrumbs");
|
||||
|
||||
const previousPerformance = (globalThis as { performance?: Performance }).performance;
|
||||
let now = 0;
|
||||
(globalThis as Record<string, unknown>).performance = { now: () => now };
|
||||
|
||||
ensureWindow();
|
||||
const previousGo = window.go;
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
async IsMainWindowMaximised() {
|
||||
now = 42;
|
||||
return true;
|
||||
},
|
||||
async CheckUpdate() {
|
||||
now = 125;
|
||||
throw new Error("offline");
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
};
|
||||
|
||||
const successStart = snapshotBreadcrumbs().length;
|
||||
now = 10;
|
||||
await app.IsMainWindowMaximised();
|
||||
const successBridge = newBreadcrumbMessages(successStart, "bridge");
|
||||
ok(
|
||||
successBridge.includes("window IsMainWindowMaximised") &&
|
||||
successBridge.includes("window IsMainWindowMaximised done ms=32"),
|
||||
"records bridge start and completion timing breadcrumbs",
|
||||
);
|
||||
|
||||
const errorStart = snapshotBreadcrumbs().length;
|
||||
now = 100;
|
||||
try {
|
||||
await app.CheckUpdate();
|
||||
} catch {
|
||||
// Expected: this branch verifies the breadcrumb emitted by the proxy rejection path.
|
||||
}
|
||||
const errorBridge = newBreadcrumbMessages(errorStart, "bridge");
|
||||
const bridgeErrors = newBreadcrumbMessages(errorStart, "bridge.error");
|
||||
ok(errorBridge.includes("update CheckUpdate"), "records bridge start breadcrumb before failed calls");
|
||||
ok(bridgeErrors.includes("CheckUpdate ms=25"), "records bridge failure timing breadcrumbs");
|
||||
|
||||
window.go = previousGo;
|
||||
if (previousPerformance) {
|
||||
(globalThis as Record<string, unknown>).performance = previousPerformance;
|
||||
} else {
|
||||
delete (globalThis as { performance?: Performance }).performance;
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -257,7 +257,7 @@ console.log("\ncomposer goal toggle");
|
||||
turnStartAt: Date.now(),
|
||||
});
|
||||
|
||||
const stopButton = document.querySelector(".composer-runstatus__stop") as HTMLButtonElement | null;
|
||||
const stopButton = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
|
||||
if (!stopButton) throw new Error("composer stop button did not render");
|
||||
|
||||
await act(async () => {
|
||||
@@ -643,7 +643,7 @@ console.log("\ncomposer goal toggle");
|
||||
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");
|
||||
eq(textarea.placeholder, "Running — type guidance, Enter adds it 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");
|
||||
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
// Run: tsx src/__tests__/composer-run-strip.test.tsx
|
||||
//
|
||||
// The run state lives inside the composer card (no floating pill, no layout
|
||||
// jump), stop has a fixed home next to send, and a pending approval/ask shifts
|
||||
// the strip into a waiting state instead of a ticking "working" spinner.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Composer } from "../components/Composer";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, ToolApprovalMode, TokenMode } 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 flushTimers(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
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.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.InputEvent = dom.window.InputEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.File = dom.window.File;
|
||||
globalThis.FileReader = dom.window.FileReader;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
globalThis.ResizeObserver = TestResizeObserver;
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
matches: true,
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
onchange: null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const calls = { cancel: 0 };
|
||||
let currentProps: Parameters<typeof Composer>[0] = {
|
||||
running: false,
|
||||
collaborationMode: "normal" as CollaborationMode,
|
||||
toolApprovalMode: "ask" as ToolApprovalMode,
|
||||
tokenMode: "full" as TokenMode,
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
modelLabel: "DeepSeek-R1",
|
||||
onSend: () => {},
|
||||
onCancel: () => {
|
||||
calls.cancel += 1;
|
||||
return undefined;
|
||||
},
|
||||
onCycleMode: () => {},
|
||||
onSetMode: () => {},
|
||||
onSetCollaborationMode: () => {},
|
||||
onSetToolApprovalMode: () => {},
|
||||
onToggleYoloApprovalMode: () => {},
|
||||
onClearGoal: () => {},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
onSetTokenMode: () => {},
|
||||
ready: true,
|
||||
...props,
|
||||
};
|
||||
const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
|
||||
currentProps = { ...currentProps, ...nextProps };
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<Composer {...currentProps} />
|
||||
</ToastProvider>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
};
|
||||
await paint();
|
||||
return { root, calls, rerender: paint };
|
||||
}
|
||||
|
||||
console.log("\ncomposer run strip");
|
||||
|
||||
// Idle: no strip, no stop button, plain send arrow.
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root } = await renderComposer();
|
||||
|
||||
eq(document.querySelector(".composer-run-strip"), null, "idle composer renders no run strip");
|
||||
eq(document.querySelector(".composer__btn--stop"), null, "idle composer renders no stop button");
|
||||
ok(document.querySelector(".composer__btn--send") !== null, "idle composer keeps the send button");
|
||||
eq(document.querySelector(".composer-toolbar--status-only"), null, "floating status pill is gone");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Running: strip lives inside the card, ticker is aria-hidden, stop cancels.
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, calls } = await renderComposer({ running: true, turnStartAt: Date.now() });
|
||||
|
||||
const strip = document.querySelector(".composer-card .composer-run-strip");
|
||||
ok(strip !== null, "running strip renders inside the composer card");
|
||||
const ticker = strip?.querySelector(".composer-run-strip__text");
|
||||
eq(ticker?.getAttribute("aria-hidden"), "true", "ticking spinner text stays out of the accessibility tree");
|
||||
const live = strip?.querySelector(".sr-only[role=\"status\"]");
|
||||
eq(live?.textContent, "Reasonix is working", "live region announces the stable state text only");
|
||||
ok(document.querySelector(".composer-card--running") !== null, "running card keeps its running modifier");
|
||||
|
||||
const stop = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
|
||||
if (!stop) throw new Error("running composer stop button did not render");
|
||||
await act(async () => {
|
||||
stop.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(calls.cancel, 1, "stop button next to send cancels the turn");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Waiting on approval: strip switches to the waiting state and stops ticking.
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, rerender } = await renderComposer({ running: true, turnStartAt: Date.now() });
|
||||
|
||||
await rerender({ pendingApprovalLabel: "Run command", disabled: true });
|
||||
|
||||
const strip = document.querySelector(".composer-run-strip");
|
||||
ok(strip?.classList.contains("composer-run-strip--waiting") === true, "pending approval shifts the strip into waiting");
|
||||
const text = strip?.querySelector(".composer-run-strip__text");
|
||||
eq(text?.textContent, "Waiting for your approval — Run command", "waiting strip names the tool awaiting approval");
|
||||
eq(text?.getAttribute("aria-hidden"), null, "waiting text is static and stays accessible");
|
||||
eq(document.querySelector(".composer-card--running"), null, "waiting card hands the running accent off to the prompt card");
|
||||
ok(document.querySelector(".composer-card--waiting") !== null, "waiting card takes the waiting modifier");
|
||||
|
||||
const modeButtons = [...document.querySelectorAll(".composer-modebar--approval .composer-modebar__item")] as HTMLButtonElement[];
|
||||
ok(modeButtons.length === 3 && modeButtons.every((b) => !b.disabled), "approval bar stays usable while its own prompt disables the composer");
|
||||
|
||||
await rerender({ pendingApprovalLabel: null, pendingAsk: true });
|
||||
eq(
|
||||
document.querySelector(".composer-run-strip__text")?.textContent,
|
||||
"Waiting for your answer",
|
||||
"pending ask question shows the ask waiting state",
|
||||
);
|
||||
ok(
|
||||
modeButtons.every((b) => b.disabled),
|
||||
"approval bar stays disabled for non-approval reasons",
|
||||
);
|
||||
|
||||
await rerender({ pendingAsk: false, disabled: false });
|
||||
ok(
|
||||
document.querySelector(".composer-run-strip__text")?.getAttribute("aria-hidden") === "true",
|
||||
"resolving the prompt returns the strip to the ticking spinner",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Cancel restores queued guidance: stop means "stop acting", never "discard
|
||||
// what I typed".
|
||||
{
|
||||
const dom = installDom();
|
||||
const { root, calls } = await renderComposer({
|
||||
running: true,
|
||||
turnStartAt: Date.now(),
|
||||
guidanceQueuePreviewItems: ["数到一半改用英文", "最后给出一句总结"],
|
||||
});
|
||||
|
||||
ok(document.querySelector(".composer-guidance-shelf") !== null, "queued guidance renders in the shelf");
|
||||
|
||||
const stop = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
|
||||
if (!stop) throw new Error("stop button did not render");
|
||||
await act(async () => {
|
||||
stop.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(calls.cancel, 1, "stop cancels the running turn");
|
||||
const ta = document.querySelector("textarea") as HTMLTextAreaElement;
|
||||
eq(ta.value, "数到一半改用英文\n最后给出一句总结", "stop folds unconsumed queued guidance back into the draft");
|
||||
eq(document.querySelector(".composer-guidance-shelf"), null, "restored queue clears the shelf");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Waiting on the user pauses the ticker clock: elapsed time means model time.
|
||||
{
|
||||
const dom = installDom();
|
||||
const start = Date.now() - 30000;
|
||||
const { root, rerender } = await renderComposer({ running: true, turnStartAt: start });
|
||||
|
||||
await rerender({ pendingApprovalLabel: "Run command", disabled: true });
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2400));
|
||||
});
|
||||
await rerender({ pendingApprovalLabel: null, disabled: false });
|
||||
|
||||
const ticker = document.querySelector(".composer-run-strip__text")?.textContent ?? "";
|
||||
ok(/ 30s| 31s/.test(ticker), `ticker excludes the time spent waiting for approval (got "${ticker}")`);
|
||||
ok(!/ 32s| 33s/.test(ticker), "ticker does not count the ~2.4s approval wait as model time");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Resize consistency: --composer-height always carries the logical height in
|
||||
// every writer (React render, live drag, keyboard), with the run strip's
|
||||
// reservation isolated in a CSS calc — so dragging a resized composer during a
|
||||
// running turn cannot flash-shrink the card.
|
||||
{
|
||||
const stylesSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../styles.css"), "utf8");
|
||||
ok(
|
||||
stylesSource.includes("calc(var(--composer-height) + var(--composer-run-strip-reserved, 0px))"),
|
||||
"resized card height combines logical height and strip reservation in CSS",
|
||||
);
|
||||
|
||||
const dom = installDom();
|
||||
const { root, rerender } = await renderComposer({ running: true, turnStartAt: Date.now() });
|
||||
|
||||
const handle = document.querySelector(".composer-resize-handle") as HTMLButtonElement;
|
||||
await act(async () => {
|
||||
handle.focus();
|
||||
handle.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Home", bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
const card = document.querySelector(".composer-card") as HTMLElement;
|
||||
eq(card.style.getPropertyValue("--composer-height"), "104px", "render path writes the logical height, not a compensated one");
|
||||
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "30px", "running card reserves the strip height via its own variable");
|
||||
|
||||
// Drag while running: the live writer stays in logical-height space.
|
||||
await act(async () => {
|
||||
handle.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true, clientY: 300 }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(card.style.getPropertyValue("--composer-height"), "104px", "drag start does not flash-shrink the running card");
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.MouseEvent("pointermove", { bubbles: true, clientY: 280 }));
|
||||
document.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, clientY: 280 }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(card.style.getPropertyValue("--composer-height"), "124px", "drag release keeps the same logical-height space as the render path");
|
||||
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "30px", "strip reservation survives the drag");
|
||||
eq(handle.getAttribute("aria-valuenow"), "124", "separator reports the logical height");
|
||||
|
||||
await rerender({ running: false, turnStartAt: undefined });
|
||||
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "0px", "idle card releases the strip reservation");
|
||||
eq(card.style.getPropertyValue("--composer-height"), "124px", "idle card keeps the user's logical height");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -1,6 +1,6 @@
|
||||
// Run: tsx src/__tests__/context-panel-breakdown.test.ts
|
||||
|
||||
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSourceRows, contextUsageRefreshKey, contextWindowStatus, 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;
|
||||
@@ -109,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");
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
buildPerformancePayload,
|
||||
formatPerformanceContext,
|
||||
globalCrashReportReason,
|
||||
installPerformancePressureMonitor,
|
||||
normalizeCrashError,
|
||||
parseReportedPerf,
|
||||
performanceLabelForReason,
|
||||
@@ -151,6 +152,42 @@ eq(shouldPromptForPerformanceLabel(false, 5 * 60_000, false), false, "respects t
|
||||
eq(shouldPromptForPerformanceLabel(false, 11 * 60_000, true), false, "never prompts while the window is hidden");
|
||||
eq(shouldPromptForPerformanceLabel(false, 11 * 60_000, false, false), false, "never prompts while unfocused");
|
||||
|
||||
{
|
||||
let interval: (() => void) | undefined;
|
||||
let now = 0;
|
||||
let promptPainted = false;
|
||||
const previousWindow = (globalThis as any).window;
|
||||
const previousDocument = (globalThis as any).document;
|
||||
const previousPerformance = (globalThis as any).performance;
|
||||
const previousPerformanceObserver = (globalThis as any).PerformanceObserver;
|
||||
(globalThis as any).performance = { now: () => now };
|
||||
(globalThis as any).window = {
|
||||
runtime: {},
|
||||
setInterval: (cb: () => void) => {
|
||||
interval = cb;
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
(globalThis as any).document = {
|
||||
visibilityState: "visible",
|
||||
hasFocus: () => true,
|
||||
addEventListener: () => {},
|
||||
getElementById: () => {
|
||||
promptPainted = true;
|
||||
return null;
|
||||
},
|
||||
};
|
||||
(globalThis as any).PerformanceObserver = undefined;
|
||||
installPerformancePressureMonitor();
|
||||
now = 26_000;
|
||||
interval?.();
|
||||
eq(promptPainted, false, "first post-grace event-loop tick primes without reporting startup backlog");
|
||||
(globalThis as any).window = previousWindow;
|
||||
(globalThis as any).document = previousDocument;
|
||||
(globalThis as any).performance = previousPerformance;
|
||||
(globalThis as any).PerformanceObserver = previousPerformanceObserver;
|
||||
}
|
||||
|
||||
const reportedPerf = serializeReportedPerf(new Set(["performance.lag"]), "abc123");
|
||||
eq([...parseReportedPerf(reportedPerf, "abc123")], ["performance.lag"], "round-trips reported labels for the same build");
|
||||
eq([...parseReportedPerf(reportedPerf, "def456")], [], "re-surfaces reported labels on a new build");
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// Run: tsx src/__tests__/history-recovery-copies.test.tsx
|
||||
//
|
||||
// Recovery-copy bulk actions in HistoryPanel: the history view sweeps idle
|
||||
// copies into the trash (skipping current/open ones), the trash view purges
|
||||
// them, and both flows keep normal sessions untouched.
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import { registerHooks } from "node:module";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { SessionMeta } from "../lib/types";
|
||||
|
||||
// HistoryPanel transitively imports Welcome's SVG wordmark; tsx has no asset
|
||||
// loader, so redirect .svg specifiers to an empty-string module stub, the way
|
||||
// Vite would default-export a URL.
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier.endsWith(".svg")) {
|
||||
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
},
|
||||
});
|
||||
|
||||
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 (JSON.stringify(actual) === JSON.stringify(expected)) ok(true, label);
|
||||
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
|
||||
}
|
||||
|
||||
function flushTimers(ms = 0): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
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(dom.window.navigator, "language", { configurable: true, value: "en-US" });
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
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);
|
||||
globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
|
||||
return dom;
|
||||
}
|
||||
|
||||
const now = 1_750_000_000_000;
|
||||
|
||||
function session(overrides: Partial<SessionMeta> & { path: string }): SessionMeta {
|
||||
return {
|
||||
preview: "session preview",
|
||||
turns: 3,
|
||||
createdAt: now - 3_600_000,
|
||||
lastActivityAt: now,
|
||||
modTime: now,
|
||||
current: false,
|
||||
open: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function renderPanel(props: Record<string, unknown>) {
|
||||
const { HistoryPanel } = await import("../components/HistoryPanel");
|
||||
const { LocaleProvider } = await import("../lib/i18n");
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<HistoryPanel
|
||||
running={false}
|
||||
onResume={() => {}}
|
||||
onPreview={async () => []}
|
||||
onDelete={() => {}}
|
||||
onRename={() => {}}
|
||||
onClose={() => {}}
|
||||
{...props}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers(30);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
function findButton(text: string): HTMLButtonElement | undefined {
|
||||
return Array.from(document.querySelectorAll("button")).find((b) => b.textContent?.trim() === text) as
|
||||
| HTMLButtonElement
|
||||
| undefined;
|
||||
}
|
||||
|
||||
async function click(button: HTMLButtonElement) {
|
||||
await act(async () => {
|
||||
button.click();
|
||||
await flushTimers(20);
|
||||
});
|
||||
}
|
||||
|
||||
console.log("\nhistory panel recovery-copy bulk actions");
|
||||
|
||||
// History view: the sweep button trashes idle recovery copies only.
|
||||
{
|
||||
const dom = installDom();
|
||||
const deleted: string[][] = [];
|
||||
const purged: string[][] = [];
|
||||
const root = await renderPanel({
|
||||
kind: "history",
|
||||
sessions: [
|
||||
session({ path: "/s/normal.jsonl" }),
|
||||
session({ path: "/s/idle-recovery-0123456789abcdef.jsonl", recovered: true }),
|
||||
session({ path: "/s/open-recovery-0123456789abcdef.jsonl", recovered: true, open: true }),
|
||||
session({ path: "/s/current-recovery-0123456789abcdef.jsonl", recovered: true, current: true }),
|
||||
],
|
||||
onDeleteMany: (paths: string[]) => deleted.push(paths),
|
||||
onPurgeAll: (paths: string[]) => purged.push(paths),
|
||||
});
|
||||
|
||||
const sweep = findButton("Trash recovery copies");
|
||||
ok(Boolean(sweep), "history view shows the trash-recovery-copies button");
|
||||
if (sweep) {
|
||||
await click(sweep); // arm
|
||||
const confirm = findButton("Confirm trash copies");
|
||||
ok(Boolean(confirm), "sweep arms into a confirm step");
|
||||
if (confirm) await click(confirm);
|
||||
}
|
||||
eq(deleted, [["/s/idle-recovery-0123456789abcdef.jsonl"]], "sweep trashes only idle recovery copies");
|
||||
eq(purged, [], "history sweep never purges");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// History view: no idle copies → no sweep button.
|
||||
{
|
||||
const dom = installDom();
|
||||
const root = await renderPanel({
|
||||
kind: "history",
|
||||
sessions: [
|
||||
session({ path: "/s/normal.jsonl" }),
|
||||
session({ path: "/s/current-recovery-0123456789abcdef.jsonl", recovered: true, current: true }),
|
||||
],
|
||||
onDeleteMany: () => {},
|
||||
});
|
||||
ok(!findButton("Trash recovery copies"), "history view hides the sweep button when every copy is live");
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Trash view: clear copies purges recovery copies, empty trash stays separate.
|
||||
{
|
||||
const dom = installDom();
|
||||
const purged: string[][] = [];
|
||||
const root = await renderPanel({
|
||||
kind: "trash",
|
||||
sessions: [
|
||||
session({ path: "/t/normal.jsonl", deletedAt: now }),
|
||||
session({ path: "/t/a-recovery-0123456789abcdef.jsonl", deletedAt: now, recovered: true }),
|
||||
session({ path: "/t/b-recovery-0123456789abcdef.jsonl", deletedAt: now, recovered: true }),
|
||||
],
|
||||
onRestore: () => {},
|
||||
onPurge: () => {},
|
||||
onPurgeAll: (paths: string[]) => purged.push(paths),
|
||||
});
|
||||
|
||||
const clear = findButton("Clear copies");
|
||||
ok(Boolean(clear), "trash view shows the clear-copies button");
|
||||
if (clear) {
|
||||
await click(clear); // arm
|
||||
const confirm = findButton("Confirm clear copies");
|
||||
ok(Boolean(confirm), "clear copies arms into a confirm step");
|
||||
if (confirm) await click(confirm);
|
||||
}
|
||||
eq(
|
||||
purged,
|
||||
[["/t/a-recovery-0123456789abcdef.jsonl", "/t/b-recovery-0123456789abcdef.jsonl"]],
|
||||
"clear copies purges every trashed recovery copy and nothing else",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
projectTreeReadActivityKey,
|
||||
projectTreeTopicHasUnreadActivity,
|
||||
projectTreeShouldRenderTopicActions,
|
||||
projectTreeTopicMetaLine,
|
||||
} from "../components/ProjectTree";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
|
||||
@@ -27,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",
|
||||
@@ -107,6 +114,29 @@ 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",
|
||||
|
||||
@@ -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,31 @@
|
||||
// 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("\nquiet recovery prompt privacy");
|
||||
|
||||
ok(!appSource.includes("banner--recovery"), "App does not render a persistent recovery banner");
|
||||
ok(!appSource.includes("recoveryDigest"), "App does not expose recovery digest through recovery prompts");
|
||||
ok(!appSource.includes("recoveryParentId"), "App does not expose parent session id through recovery prompts");
|
||||
ok(!appSource.includes("recoveryBannerTitle"), "App does not build a detailed recovery tooltip");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,62 @@
|
||||
// Run: tsx src/__tests__/recovery-quiet-notifications.test.ts
|
||||
//
|
||||
// Recovery copies remain protected by the backend, but the desktop UI should
|
||||
// not interrupt users with recovery lifecycle banners, toasts, or transcript
|
||||
// notices.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { en } from "../locales/en";
|
||||
import { zh } from "../locales/zh";
|
||||
import { zhTW } from "../locales/zh-TW";
|
||||
|
||||
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");
|
||||
const controllerSource = readFileSync(resolve(here, "../lib/useController.ts"), "utf8");
|
||||
|
||||
console.log("\nquiet recovery notifications");
|
||||
|
||||
ok(!appSource.includes("banner--recovery"), "App does not render a recovery banner");
|
||||
ok(!appSource.includes("recovery.toast"), "App does not show a recovery-created toast");
|
||||
ok(!appSource.includes("onSessionRecoveryFailed"), "App does not show recovery failure toasts");
|
||||
ok(!appSource.includes("AcknowledgeTabRecovery"), "App does not expose recovery acknowledgement controls");
|
||||
ok(!appSource.includes("OpenTabRecoveryParent"), "App does not expose recovery compare controls");
|
||||
ok(!appSource.includes("recovery.openOriginalFailed"), "App does not carry recovery compare failure text");
|
||||
|
||||
ok(controllerSource.includes("function quietTranscriptNoticeKey"), "controller centralizes quiet transcript notices");
|
||||
ok(controllerSource.includes("if (quietTranscriptNoticeKey(rawText))"), "raw quiet notices are skipped before localization");
|
||||
ok(controllerSource.includes("if (quietTranscriptNoticeKey(text))"), "localized quiet notices are skipped before rendering");
|
||||
|
||||
const removedPromptKeys = [
|
||||
"recovery.open",
|
||||
"recovery.toast",
|
||||
"recovery.failedLease",
|
||||
"recovery.failedUnavailable",
|
||||
"recovery.banner",
|
||||
"recovery.bannerCompare",
|
||||
"recovery.bannerDismiss",
|
||||
"recovery.openOriginalFailed",
|
||||
];
|
||||
for (const [name, dict] of [["en", en], ["zh", zh], ["zh-TW", zhTW]] as const) {
|
||||
for (const key of removedPromptKeys) {
|
||||
ok(!(key in dict), `${name} locale omits ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
SettingsPanel,
|
||||
formatProviderExtraBody,
|
||||
parseProviderExtraBody,
|
||||
providerExtraBodyParseError,
|
||||
providerBaseURLFromChatURL,
|
||||
providerChatURLPreview,
|
||||
providerEditorEffectiveKind,
|
||||
@@ -41,6 +42,19 @@ function flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function installCanvasMock(win: Window) {
|
||||
Object.defineProperty(win.HTMLCanvasElement.prototype, "getContext", {
|
||||
configurable: true,
|
||||
value(type: string) {
|
||||
if (type !== "2d") return null;
|
||||
return {
|
||||
font: "",
|
||||
measureText: () => ({ width: 0 }),
|
||||
} as unknown as CanvasRenderingContext2D;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(label: string, predicate: () => boolean) {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await act(async () => {
|
||||
@@ -146,11 +160,32 @@ try {
|
||||
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,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
installCanvasMock(dom.window as unknown as Window);
|
||||
(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;
|
||||
@@ -165,6 +200,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;
|
||||
@@ -191,6 +227,7 @@ await act(async () => {
|
||||
<LocaleProvider>
|
||||
<SettingsPanel
|
||||
initialTab="general"
|
||||
desktopPlatform="linux"
|
||||
onClose={() => {}}
|
||||
onChanged={(settings?: SettingsView) => {
|
||||
onChangedSettings = settings;
|
||||
@@ -238,6 +275,7 @@ await act(async () => {
|
||||
<LocaleProvider>
|
||||
<SettingsPanel
|
||||
initialTab="general"
|
||||
desktopPlatform="linux"
|
||||
onClose={() => {}}
|
||||
onChanged={() => {}}
|
||||
/>
|
||||
@@ -266,6 +304,55 @@ 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);
|
||||
@@ -301,7 +388,7 @@ window.go = {
|
||||
await act(async () => {
|
||||
botsRoot.render(
|
||||
<LocaleProvider>
|
||||
<SettingsPanel initialTab="bots" onClose={() => {}} onChanged={() => {}} />
|
||||
<SettingsPanel initialTab="bots" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Run: tsx src/__tests__/sound.test.ts
|
||||
|
||||
import { attentionChimeEventKey, clearAttentionChimeKeys, shouldPlayAttentionChimeForEvent } from "../lib/sound";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq(a: unknown, b: unknown, label: string) {
|
||||
if (a === 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;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nsound notifications");
|
||||
|
||||
{
|
||||
eq(attentionChimeEventKey({ kind: "approval_request", tabId: "tab-a", approval: { id: "approval-1" } }), "approval:tab-a:approval-1", "approval request builds a tab-scoped chime key");
|
||||
eq(attentionChimeEventKey({ kind: "ask_request", tabId: "tab-a", ask: { id: "ask-1" } }), "ask:tab-a:ask-1", "ask request builds a tab-scoped chime key");
|
||||
eq(attentionChimeEventKey({ kind: "approval_request", approval: { id: "approval-1" } }), "approval::approval-1", "legacy approval events without a tab still build a stable key");
|
||||
eq(attentionChimeEventKey({ kind: "turn_done" }), undefined, "non-attention events do not build chime keys");
|
||||
}
|
||||
|
||||
{
|
||||
const seen = new Set<string>();
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen), true, "first approval event plays");
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen), false, "replayed approval event for the same tab is deduped");
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-b", approval: { id: "1" } }, seen), true, "same approval id from another tab still plays");
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-a", ask: { id: "1" } }, seen), true, "ask id sharing an approval id still plays");
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request" }, seen), false, "malformed approval event does not play");
|
||||
}
|
||||
|
||||
{
|
||||
// The dedupe set stays bounded: after many unique prompts it self-prunes
|
||||
// while still deduping recently seen ids.
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "t", approval: { id: String(i) } }, seen);
|
||||
}
|
||||
eq(seen.size <= 1024, true, "dedupe set stays bounded after 2000 unique prompts");
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "t", approval: { id: "1999" } }, seen), false, "most recent prompt id is still deduped after pruning");
|
||||
}
|
||||
|
||||
{
|
||||
// Runtime rebuild clears dedupe keys so reissued ids chime again.
|
||||
const seen = new Set<string>();
|
||||
shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen);
|
||||
shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-b", ask: { id: "1" } }, seen);
|
||||
clearAttentionChimeKeys(seen, "tab-a");
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "approval_request", tabId: "tab-a", approval: { id: "1" } }, seen), true, "rebuilt tab's reissued approval id chimes again");
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-b", ask: { id: "1" } }, seen), false, "other tab's keys survive a scoped clear");
|
||||
clearAttentionChimeKeys(seen);
|
||||
eq(shouldPlayAttentionChimeForEvent({ kind: "ask_request", tabId: "tab-b", ask: { id: "1" } }, seen), true, "tab-less ready clears every key");
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error(`sound notifications: ${failed} failed, ${passed} passed`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`sound notifications: ${passed} passed`);
|
||||
@@ -133,9 +133,12 @@ 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>();
|
||||
@@ -144,12 +147,15 @@ 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, tabE, tabF, tabG].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> = [];
|
||||
|
||||
@@ -172,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;
|
||||
@@ -191,6 +204,12 @@ window.go = {
|
||||
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) => {
|
||||
@@ -198,9 +217,15 @@ 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;
|
||||
@@ -509,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();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Run: tsx src/__tests__/tool-card-error-details.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ToolCard } from "../components/ToolCard";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { Item } from "../lib/useController";
|
||||
|
||||
type ToolItem = Extract<Item, { kind: "tool" }>;
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: unknown, 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 flushTimers(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
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.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
dom.window.matchMedia = () => ({
|
||||
matches: true,
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
onchange: null,
|
||||
addListener: () => undefined,
|
||||
removeListener: () => undefined,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
dispatchEvent: () => false,
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
console.log("\ntool card error details");
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
const error = "evidence 1: verification command \"Select-String -Path @(...long...)\" has no matching successful receipt — cite the command exactly as it ran in the session; commands that ran: [\"unique-command-tail\"] — pick the matching one and retry complete_step";
|
||||
const item: ToolItem = {
|
||||
kind: "tool",
|
||||
id: "complete-step-error",
|
||||
name: "complete_step",
|
||||
args: "",
|
||||
readOnly: true,
|
||||
status: "error",
|
||||
output: `error: ${error}`,
|
||||
error,
|
||||
durationMs: 62,
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(LocaleProvider, null,
|
||||
React.createElement(ToolCard, { item }),
|
||||
),
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
eq(document.querySelectorAll(".code-block").length, 0, "duplicate error output is not rendered as a code block");
|
||||
ok(document.querySelector(".tool__err-summary")?.textContent?.includes("verification command has no matching successful receipt"), "compact error summary is visible");
|
||||
ok(!document.querySelector(".tool__err-details"), "full error details are hidden by default");
|
||||
ok(!document.body.textContent?.includes("unique-command-tail"), "long receipt list is not visible before expansion");
|
||||
|
||||
const toggle = document.querySelector(".tool__err-toggle") as HTMLButtonElement | null;
|
||||
if (!toggle) throw new Error("error details toggle did not render");
|
||||
await act(async () => {
|
||||
toggle.click();
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
ok(document.querySelector(".tool__err-details")?.textContent?.includes("unique-command-tail"), "full error details render after expansion");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -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);
|
||||
@@ -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,203 @@ 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 can be normalized without exposing 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 can be normalized",
|
||||
);
|
||||
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 can be normalized",
|
||||
);
|
||||
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 can be normalized",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("session changed on disk; adopted the newer transcript"),
|
||||
"The session changed on disk, so Reasonix adopted the newer transcript.",
|
||||
"adopted transcript notice can be normalized",
|
||||
);
|
||||
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 can be normalized",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("The assistant answered before taking action; asking it to use the required tools."),
|
||||
"The assistant answered before taking action; asking it to use the required tools.",
|
||||
"canonical backend notice is routed through the locale dictionary",
|
||||
);
|
||||
eq(
|
||||
localizedBackendNoticeText("background export failed: needs attention"),
|
||||
"Background export needs attention.",
|
||||
"dynamic background job 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, 0, "recovery conflict notices stay silent in the live transcript");
|
||||
eq(s.seq, 0, "silent recovery notices do not consume sequence ids");
|
||||
|
||||
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 quietLifecycleMessages = [
|
||||
{ level: "info", text: "guardian enabled · model=guardian-test" },
|
||||
{ level: "warn", text: "2 MCP server(s) failed to start: fs, browser — run /mcp for details" },
|
||||
{ level: "warn", text: "mcp fs: stdio plugin \"fs\": command \"missing-fs\" not found on PATH" },
|
||||
{ level: "info", text: "settings applied: session refreshed after the lease was released" },
|
||||
{ level: "info", text: "plugin \"slowserver\" has been slow 3 startups in a row (last 30000ms, budget 1000ms); demoting to background startup this session" },
|
||||
] as const;
|
||||
let s = initialState;
|
||||
for (const message of quietLifecycleMessages) {
|
||||
s = reducer(s, { type: "event", e: { kind: "notice", level: message.level, text: message.text } });
|
||||
}
|
||||
eq(s.items.filter((item) => item.kind === "notice").length, 0, "background lifecycle notices stay silent in the live transcript");
|
||||
eq(s.seq, 0, "silent lifecycle notices do not consume sequence ids");
|
||||
|
||||
const userActionFailure = reducer(s, { type: "event", e: { kind: "notice", level: "warn", text: "mcp connect: no configured MCP server named \"fs\"" } });
|
||||
const visibleNotices = userActionFailure.items.filter((item) => item.kind === "notice");
|
||||
eq(visibleNotices.length, 1, "user-triggered MCP failures remain visible");
|
||||
eq(visibleNotices[0]?.kind === "notice" && visibleNotices[0].text, "mcp connect: no configured MCP server named \"fs\"", "visible MCP failure keeps its text");
|
||||
}
|
||||
|
||||
{
|
||||
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: "notice", level: "info", content: "guardian enabled · model=guardian-test" },
|
||||
{ role: "notice", level: "warn", content: "1 MCP server(s) failed to start: fs — run /mcp for details" },
|
||||
{ role: "notice", level: "info", content: "settings applied: session refreshed after the lease was released" },
|
||||
{ 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 lifecycleNotices = hydrated.items.filter((item) => item.kind === "notice");
|
||||
const users = hydrated.items.filter((item) => item.kind === "user");
|
||||
eq(recoveryNotices.length, 0, "recovery conflict notices stay silent when hydrating history");
|
||||
eq(lifecycleNotices.length, 0, "background lifecycle notices stay silent when hydrating history");
|
||||
eq(users[0]?.kind === "user" && users[0].id, "h0", "silent history notices keep later item ids compact");
|
||||
eq(hydrated.seq, 1, "silent history notices do not inflate the hydrated sequence");
|
||||
}
|
||||
|
||||
{
|
||||
const hydrated = historyMessagesToItems([{ role: "notice", level: "warn", content: "short notice", detail: "historical diagnostic" }], "h");
|
||||
const notice = hydrated.items.find((item) => item.kind === "notice" && item.text === "short notice");
|
||||
eq(notice?.kind === "notice" && notice.detail, "historical diagnostic", "history notices preserve expandable detail text");
|
||||
}
|
||||
|
||||
{
|
||||
eq(sameMeta(meta(), meta()), true, "identical meta is unchanged");
|
||||
eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "plan" })), false, "collaboration mode changes invalidate meta equality");
|
||||
@@ -211,6 +408,12 @@ console.log("\nuse controller meta");
|
||||
eq(Boolean(notice), true, "turn_done checkpoint merge preserves runtime notices");
|
||||
}
|
||||
|
||||
{
|
||||
const s = reducer(initialState, { type: "event", e: { kind: "notice", level: "warn", text: "short notice", detail: "verbose diagnostic" } });
|
||||
const notice = s.items.find((item) => item.kind === "notice" && item.text === "short notice");
|
||||
eq(notice?.kind === "notice" && notice.detail, "verbose diagnostic", "runtime notices preserve expandable detail text");
|
||||
}
|
||||
|
||||
{
|
||||
let s = reducer(initialState, {
|
||||
type: "history_page",
|
||||
|
||||
@@ -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);
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import gsap from "gsap";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { ComposerInsertRequest, DirEntry, WireApproval } from "../lib/types";
|
||||
import { useT, type Translator } from "../lib/i18n";
|
||||
import type { ComposerInsertRequest, DirEntry, ToolApprovalMode, WireApproval } from "../lib/types";
|
||||
import { PromptAction, PromptBadge, PromptHeaderAction, PromptShelf } from "./PromptShelf";
|
||||
import { playAttentionChime } from "../lib/sound";
|
||||
import { DUR_FAST } from "../lib/gsapAnimations";
|
||||
import {
|
||||
FileReferenceMenu,
|
||||
@@ -28,7 +27,93 @@ function animateShelfExit(
|
||||
}
|
||||
|
||||
function requiresFreshHumanApproval(tool: string): boolean {
|
||||
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode";
|
||||
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode" || tool === "sandbox_escape";
|
||||
}
|
||||
|
||||
const APPROVAL_MODE_RANK: Record<ToolApprovalMode, number> = { ask: 0, auto: 1, yolo: 2 };
|
||||
|
||||
export 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");
|
||||
case "exit_plan_mode":
|
||||
return t("approval.toolLabelExitPlan");
|
||||
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({
|
||||
@@ -40,6 +125,7 @@ export function ApprovalModal({
|
||||
cwd,
|
||||
insertRequest,
|
||||
onRevisionActiveChange,
|
||||
toolApprovalMode,
|
||||
}: {
|
||||
approval: WireApproval;
|
||||
onAnswer: (allow: boolean, session: boolean, persist: boolean) => void;
|
||||
@@ -49,21 +135,35 @@ export function ApprovalModal({
|
||||
cwd?: string;
|
||||
insertRequest?: ComposerInsertRequest | null;
|
||||
onRevisionActiveChange?: (active: boolean) => void;
|
||||
toolApprovalMode?: ToolApprovalMode;
|
||||
}) {
|
||||
const t = useT();
|
||||
const isPlanApproval = approval.tool === "exit_plan_mode";
|
||||
const isPlanModeReadOnlyCommand = approval.tool === "plan_mode_read_only_command";
|
||||
const toolLabel = isPlanModeReadOnlyCommand ? "bash" : approval.tool;
|
||||
const toolLabel = approvalToolLabel(approval.tool, t);
|
||||
const isFreshHumanApproval = requiresFreshHumanApproval(approval.tool);
|
||||
const subject = approval.subject.trim();
|
||||
const hasFreshSessionGrant = approval.tool === "sandbox_escape";
|
||||
// Switching the approval segmented control to a more permissive mode does not
|
||||
// resolve an already-pending request; say so on the card instead of leaving
|
||||
// the user to wonder why the switch "did nothing".
|
||||
const initialToolApprovalModeRef = useRef(toolApprovalMode);
|
||||
const approvalModeRelaxed =
|
||||
!isPlanApproval &&
|
||||
toolApprovalMode !== undefined &&
|
||||
initialToolApprovalModeRef.current !== undefined &&
|
||||
APPROVAL_MODE_RANK[toolApprovalMode] > APPROVAL_MODE_RANK[initialToolApprovalModeRef.current];
|
||||
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 = approval.reason?.trim() || subjectSummary || approval.tool;
|
||||
const hasToolDetails = Boolean(approval.reason || subject);
|
||||
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));
|
||||
// Action index currently hovered/focused; the consequence preview row
|
||||
// prefers it over the keyboard-selected action.
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(null);
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const shelfRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
@@ -100,6 +200,9 @@ export function ApprovalModal({
|
||||
|
||||
const chooseToolAction = (key: string) => {
|
||||
if (key === "1") answerWithExit(() => onAnswer(true, 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));
|
||||
@@ -107,16 +210,22 @@ export function ApprovalModal({
|
||||
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();
|
||||
setHoverIndex(null);
|
||||
}, [approval.id, isPlanApproval, showToolDetailsByDefault]);
|
||||
|
||||
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? 2 : 4;
|
||||
const actionCount = isPlanApproval ? 3 : isFreshHumanApproval ? (hasFreshSessionGrant ? 3 : 2) : 4;
|
||||
const selectedIndexRef = useRef(selectedIndex);
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
|
||||
@@ -137,8 +246,7 @@ export function ApprovalModal({
|
||||
event.preventDefault();
|
||||
const key = String(selectedIndexRef.current + 1);
|
||||
if (isPlanApproval) choosePlanAction(key);
|
||||
else if (isFreshHumanApproval) chooseToolAction(selectedIndexRef.current === 0 ? "1" : "4");
|
||||
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);
|
||||
@@ -147,7 +255,7 @@ export function ApprovalModal({
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [isPlanApproval, isFreshHumanApproval, onAnswer, onExitPlan, onStop, actionCount]);
|
||||
}, [isPlanApproval, isFreshHumanApproval, hasFreshSessionGrant, onAnswer, onExitPlan, onStop, actionCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (revisionOpen) {
|
||||
@@ -293,6 +401,26 @@ export function ApprovalModal({
|
||||
);
|
||||
}
|
||||
|
||||
// Descriptor list mirrors the action buttons below; the consequence row
|
||||
// previews what the hovered (or keyboard-selected) action will actually do,
|
||||
// in the quick-pick style the ask card uses for option descriptions.
|
||||
const toolActions: { key: string; label: string; desc: string; run: () => void }[] = [
|
||||
{ key: "1", label: t("approval.allowOnce"), desc: t("approval.allowOnceDesc"), run: () => onAnswer(true, false, false) },
|
||||
...(isFreshHumanApproval
|
||||
? hasFreshSessionGrant
|
||||
? [
|
||||
{ key: "2", label: t("approval.allowSandboxEscapeSession"), desc: t("approval.allowSandboxEscapeSessionDesc"), run: () => onAnswer(true, true, false) },
|
||||
{ key: "3", label: t("approval.deny"), desc: t("approval.denyDesc"), run: () => onAnswer(false, false, false) },
|
||||
]
|
||||
: [{ key: "2", label: t("approval.deny"), desc: t("approval.denyDesc"), run: () => onAnswer(false, false, false) }]
|
||||
: [
|
||||
{ key: "2", label: t("approval.allowRuleSession"), desc: t("approval.allowRuleSessionDesc"), run: () => onAnswer(true, true, false) },
|
||||
{ key: "3", label: t("approval.allowRulePersistent"), desc: t("approval.allowRulePersistentDesc"), run: () => onAnswer(true, true, true) },
|
||||
{ key: "4", label: t("approval.deny"), desc: t("approval.denyDesc"), run: () => onAnswer(false, false, false) },
|
||||
]),
|
||||
];
|
||||
const previewAction = toolActions[hoverIndex ?? selectedIndex] ?? null;
|
||||
|
||||
return (
|
||||
<div ref={shelfRef}>
|
||||
<PromptShelf
|
||||
@@ -316,26 +444,46 @@ export function ApprovalModal({
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<PromptAction keyLabel="1" label={t("approval.allowOnce")} onClick={() => answerWithExit(() => onAnswer(true, false, false))} selected={selectedIndex === 0} />
|
||||
{isFreshHumanApproval ? (
|
||||
<PromptAction keyLabel="4" 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} />
|
||||
</>
|
||||
)}
|
||||
{toolActions.map((action, index) => (
|
||||
<PromptAction
|
||||
key={action.key}
|
||||
keyLabel={action.key}
|
||||
label={action.label}
|
||||
onClick={() => answerWithExit(action.run)}
|
||||
selected={selectedIndex === index}
|
||||
title={action.desc}
|
||||
onHoverChange={(hovering) =>
|
||||
setHoverIndex((current) => (hovering ? index : current === index ? null : current))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
note={
|
||||
previewAction && (
|
||||
<div className="approval-consequence">
|
||||
<span className="approval-consequence__label">{previewAction.label}</span>
|
||||
<span className="approval-consequence__text">{previewAction.desc}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
{detailsOpen && (
|
||||
<div className="approval-details">
|
||||
{approval.reason && <div className="approval-reason">{approval.reason}</div>}
|
||||
{subject && (
|
||||
<pre className="approval-subject">{subject}</pre>
|
||||
{/* Guard the whole block: PromptShelf only renders its body when children
|
||||
are truthy, and a fragment of two false branches would still count. */}
|
||||
{(approvalModeRelaxed || detailsOpen) && (
|
||||
<>
|
||||
{approvalModeRelaxed && (
|
||||
<div className="approval-mode-hint">{t("approval.modeSwitchPendingHint")}</div>
|
||||
)}
|
||||
</div>
|
||||
{detailsOpen && (
|
||||
<div className="approval-details">
|
||||
{reason && <div className="approval-reason">{reason}</div>}
|
||||
{subject && (
|
||||
<pre className="approval-subject">{subject}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PromptShelf>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { QuestionAnswer, WireAsk, WireAskQuestion } from "../lib/types";
|
||||
import { PromptAction, PromptHeaderAction, PromptShelf } from "./PromptShelf";
|
||||
import { playAttentionChime } from "../lib/sound";
|
||||
|
||||
// AskCard renders the `ask` tool as a compact prompt shelf near the composer. It
|
||||
// walks multi-question asks one at a time; single-select answers advance
|
||||
@@ -24,6 +23,8 @@ export function AskCard({
|
||||
const [custom, setCustom] = useState<Record<string, string>>({});
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
// Option label currently hovered/focused; drives the detail preview row.
|
||||
const [hoverLabel, setHoverLabel] = useState<string | null>(null);
|
||||
const shelfRef = useRef<HTMLDivElement | null>(null);
|
||||
const customInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const advanceTimer = useRef<number | null>(null);
|
||||
@@ -40,8 +41,11 @@ export function AskCard({
|
||||
setCustom({});
|
||||
setCustomOpen(false);
|
||||
setActive(0);
|
||||
setHoverLabel(null);
|
||||
if (advanceTimer.current != null) window.clearTimeout(advanceTimer.current);
|
||||
playAttentionChime();
|
||||
// The attention chime plays from the global runtime event stream (App.tsx),
|
||||
// keyed by prompt id — not here. A mount-time chime would double-fire for
|
||||
// the active tab and stay silent for background tabs.
|
||||
}, [ask.id]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -73,6 +77,7 @@ export function AskCard({
|
||||
|
||||
useEffect(() => {
|
||||
setCustomOpen(false);
|
||||
setHoverLabel(null);
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -156,6 +161,23 @@ export function AskCard({
|
||||
[active, custom, questions, sel],
|
||||
);
|
||||
|
||||
// Grid cells truncate descriptions to one line; this row previews the full
|
||||
// description of the hovered/focused option, falling back to the latest
|
||||
// selected option and then the first described option so it never blanks.
|
||||
const detailOption = useMemo(() => {
|
||||
if (!q) return null;
|
||||
const withDescription = (label?: string | null) =>
|
||||
label ? q.options.find((option) => option.label === label && option.description) : undefined;
|
||||
const hovered = withDescription(hoverLabel);
|
||||
if (hovered) return hovered;
|
||||
const selectedLabels = sel[q.id] ?? [];
|
||||
for (let i = selectedLabels.length - 1; i >= 0; i -= 1) {
|
||||
const chosen = withDescription(selectedLabels[i]);
|
||||
if (chosen) return chosen;
|
||||
}
|
||||
return q.options.find((option) => option.description) ?? null;
|
||||
}, [hoverLabel, q, sel]);
|
||||
|
||||
if (!q) return null;
|
||||
|
||||
return (
|
||||
@@ -196,11 +218,23 @@ export function AskCard({
|
||||
description={o.description}
|
||||
onClick={() => toggle(q, o.label)}
|
||||
selected={on}
|
||||
title={o.description || undefined}
|
||||
onHoverChange={(hovering) =>
|
||||
setHoverLabel((current) => (hovering ? o.label : current === o.label ? null : current))
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
}
|
||||
note={
|
||||
detailOption && (
|
||||
<div className="ask-shelf__detail">
|
||||
<span className="ask-shelf__detail-label">{detailOption.label}</span>
|
||||
<span className="ask-shelf__detail-text">{detailOption.description}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
quickActions={
|
||||
<>
|
||||
{active > 0 && (
|
||||
|
||||
@@ -51,6 +51,9 @@ const LONG_PASTE_MIN_CHARS = 2000;
|
||||
const LONG_PASTE_MIN_LINES = 20;
|
||||
const COMPOSER_MIN_HEIGHT = 104;
|
||||
const COMPOSER_MAX_HEIGHT = 360;
|
||||
// Height reserved for the in-card run strip while a turn runs; applied via a
|
||||
// CSS calc so --composer-height always stays in "logical height" space.
|
||||
const COMPOSER_RUN_STRIP_RESERVED = 30;
|
||||
const COMPOSER_MAX_VIEWPORT_RATIO = 0.4;
|
||||
const COMPOSER_AUTO_RESERVED_HEIGHT = 58;
|
||||
const PROMPT_HISTORY_PREFETCH_REMAINING = 3;
|
||||
@@ -255,6 +258,14 @@ function composerMaxHeight(): number {
|
||||
return Math.max(COMPOSER_MIN_HEIGHT, Math.min(COMPOSER_MAX_HEIGHT, Math.floor(window.innerHeight * COMPOSER_MAX_VIEWPORT_RATIO)));
|
||||
}
|
||||
|
||||
// The rendered card includes the run strip while a turn runs; subtract it to
|
||||
// recover the user's logical height when measuring from the DOM.
|
||||
function composerLogicalHeight(card: HTMLElement): number {
|
||||
const strip = card.querySelector(".composer-run-strip");
|
||||
const stripHeight = strip ? strip.getBoundingClientRect().height : 0;
|
||||
return card.getBoundingClientRect().height - stripHeight;
|
||||
}
|
||||
|
||||
function clampComposerHeight(height: number): number {
|
||||
return Math.min(Math.max(Math.round(height), COMPOSER_MIN_HEIGHT), composerMaxHeight());
|
||||
}
|
||||
@@ -449,6 +460,8 @@ export function Composer({
|
||||
turnStartAt,
|
||||
turnTokens,
|
||||
retry,
|
||||
pendingApprovalLabel,
|
||||
pendingAsk = false,
|
||||
transientDismissSignal,
|
||||
sessionKey,
|
||||
fileRefRefreshKey,
|
||||
@@ -492,6 +505,11 @@ export function Composer({
|
||||
turnStartAt?: number;
|
||||
turnTokens?: number;
|
||||
retry?: { attempt: number; max: number };
|
||||
// Resolved label of the tool waiting for approval (null/undefined when none);
|
||||
// shifts the run strip into its waiting state so the ticking spinner does not
|
||||
// claim the model is working while a prompt is blocked on the user.
|
||||
pendingApprovalLabel?: string | null;
|
||||
pendingAsk?: boolean;
|
||||
transientDismissSignal?: number;
|
||||
sessionKey?: string;
|
||||
fileRefRefreshKey?: number | string;
|
||||
@@ -1536,7 +1554,19 @@ export function Composer({
|
||||
const handleCancel = () => {
|
||||
const restored = onCancel();
|
||||
if (goalModeOn && activeGoal) onClearGoal();
|
||||
if (typeof restored === "string") setTextCaretEnd(restored);
|
||||
// Queued guidance the model never consumed would otherwise vanish when the
|
||||
// cancelled turn ends (the running→false effect clears the shelf). Fold it
|
||||
// back into the draft: cancelling means "stop acting", not "discard what I
|
||||
// typed" — the same contract onCancel already honors for un-sent text.
|
||||
const queued = pendingGuidance.map((item) => item.text).filter((part) => part.trim() !== "");
|
||||
if (queued.length === 0) {
|
||||
if (typeof restored === "string") setTextCaretEnd(restored);
|
||||
return;
|
||||
}
|
||||
setPendingGuidance([]);
|
||||
setGuidanceExpanded(false);
|
||||
const base = typeof restored === "string" ? restored : text;
|
||||
setTextCaretEnd([base, ...queued].filter((part) => part.trim() !== "").join("\n"));
|
||||
};
|
||||
|
||||
const pickCommand = (c: CommandInfo) => setTextCaretEnd("/" + c.name + " ");
|
||||
@@ -1637,7 +1667,7 @@ export function Composer({
|
||||
|
||||
e.preventDefault();
|
||||
const startY = e.clientY;
|
||||
const startHeight = composerHeight ?? card.getBoundingClientRect().height;
|
||||
const startHeight = composerHeight ?? composerLogicalHeight(card);
|
||||
let nextHeight = clampComposerHeight(startHeight);
|
||||
let moved = false;
|
||||
card.style.setProperty("--composer-height", `${nextHeight}px`);
|
||||
@@ -1675,7 +1705,7 @@ export function Composer({
|
||||
|
||||
const onComposerResizeKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
|
||||
const card = composerCardRef.current;
|
||||
const current = composerHeight ?? card?.getBoundingClientRect().height ?? COMPOSER_MIN_HEIGHT;
|
||||
const current = composerHeight ?? (card ? composerLogicalHeight(card) : COMPOSER_MIN_HEIGHT);
|
||||
const step = e.shiftKey ? 32 : 16;
|
||||
let next: number | null = null;
|
||||
if (e.key === "ArrowUp" || e.key === "PageUp") next = current + step;
|
||||
@@ -2006,7 +2036,18 @@ export function Composer({
|
||||
}
|
||||
};
|
||||
|
||||
const composerCardStyle = composerHeight === null ? undefined : ({ "--composer-height": `${composerHeight}px` } as CSSProperties);
|
||||
// When the run strip is visible inside a user-resized card, the card grows
|
||||
// by the strip's reserved height so the meta row stays fully visible.
|
||||
// --composer-height carries only the user's logical height; the reservation
|
||||
// is a separate variable consumed by the CSS calc, so the live resize drag
|
||||
// (which writes raw logical heights) stays consistent with this render path.
|
||||
const showRunStrip = Boolean(retry || running);
|
||||
const composerCardStyle = composerHeight === null
|
||||
? undefined
|
||||
: ({
|
||||
"--composer-height": `${composerHeight}px`,
|
||||
"--composer-run-strip-reserved": `${showRunStrip ? COMPOSER_RUN_STRIP_RESERVED : 0}px`,
|
||||
} as CSSProperties);
|
||||
const textareaStyle = composerHeight === null && textareaAutoHeight !== null
|
||||
? ({ height: `${textareaAutoHeight}px`, overflowY: textareaAutoOverflow ? "auto" : "hidden" } as CSSProperties)
|
||||
: undefined;
|
||||
@@ -2054,17 +2095,45 @@ export function Composer({
|
||||
requestAnimationFrame(() => taRef.current?.focus());
|
||||
});
|
||||
};
|
||||
const runActivity = retry
|
||||
// Run-strip state machine: retry > waiting-approval > waiting-ask > streaming.
|
||||
// The waiting states replace the whimsical ticker — while a prompt is blocked
|
||||
// on the user, the strip must say so instead of counting "working" seconds.
|
||||
const waitingPrompt = pendingApprovalLabel ? "approval" : pendingAsk ? "ask" : null;
|
||||
// The pending approval itself disables the composer; keep the approval bar
|
||||
// usable in exactly that case so the mode can still be changed mid-prompt.
|
||||
const approvalBarDisabled = Boolean(disabled) && !pendingApprovalLabel;
|
||||
// Waiting on the user (approval/ask) is not model work: pause the ticker's
|
||||
// clock while a prompt is blocked so the elapsed time means "model time",
|
||||
// matching what the waiting strip promises. Retries keep counting — they are
|
||||
// system time, not user time. Accumulated in state via the effect cleanup so
|
||||
// leaving the waiting state re-renders with the corrected time immediately.
|
||||
const [waitAccumMs, setWaitAccumMs] = useState(0);
|
||||
useEffect(() => {
|
||||
setWaitAccumMs(0);
|
||||
}, [turnStartAt]);
|
||||
useEffect(() => {
|
||||
if (!waitingPrompt) return;
|
||||
const since = Date.now();
|
||||
return () => setWaitAccumMs((total) => total + (Date.now() - since));
|
||||
}, [waitingPrompt]);
|
||||
const runStateText = retry
|
||||
? t("status.retrying", { attempt: retry.attempt, max: retry.max })
|
||||
: running && turnStartAt
|
||||
? (() => {
|
||||
const elapsedMs = Math.max(0, now - turnStartAt);
|
||||
const words = SPINNER_WORDS[locale];
|
||||
const word = words[Math.floor(elapsedMs / 3000) % words.length];
|
||||
const tok = turnTokens && turnTokens > 0 ? ` · ↓ ${fmtTokens(turnTokens)} ${t("status.tokens")}` : "";
|
||||
return `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
|
||||
})()
|
||||
: null;
|
||||
: waitingPrompt === "approval"
|
||||
? t("composer.runWaitingApproval", { tool: pendingApprovalLabel ?? "" })
|
||||
: waitingPrompt === "ask"
|
||||
? t("composer.runWaitingAsk")
|
||||
: running
|
||||
? t("composer.runAnnounceRunning")
|
||||
: null;
|
||||
const runTicker = !retry && !waitingPrompt && running && turnStartAt
|
||||
? (() => {
|
||||
const elapsedMs = Math.max(0, now - turnStartAt - waitAccumMs);
|
||||
const words = SPINNER_WORDS[locale];
|
||||
const word = words[Math.floor(elapsedMs / 3000) % words.length];
|
||||
const tok = turnTokens && turnTokens > 0 ? ` · ↓ ${fmtTokens(turnTokens)} ${t("status.tokens")}` : "";
|
||||
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");
|
||||
@@ -2322,20 +2391,6 @@ export function Composer({
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{runActivity && (
|
||||
<div className="composer-toolbar composer-toolbar--status-only">
|
||||
<div className="composer-runstatus" role="status" aria-live="polite">
|
||||
<span className="composer-runstatus__dot" />
|
||||
<span className="composer-runstatus__text">{runActivity}</span>
|
||||
<Tooltip label={t("composer.stop")}>
|
||||
<button className="composer-runstatus__stop" type="button" onClick={handleCancel}>
|
||||
<Square size={10} fill="currentColor" />
|
||||
<span>{t("composer.stopShort")}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pendingGuidance.length > 0 && (
|
||||
<div className="composer-guidance-shelf" aria-label={t("composer.guidanceQueue")}>
|
||||
<div className="composer-guidance-head">
|
||||
@@ -2484,7 +2539,7 @@ export function Composer({
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`composer-card${composerHeight !== null || composerResizing ? " composer-card--resized" : ""}${composerAutoExpanded ? " composer-card--autosized" : ""}${composerResizing ? " composer-card--resizing" : ""}${running ? " composer-card--running" : ""}`}
|
||||
className={`composer-card${composerHeight !== null || composerResizing ? " composer-card--resized" : ""}${composerAutoExpanded ? " composer-card--autosized" : ""}${composerResizing ? " composer-card--resizing" : ""}${running ? (waitingPrompt ? " composer-card--waiting" : " composer-card--running") : ""}`}
|
||||
ref={composerCardRef}
|
||||
style={composerCardStyle}
|
||||
>
|
||||
@@ -2502,6 +2557,17 @@ export function Composer({
|
||||
onKeyDown={onComposerResizeKeyDown}
|
||||
onDoubleClick={resetComposerHeight}
|
||||
/>
|
||||
{runStateText && (
|
||||
<div className={`composer-run-strip${waitingPrompt ? " composer-run-strip--waiting" : ""}`}>
|
||||
<span className="composer-run-strip__dot" aria-hidden="true" />
|
||||
{/* The ticker re-renders every second; keep it out of the accessibility
|
||||
tree and announce only the stable state text via the live region. */}
|
||||
<span className="composer-run-strip__text" aria-hidden={runTicker ? true : undefined}>
|
||||
{runTicker ?? runStateText}
|
||||
</span>
|
||||
<span className="sr-only" role="status">{runStateText}</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`composer${dragOver ? " composer--dragover" : ""}${disabled || readOnly ? " composer--disabled" : ""}${shellModeActive ? " composer--shell" : ""}`}
|
||||
onDrop={onDrop}
|
||||
@@ -2543,6 +2609,18 @@ export function Composer({
|
||||
{composerPrompt}
|
||||
</span>
|
||||
)}
|
||||
{running && (
|
||||
<Tooltip label={t("composer.stop")}>
|
||||
<button
|
||||
className="composer__btn composer__btn--stop"
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
aria-label={t("composer.stop")}
|
||||
>
|
||||
<Square size={12} fill="currentColor" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={submitTooltip}>
|
||||
<button
|
||||
className={`composer__btn composer__btn--send${running ? " composer__btn--steer" : ""}`}
|
||||
@@ -2550,7 +2628,7 @@ export function Composer({
|
||||
disabled={submitBlocked}
|
||||
aria-label={submitTooltip}
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
{running ? <CornerDownRight size={16} /> : <ArrowUp size={16} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -2634,13 +2712,17 @@ export function Composer({
|
||||
)}
|
||||
</div>
|
||||
<div className="composer-meta__control composer-meta__control--approval">
|
||||
{/* A pending tool approval disables the composer, but the approval
|
||||
bar stays usable so mode changes remain possible mid-prompt;
|
||||
the approval card explains that the pending request still needs
|
||||
an explicit decision. */}
|
||||
<div className="composer-modebar composer-modebar--approval" data-mode={toolApprovalMode} title={t("composer.accessMenuTitle")}>
|
||||
<span className="composer-modebar__thumb" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
className={`composer-modebar__item composer-modebar__item--ask${toolApprovalMode === "ask" ? " composer-modebar__item--active" : ""}`}
|
||||
onClick={() => chooseApprovalMode("ask")}
|
||||
disabled={disabled}
|
||||
disabled={approvalBarDisabled}
|
||||
aria-pressed={toolApprovalMode === "ask"}
|
||||
title={t("composer.accessAskTitle")}
|
||||
>
|
||||
@@ -2651,7 +2733,7 @@ export function Composer({
|
||||
type="button"
|
||||
className={`composer-modebar__item composer-modebar__item--auto${toolApprovalMode === "auto" ? " composer-modebar__item--active" : ""}`}
|
||||
onClick={() => chooseApprovalMode("auto")}
|
||||
disabled={disabled}
|
||||
disabled={approvalBarDisabled}
|
||||
aria-pressed={toolApprovalMode === "auto"}
|
||||
title={t("composer.accessAutoTitle")}
|
||||
>
|
||||
@@ -2662,7 +2744,7 @@ export function Composer({
|
||||
type="button"
|
||||
className={`composer-modebar__item composer-modebar__item--yolo${toolApprovalMode === "yolo" ? " composer-modebar__item--active" : ""}`}
|
||||
onClick={() => chooseApprovalMode("yolo")}
|
||||
disabled={disabled}
|
||||
disabled={approvalBarDisabled}
|
||||
aria-pressed={toolApprovalMode === "yolo"}
|
||||
title={t("composer.accessYoloTitle")}
|
||||
>
|
||||
|
||||
@@ -126,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;
|
||||
@@ -354,11 +370,12 @@ export function ContextPanel({
|
||||
? usage.totalTokens
|
||||
: promptTokens + completionTokens;
|
||||
const reasoningTokens = usage?.reasoningTokens ?? info?.reasoningTokens ?? 0;
|
||||
// Session-cumulative values for the top summary.
|
||||
// Prefer usage.sessionCacheHitTokens — it is the session-cumulative value from
|
||||
// the Go agent (includes background tasks) and arrives with every usage event.
|
||||
const sessionCacheHit = usage?.sessionCacheHitTokens ?? info?.sessionCacheHitTokens ?? context?.cacheHitTokens ?? 0;
|
||||
const sessionCacheMiss = usage?.sessionCacheMissTokens ?? info?.sessionCacheMissTokens ?? context?.cacheMissTokens ?? 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);
|
||||
|
||||
@@ -28,6 +28,7 @@ export function HistoryPanel({
|
||||
onRestore,
|
||||
onPurge,
|
||||
onPurgeAll,
|
||||
onDeleteMany,
|
||||
onClose,
|
||||
}: {
|
||||
kind?: "history" | "trash";
|
||||
@@ -40,6 +41,7 @@ export function HistoryPanel({
|
||||
onRestore?: (path: string) => void;
|
||||
onPurge?: (path: string) => void;
|
||||
onPurgeAll?: (paths: string[]) => void;
|
||||
onDeleteMany?: (paths: string[]) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const tr = useT();
|
||||
@@ -56,7 +58,7 @@ export function HistoryPanel({
|
||||
const [menuPoint, setMenuPoint] = useState<ContextMenuPoint | null>(null);
|
||||
const [blankMenuPoint, setBlankMenuPoint] = useState<ContextMenuPoint | null>(null);
|
||||
const [menuConfirmTarget, setMenuConfirmTarget] = useState<
|
||||
{ kind: "delete"; path: string } | { kind: "purge"; path: string } | { kind: "clear" } | null
|
||||
{ kind: "delete"; path: string } | { kind: "purge"; path: string } | { kind: "clear" } | { kind: "clearRecovery" } | null
|
||||
>(null);
|
||||
const [preview, setPreview] = useState<{
|
||||
path: string;
|
||||
@@ -144,15 +146,33 @@ export function HistoryPanel({
|
||||
return [s.title, s.preview, s.path, s.topicTitle, s.workspaceRoot].some((part) => (part ?? "").toLowerCase().includes(q));
|
||||
});
|
||||
}, [dateFilter, isTrash, query, scopeFilter, sessions, statusFilter]);
|
||||
// Recovery copies are bulk-actionable in both views: trash purges them for
|
||||
// good, history sweeps them into the trash. The history sweep skips copies
|
||||
// that are current or open in a tab — an adopted copy is someone's live
|
||||
// conversation. Counting from `sessions` (not the filtered list) keeps the
|
||||
// sweep exhaustive even while a search or filter is active.
|
||||
const recoveryCopyPaths = useMemo(
|
||||
() => sessions.filter((s) => s.recovered && (isTrash || (!s.current && !s.open))).map((s) => s.path),
|
||||
[isTrash, sessions],
|
||||
);
|
||||
const recoveryCopyCount = recoveryCopyPaths.length;
|
||||
const displayedSessions = useMemo(
|
||||
() =>
|
||||
isTrash
|
||||
? filteredSessions
|
||||
: [...filteredSessions.filter((s) => !s.recovered), ...filteredSessions.filter((s) => s.recovered)],
|
||||
[filteredSessions, isTrash],
|
||||
);
|
||||
|
||||
// Sessions arrive newest-first; bucket consecutive ones under a day heading
|
||||
// (Today / Yesterday / a date) while preserving that order.
|
||||
const groups: { label: string; items: SessionMeta[] }[] = [];
|
||||
for (const s of filteredSessions) {
|
||||
const groups: { label: string; items: SessionMeta[]; recovered: boolean }[] = [];
|
||||
for (const s of displayedSessions) {
|
||||
const recovered = !isTrash && Boolean(s.recovered);
|
||||
const label = dayLabel(sessionTimeForGrouping(s, isTrash));
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.label === label) last.items.push(s);
|
||||
else groups.push({ label, items: [s] });
|
||||
if (last && last.label === label && last.recovered === recovered) last.items.push(s);
|
||||
else groups.push({ label, items: [s], recovered });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -168,19 +188,19 @@ export function HistoryPanel({
|
||||
|
||||
useEffect(() => {
|
||||
setEditing(null);
|
||||
if (filteredSessions.length === 0) {
|
||||
if (displayedSessions.length === 0) {
|
||||
if (preview) setPreview(null);
|
||||
return;
|
||||
}
|
||||
if (preview && filteredSessions.some((s) => s.path === preview.path)) return;
|
||||
const first = filteredSessions.find((s) => !s.current) ?? filteredSessions[0];
|
||||
if (preview && displayedSessions.some((s) => s.path === preview.path)) return;
|
||||
const first = displayedSessions.find((s) => !s.current) ?? displayedSessions[0];
|
||||
void loadPreview(first);
|
||||
}, [filteredSessions, loadPreview, preview]);
|
||||
}, [displayedSessions, loadPreview, preview]);
|
||||
|
||||
const previewItems = useMemo(() => previewMessagesToItems(preview?.messages ?? []), [preview?.messages]);
|
||||
const selectedSession = useMemo(
|
||||
() => (preview ? filteredSessions.find((s) => s.path === preview.path) ?? null : null),
|
||||
[filteredSessions, preview],
|
||||
() => (preview ? displayedSessions.find((s) => s.path === preview.path) ?? null : null),
|
||||
[displayedSessions, preview],
|
||||
);
|
||||
const openSessionMenu = (event: ReactMouseEvent<HTMLElement>, s: SessionMeta) => {
|
||||
event.preventDefault();
|
||||
@@ -207,6 +227,13 @@ export function HistoryPanel({
|
||||
setBlankMenuPoint(null);
|
||||
setMenuConfirmTarget({ kind: "clear" });
|
||||
};
|
||||
const armClearRecoveryCopies = () => {
|
||||
if (recoveryCopyCount === 0 || (!isTrash && running)) return;
|
||||
setMenuSession(null);
|
||||
setMenuPoint(null);
|
||||
setBlankMenuPoint(null);
|
||||
setMenuConfirmTarget({ kind: "clearRecovery" });
|
||||
};
|
||||
const closeHistoryMenus = () => {
|
||||
setMenuSession(null);
|
||||
setMenuPoint(null);
|
||||
@@ -226,6 +253,12 @@ export function HistoryPanel({
|
||||
closeHistoryMenus();
|
||||
onPurgeAll?.(paths);
|
||||
};
|
||||
const clearRecoveryCopies = () => {
|
||||
const paths = recoveryCopyPaths;
|
||||
closeHistoryMenus();
|
||||
if (isTrash) onPurgeAll?.(paths);
|
||||
else onDeleteMany?.(paths);
|
||||
};
|
||||
const sessionMenuItems: ContextMenuItem[] = menuSession
|
||||
? isTrash
|
||||
? [
|
||||
@@ -292,30 +325,52 @@ export function HistoryPanel({
|
||||
]
|
||||
: [];
|
||||
const trashBlankMenuItems: ContextMenuItem[] =
|
||||
menuConfirmTarget?.kind === "clear"
|
||||
menuConfirmTarget?.kind === "clearRecovery"
|
||||
? [
|
||||
{
|
||||
key: "clear-trash-confirm",
|
||||
key: "clear-recovery-confirm",
|
||||
icon: <Trash2 size={13} />,
|
||||
label: tr("history.confirmClearTrash"),
|
||||
label: tr("history.confirmClearRecoveryCopies"),
|
||||
danger: true,
|
||||
onSelect: clearTrash,
|
||||
onSelect: clearRecoveryCopies,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: "clear-trash",
|
||||
icon: <Trash2 size={13} />,
|
||||
label: tr("history.clearTrashMenu"),
|
||||
danger: true,
|
||||
onSelect: () => setMenuConfirmTarget({ kind: "clear" }),
|
||||
},
|
||||
];
|
||||
: menuConfirmTarget?.kind === "clear"
|
||||
? [
|
||||
{
|
||||
key: "clear-trash-confirm",
|
||||
icon: <Trash2 size={13} />,
|
||||
label: tr("history.confirmClearTrash"),
|
||||
danger: true,
|
||||
onSelect: clearTrash,
|
||||
},
|
||||
]
|
||||
: [
|
||||
...(recoveryCopyCount > 0
|
||||
? [
|
||||
{
|
||||
key: "clear-recovery",
|
||||
icon: <Trash2 size={13} />,
|
||||
label: tr("history.clearRecoveryCopiesMenu"),
|
||||
danger: true,
|
||||
onSelect: () => setMenuConfirmTarget({ kind: "clearRecovery" }),
|
||||
} as ContextMenuItem,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "clear-trash",
|
||||
icon: <Trash2 size={13} />,
|
||||
label: tr("history.clearTrashMenu"),
|
||||
danger: true,
|
||||
onSelect: () => setMenuConfirmTarget({ kind: "clear" }),
|
||||
},
|
||||
];
|
||||
const actionConfirmDelete =
|
||||
selectedSession && menuConfirmTarget?.kind === "delete" && menuConfirmTarget.path === selectedSession.path;
|
||||
const actionConfirmPurge =
|
||||
selectedSession && menuConfirmTarget?.kind === "purge" && menuConfirmTarget.path === selectedSession.path;
|
||||
const actionConfirmClearTrash = isTrash && menuConfirmTarget?.kind === "clear";
|
||||
const actionConfirmClearRecovery = menuConfirmTarget?.kind === "clearRecovery";
|
||||
|
||||
const openSelected = () => {
|
||||
if (!selectedSession || running || isTrash) return;
|
||||
@@ -356,6 +411,18 @@ export function HistoryPanel({
|
||||
{!isTrash && running && <div className="management-modal__summary history-modal__summary">{tr("history.readOnlyHint")}</div>}
|
||||
</div>
|
||||
<div className="management-modal__actions history-modal__actions">
|
||||
{recoveryCopyCount > 0 && (
|
||||
<button
|
||||
className={`chip history-clear${actionConfirmClearRecovery ? " history-clear--confirm" : ""}`}
|
||||
type="button"
|
||||
disabled={!isTrash && running}
|
||||
onClick={actionConfirmClearRecovery ? clearRecoveryCopies : armClearRecoveryCopies}
|
||||
>
|
||||
{isTrash
|
||||
? tr(actionConfirmClearRecovery ? "history.confirmClearRecoveryCopies" : "history.clearRecoveryCopies")
|
||||
: tr(actionConfirmClearRecovery ? "history.confirmTrashRecoveryCopies" : "history.trashRecoveryCopies")}
|
||||
</button>
|
||||
)}
|
||||
{isTrash && sessions.length > 0 && (
|
||||
<button
|
||||
className={`chip history-clear${actionConfirmClearTrash ? " history-clear--confirm" : ""}`}
|
||||
@@ -426,9 +493,9 @@ export function HistoryPanel({
|
||||
<div className="mem-empty">{tr("history.noResults")}</div>
|
||||
) : (
|
||||
groups.map((g) => (
|
||||
<section className="mem-section" key={g.label}>
|
||||
<section className="mem-section" key={`${g.recovered ? "recovered" : "normal"}-${g.label}`}>
|
||||
<div className="mem-section__title hist-group__title">
|
||||
<span>{g.label}</span>
|
||||
<span>{g.recovered ? `${tr("history.recoveryCopiesGroup")} · ${g.label}` : g.label}</span>
|
||||
<span className="hist-group__count">{g.items.length}</span>
|
||||
</div>
|
||||
{g.items.map((s) => {
|
||||
@@ -470,6 +537,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>
|
||||
|
||||
@@ -557,6 +557,7 @@ export function TurnActions({
|
||||
actionPending = false,
|
||||
rewindDisabled = false,
|
||||
hoverMenus = false,
|
||||
isLastTurn = false,
|
||||
}: {
|
||||
text: string;
|
||||
turn?: number;
|
||||
@@ -567,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);
|
||||
@@ -577,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");
|
||||
|
||||
@@ -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",
|
||||
@@ -1050,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 };
|
||||
@@ -1126,11 +1132,12 @@ 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";
|
||||
@@ -1140,15 +1147,8 @@ export function ProjectTree({
|
||||
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 recovered = Boolean(node.recovered);
|
||||
const recoveryTitle = recovered
|
||||
? [
|
||||
t("recovery.branch"),
|
||||
node.recoveryReason ? t("recovery.reason", { reason: node.recoveryReason }) : "",
|
||||
node.recoveryDigest ? t("recovery.digest", { digest: node.recoveryDigest.slice(0, 12) }) : "",
|
||||
].filter(Boolean).join(" · ")
|
||||
: "";
|
||||
const title = [label, recoveryTitle, 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");
|
||||
@@ -1225,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}` : ""}${unread ? " project-tree__topic--unread" : ""}${!isSessionNode && pinned ? " project-tree__topic--pinned" : ""}${recovered ? " project-tree__topic--recovered" : ""}${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}
|
||||
>
|
||||
@@ -1267,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}`}
|
||||
@@ -1279,11 +1279,6 @@ export function ProjectTree({
|
||||
</span>
|
||||
)}
|
||||
{!compactTopics && statusLabel && <span className={`project-tree__topic-status project-tree__topic-status--${status}`}>{statusLabel}</span>}
|
||||
{recovered && (
|
||||
<span className="project-tree__topic-recovery" title={recoveryTitle}>
|
||||
{t("recovery.badge")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!compactTopics && !creationTopics && meta && (
|
||||
<span className="project-tree__topic-meta">
|
||||
@@ -1307,11 +1302,6 @@ export function ProjectTree({
|
||||
{meta}
|
||||
</span>
|
||||
)}
|
||||
{compactTopics && recovered && (
|
||||
<span className="sr-only">
|
||||
{t("recovery.branch")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{unread && <span className="project-tree__topic-unread-dot" aria-hidden="true" />}
|
||||
{projectTreeShouldRenderTopicActions(isSessionNode, compactTopics, unread) && (
|
||||
|
||||
@@ -10,6 +10,7 @@ export function PromptShelf({
|
||||
actions,
|
||||
children,
|
||||
crumbs,
|
||||
note,
|
||||
quickActions,
|
||||
headerActions,
|
||||
barRef,
|
||||
@@ -24,6 +25,9 @@ export function PromptShelf({
|
||||
actions?: ReactNode;
|
||||
children?: ReactNode;
|
||||
crumbs?: ReactNode;
|
||||
// Rendered between the actions grid and the quick actions; used for
|
||||
// focus-following detail previews and similar footnotes.
|
||||
note?: ReactNode;
|
||||
quickActions?: ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
barRef?: RefObject<HTMLDivElement | null>;
|
||||
@@ -52,6 +56,7 @@ export function PromptShelf({
|
||||
{crumbs}
|
||||
{children && <div className="prompt-shelf__body">{children}</div>}
|
||||
{actions && <div className="prompt-shelf__actions">{actions}</div>}
|
||||
{note && <div className="prompt-shelf__footnote">{note}</div>}
|
||||
{quickActions && <div className="prompt-shelf__quick-actions">{quickActions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,6 +97,8 @@ export function PromptAction({
|
||||
description,
|
||||
onClick,
|
||||
ariaLabel,
|
||||
title,
|
||||
onHoverChange,
|
||||
primary = false,
|
||||
selected = false,
|
||||
quiet = false,
|
||||
@@ -102,6 +109,11 @@ export function PromptAction({
|
||||
description?: ReactNode;
|
||||
onClick: () => void;
|
||||
ariaLabel?: string;
|
||||
// Native tooltip fallback for truncated descriptions.
|
||||
title?: string;
|
||||
// Fires on mouse enter/focus (true) and mouse leave/blur (false) so the
|
||||
// parent can drive a focus-following detail preview.
|
||||
onHoverChange?: (hovering: boolean) => void;
|
||||
primary?: boolean;
|
||||
selected?: boolean;
|
||||
quiet?: boolean;
|
||||
@@ -121,6 +133,11 @@ export function PromptAction({
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
title={title}
|
||||
onMouseEnter={onHoverChange ? () => onHoverChange(true) : undefined}
|
||||
onMouseLeave={onHoverChange ? () => onHoverChange(false) : undefined}
|
||||
onFocus={onHoverChange ? () => onHoverChange(true) : undefined}
|
||||
onBlur={onHoverChange ? () => onHoverChange(false) : undefined}
|
||||
>
|
||||
{keyLabel && <span className="prompt-action__key">{keyLabel}</span>}
|
||||
{hasCopy && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lazy, memo, Suspense, useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent, type ReactNode } from "react";
|
||||
import { Bot as BotIcon, Check, CheckCircle2, ChevronDown, ChevronUp, Clipboard, GripVertical, KeyRound, Loader2, MessageCircle, Play, QrCode, RefreshCw, Send } from "lucide-react";
|
||||
import { Bot as BotIcon, Check, CheckCircle2, ChevronDown, ChevronUp, Clipboard, GripVertical, KeyRound, Loader2, MessageCircle, Minus, Play, Plus, QrCode, RefreshCw, RotateCcw, Send } from "lucide-react";
|
||||
import { asArray } from "../lib/array";
|
||||
import { useDeferredClose } from "../lib/useMountTransition";
|
||||
import { app } from "../lib/bridge";
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type ThemeStyle,
|
||||
} from "../lib/theme";
|
||||
import { TEXT_SIZES, applyTextSize, getTextSize, type TextSize } from "../lib/textSize";
|
||||
import { snapZoom, zoomToPercent, saveRestartZoom, getRestartZoom, type ZoomLevel } from "../lib/dpiScale";
|
||||
import { DEFAULT_ZOOM, MAX_ZOOM, MIN_ZOOM, ZOOM_STEP, snapZoom, zoomToPercent, saveRestartZoom, getRestartZoom, type ZoomLevel } from "../lib/dpiScale";
|
||||
import {
|
||||
applyFontFamily,
|
||||
applyMonoFontFamily,
|
||||
@@ -102,6 +102,7 @@ export function SettingsPanel({
|
||||
const [tab, setTab] = useState<SettingsTab>(initialTab === "providers" ? "models" : initialTab ?? "general");
|
||||
// Play the modal exit animation, then let the parent unmount us.
|
||||
const { status, requestClose } = useDeferredClose(onClose, 240);
|
||||
const zoomSaveSeq = useRef(0);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoadingSettings(true);
|
||||
@@ -129,6 +130,24 @@ export function SettingsPanel({
|
||||
setThemeState(nextTheme);
|
||||
setThemeStyleState(nextStyle);
|
||||
}, [s?.desktopTheme, s?.desktopThemeStyle]);
|
||||
useEffect(() => {
|
||||
if (desktopPlatform !== "windows") return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const persisted = await app.GetDesktopZoomFactor();
|
||||
if (cancelled || typeof persisted !== "number" || !Number.isFinite(persisted)) return;
|
||||
const snapped = snapZoom(persisted);
|
||||
saveRestartZoom(snapped);
|
||||
setZoomPct(zoomToPercent(snapped));
|
||||
} catch {
|
||||
// Older mocks or startup races can lack the binding; keep the local fallback.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [desktopPlatform]);
|
||||
|
||||
// apply runs a mutation, re-reads settings, and refreshes the topbar/model.
|
||||
const apply = useCallback(async (fn: () => Promise<unknown>) => {
|
||||
@@ -143,11 +162,11 @@ export function SettingsPanel({
|
||||
setWarning(result.trim());
|
||||
}
|
||||
} catch (e) {
|
||||
setErr(String((e as Error)?.message ?? e));
|
||||
setErr(formatSettingsError(e, t));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [reload, onChanged]);
|
||||
}, [reload, onChanged, t]);
|
||||
const backgroundApply = useCallback(async (fn: () => Promise<void>) => {
|
||||
setErr(null);
|
||||
setWarning(null);
|
||||
@@ -156,9 +175,24 @@ export function SettingsPanel({
|
||||
const next = await reload();
|
||||
onChanged(next);
|
||||
} catch (e) {
|
||||
setErr(String((e as Error)?.message ?? e));
|
||||
setErr(formatSettingsError(e, t));
|
||||
}
|
||||
}, [reload, onChanged]);
|
||||
}, [reload, onChanged, t]);
|
||||
const setRestartZoom = useCallback(async (zoom: ZoomLevel) => {
|
||||
const snapped = snapZoom(zoom);
|
||||
const seq = ++zoomSaveSeq.current;
|
||||
setErr(null);
|
||||
setWarning(null);
|
||||
setZoomPct(zoomToPercent(snapped));
|
||||
try {
|
||||
await app.SetDesktopZoomFactor(snapped);
|
||||
if (seq === zoomSaveSeq.current) saveRestartZoom(snapped);
|
||||
} catch (e) {
|
||||
if (seq !== zoomSaveSeq.current) return;
|
||||
setErr(formatSettingsError(e, t));
|
||||
setZoomPct(zoomToPercent(getRestartZoom()));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// Close on Esc
|
||||
useEffect(() => {
|
||||
@@ -248,18 +282,7 @@ export function SettingsPanel({
|
||||
applyTextSize(size);
|
||||
setTextSizeState(size);
|
||||
}}
|
||||
onRestartZoom={async (zoom) => {
|
||||
const snapped = snapZoom(zoom);
|
||||
setErr(null);
|
||||
setWarning(null);
|
||||
try {
|
||||
await app.SetDesktopZoomFactor(snapped);
|
||||
saveRestartZoom(snapped);
|
||||
setZoomPct(zoomToPercent(snapped));
|
||||
} catch (e) {
|
||||
setErr(String((e as Error)?.message ?? e));
|
||||
}
|
||||
}}
|
||||
onRestartZoom={setRestartZoom}
|
||||
onFontFamily={(font) => {
|
||||
applyFontFamily(font);
|
||||
setFontFamilyState(font);
|
||||
@@ -746,17 +769,38 @@ function sortedJSONValue(value: unknown): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateProviderExtraBodyValue(value: unknown, path = "extra_body"): void {
|
||||
function formatSettingsError(error: unknown, t: ReturnType<typeof useT>): string {
|
||||
const msg = String((error as Error)?.message ?? error ?? "").trim();
|
||||
const unknownModel = /^unknown model (.+)$/i.exec(msg);
|
||||
if (unknownModel) return t("settings.errorUnknownModel", { model: unknownModel[1] });
|
||||
const providerNotAdded = /^model (.+) is not available because provider (.+) is not added$/i.exec(msg);
|
||||
if (providerNotAdded) return t("settings.errorModelProviderMissing", { model: providerNotAdded[1], provider: providerNotAdded[2] });
|
||||
const providerNoKey = /^model (.+) is not available because provider (.+) has no key$/i.exec(msg);
|
||||
if (providerNoKey) return t("settings.errorModelProviderNoKey", { model: providerNoKey[1], provider: providerNoKey[2] });
|
||||
const removeAccessBusy = /^finish or cancel active work using (.+) before removing the provider access$/i.exec(msg);
|
||||
if (removeAccessBusy) return t("settings.errorRemoveAccessBusy", { provider: removeAccessBusy[1] });
|
||||
const deleteProviderBusy = /^finish or cancel active work using (.+) before deleting the provider$/i.exec(msg);
|
||||
if (deleteProviderBusy) return t("settings.errorDeleteProviderBusy", { provider: deleteProviderBusy[1] });
|
||||
const saveBeforeRemoveAccess = /^save current session before removing provider access: (.+)$/is.exec(msg);
|
||||
if (saveBeforeRemoveAccess) return t("settings.errorSaveBeforeRemoveAccess", { err: saveBeforeRemoveAccess[1] });
|
||||
const saveBeforeDeleteProvider = /^save current session before deleting provider: (.+)$/is.exec(msg);
|
||||
if (saveBeforeDeleteProvider) return t("settings.errorSaveBeforeDeleteProvider", { err: saveBeforeDeleteProvider[1] });
|
||||
const removeProviderUsed = /^remove provider: (.+) is used by open tabs and no other configured provider exists$/i.exec(msg);
|
||||
if (removeProviderUsed) return t("settings.errorRemoveProviderNoFallback", { provider: removeProviderUsed[1] });
|
||||
return msg || t("settings.errorUnknown");
|
||||
}
|
||||
|
||||
function validateProviderExtraBodyValue(value: unknown, path = "extra_body", t?: ReturnType<typeof useT>): void {
|
||||
if (value === null) {
|
||||
throw new Error(`${path} cannot contain null`);
|
||||
throw new Error(t ? t("settings.providerExtraBodyNull", { path }) : `${path} cannot contain null`);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => validateProviderExtraBodyValue(item, `${path}[${index}]`));
|
||||
value.forEach((item, index) => validateProviderExtraBodyValue(item, `${path}[${index}]`, t));
|
||||
return;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||
validateProviderExtraBodyValue(child, `${path}.${key}`);
|
||||
validateProviderExtraBodyValue(child, `${path}.${key}`, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -772,14 +816,14 @@ export function formatProviderExtraBody(extraBody: Record<string, unknown> | nul
|
||||
return JSON.stringify(sortedJSONValue(cleaned), null, 2);
|
||||
}
|
||||
|
||||
export function parseProviderExtraBody(raw: string): Record<string, unknown> {
|
||||
export function parseProviderExtraBody(raw: string, t?: ReturnType<typeof useT>): Record<string, unknown> {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("extra body must be a JSON object");
|
||||
throw new Error(t ? t("settings.providerExtraBodyObjectRequired") : "extra body must be a JSON object");
|
||||
}
|
||||
validateProviderExtraBodyValue(parsed);
|
||||
validateProviderExtraBodyValue(parsed, "extra_body", t);
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [rawKey, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
const key = rawKey.trim();
|
||||
@@ -788,6 +832,12 @@ export function parseProviderExtraBody(raw: string): Record<string, unknown> {
|
||||
return out;
|
||||
}
|
||||
|
||||
export function providerExtraBodyParseError(error: unknown, t: ReturnType<typeof useT>): string {
|
||||
if (error instanceof SyntaxError) return t("settings.providerExtraBodyError");
|
||||
const message = String((error as Error)?.message ?? error ?? "").trim();
|
||||
return message || t("settings.providerExtraBodyError");
|
||||
}
|
||||
|
||||
function providerModelFetchFallbackMessage(error: unknown, t: ReturnType<typeof useT>): string {
|
||||
const message = String((error as Error)?.message ?? error);
|
||||
if (/\bstatus\s+(401|403)\b/i.test(message)) {
|
||||
@@ -4479,7 +4529,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
|
||||
setGroupModelDraft(group.id, null);
|
||||
try {
|
||||
await apply(async () => {
|
||||
await app.SetProviderKey(apiKeyEnv, value);
|
||||
await app.SaveProviderKey(apiKeyEnv, value);
|
||||
try {
|
||||
const fetched = await app.FetchProviderModels({ ...probe, apiKeyEnv });
|
||||
if (fetched.length > 0) {
|
||||
@@ -4585,7 +4635,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
|
||||
setAdding(null);
|
||||
}}
|
||||
onResetPreset={(id) => apply(() => app.ResetProviderPresetAccess(id)).then(() => setAdding(null))}
|
||||
onAddCustom={(pv) => apply(() => app.SaveProvider(pv)).then(() => setAdding(null))}
|
||||
onAddCustom={(pv, key) => apply(() => key ? app.SaveProviderWithKey(pv, key) : app.SaveProvider(pv)).then(() => setAdding(null))}
|
||||
/>
|
||||
)}
|
||||
{adding === null && groups.map((group) => (
|
||||
@@ -4601,7 +4651,7 @@ function ProvidersSection({ s, busy, apply }: SectionProps) {
|
||||
kinds={s.providerKinds}
|
||||
onEdit={setEditing}
|
||||
onCancelEdit={() => setEditing(null)}
|
||||
onSave={(pv) => apply(() => app.SaveProvider(pv)).then(() => {
|
||||
onSave={(pv, key) => apply(() => key ? app.SaveProviderWithKey(pv, key) : app.SaveProvider(pv)).then(() => {
|
||||
setEditing(null);
|
||||
setGroupModelDraft(group.id, null);
|
||||
})}
|
||||
@@ -4705,6 +4755,10 @@ function providerTemplateConflictProviderName(choice: ProviderTemplateChoice): s
|
||||
|
||||
function providerPresetDescription(preset: ProviderPresetView, t: ReturnType<typeof useT>): string {
|
||||
switch (preset.id) {
|
||||
case "longcat-openai":
|
||||
return t("settings.addProvider.preset.longcatOpenAIDesc");
|
||||
case "longcat-anthropic":
|
||||
return t("settings.addProvider.preset.longcatAnthropicDesc");
|
||||
case "kimi-cn":
|
||||
return t("settings.addProvider.preset.kimiCnDesc");
|
||||
case "kimi-global":
|
||||
@@ -4811,7 +4865,7 @@ function AddProviderPanel({
|
||||
onAddPreset: (id: string, key: string) => Promise<void>;
|
||||
onViewPresetConflict: (providerName: string) => void;
|
||||
onResetPreset: (id: string) => Promise<void>;
|
||||
onAddCustom: (p: ProviderView) => void | Promise<void>;
|
||||
onAddCustom: (p: ProviderView, key?: string) => void | Promise<void>;
|
||||
}) {
|
||||
const t = useT();
|
||||
const templateChoices = useMemo<ProviderTemplateChoice[]>(() => [
|
||||
@@ -5026,7 +5080,7 @@ function ProviderAccessCard({
|
||||
kinds: string[];
|
||||
onEdit: (name: string) => void;
|
||||
onCancelEdit: () => void;
|
||||
onSave: (p: ProviderView) => void | Promise<void>;
|
||||
onSave: (p: ProviderView, key?: string) => void | Promise<void>;
|
||||
onRefresh: () => void;
|
||||
onToggleDraftModel: (model: string) => void;
|
||||
onToggleDraftVision: (model: string) => void;
|
||||
@@ -5517,7 +5571,7 @@ function ProviderEditor({
|
||||
kinds: string[];
|
||||
busy: boolean;
|
||||
onCancel: () => void;
|
||||
onSave: (p: ProviderView) => void;
|
||||
onSave: (p: ProviderView, key?: string) => void | Promise<void>;
|
||||
onSaveKey?: (apiKeyEnv: string, value: string) => Promise<void>;
|
||||
onClearKey?: (apiKeyEnv: string) => Promise<void>;
|
||||
}) {
|
||||
@@ -5564,9 +5618,9 @@ function ProviderEditor({
|
||||
const effectiveHeaders = parseProviderHeaders(headersDraft);
|
||||
const extraBodyParse = useMemo(() => {
|
||||
try {
|
||||
return { value: parseProviderExtraBody(extraBodyDraft), error: "" };
|
||||
} catch {
|
||||
return { value: {}, error: t("settings.providerExtraBodyError") };
|
||||
return { value: parseProviderExtraBody(extraBodyDraft, t), error: "" };
|
||||
} catch (e) {
|
||||
return { value: {}, error: providerExtraBodyParseError(e, t) };
|
||||
}
|
||||
}, [extraBodyDraft, t]);
|
||||
const effectiveExtraBody = extraBodyParse.value;
|
||||
@@ -5594,7 +5648,7 @@ function ProviderEditor({
|
||||
try {
|
||||
const effectiveApiKeyEnv = providerApiKeyEnvForSave(name, apiKeyEnv, keyDraft);
|
||||
if (!apiKeyEnv.trim()) setApiKeyEnv(effectiveApiKeyEnv);
|
||||
if (keyDraft.trim()) await app.SetProviderKey(effectiveApiKeyEnv, keyDraft.trim());
|
||||
if (keyDraft.trim()) await app.SaveProviderKey(effectiveApiKeyEnv, keyDraft.trim());
|
||||
const fetched = await app.FetchProviderModels({
|
||||
name: name.trim() || t("settings.newProviderDraftName"),
|
||||
builtIn: initial?.builtIn ?? false,
|
||||
@@ -5642,11 +5696,12 @@ function ProviderEditor({
|
||||
|
||||
const save = async () => {
|
||||
if (extraBodyInvalid) return;
|
||||
setFetchStatus(null);
|
||||
setFetchFallback(null);
|
||||
const ms = parseProviderListInput(models);
|
||||
const vms = parseProviderListInput(visionModels).filter((model) => ms.includes(model));
|
||||
const effectiveApiKeyEnv = providerApiKeyEnvForSave(name, apiKeyEnv, keyDraft);
|
||||
if (keyDraft.trim()) await app.SetProviderKey(effectiveApiKeyEnv, keyDraft.trim());
|
||||
onSave({
|
||||
const provider: ProviderView = {
|
||||
name: name.trim(),
|
||||
builtIn: initial?.builtIn ?? false,
|
||||
added: initial?.added ?? true,
|
||||
@@ -5672,7 +5727,12 @@ function ProviderEditor({
|
||||
// NormalizeEffort would otherwise silently ignore an unsupported value.
|
||||
defaultEffort: cleanedSupportedEfforts.length > 0 ? cleanDefaultEffort : "",
|
||||
modelOverrides: initial?.modelOverrides ?? [],
|
||||
});
|
||||
};
|
||||
try {
|
||||
await onSave(provider, keyDraft.trim() || undefined);
|
||||
} catch (e) {
|
||||
setFetchFallback(String((e as Error)?.message ?? e));
|
||||
}
|
||||
};
|
||||
|
||||
if (builtIn) {
|
||||
@@ -6185,7 +6245,7 @@ function HooksSection({ onChanged }: { onChanged: (settings?: SettingsView | nul
|
||||
|
||||
const parseHooksEditorJSON = (raw = jsonText): { hooks: HookConfigView[]; text: string } | null => {
|
||||
try {
|
||||
const hooks = parseHooksJSON(raw, view?.events ?? []);
|
||||
const hooks = parseHooksJSON(raw, view?.events ?? [], t);
|
||||
const text = formatHooksJSON(hooks, view?.events ?? []);
|
||||
setJsonText(text);
|
||||
setJsonError(null);
|
||||
@@ -6376,7 +6436,7 @@ function formatHooksJSON(hooks: HookConfigView[], eventOrder: string[]): string
|
||||
return JSON.stringify({ hooks: ordered }, null, 2);
|
||||
}
|
||||
|
||||
function parseHooksJSON(raw: string, validEvents: string[]): HookConfigView[] {
|
||||
function parseHooksJSON(raw: string, validEvents: string[], t: ReturnType<typeof useT>): HookConfigView[] {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return [];
|
||||
let parsed: unknown;
|
||||
@@ -6386,21 +6446,21 @@ function parseHooksJSON(raw: string, validEvents: string[]): HookConfigView[] {
|
||||
throw new Error(String((e as Error)?.message ?? e));
|
||||
}
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item) => normalizeHookConfig(parseHookArrayItem(item, validEvents))).filter((h) => h.event);
|
||||
return parsed.map((item) => normalizeHookConfig(parseHookArrayItem(item, validEvents, t))).filter((h) => h.event);
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("expected an object or array");
|
||||
throw new Error(t("settings.hooksJsonExpectedObjectArray"));
|
||||
}
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
const hooksValue = obj.hooks && typeof obj.hooks === "object" && !Array.isArray(obj.hooks) ? obj.hooks : obj;
|
||||
return flattenHooksMap(hooksValue as Record<string, unknown>, validEvents);
|
||||
return flattenHooksMap(hooksValue as Record<string, unknown>, validEvents, t);
|
||||
}
|
||||
|
||||
function parseHookArrayItem(item: unknown, validEvents: string[]): HookConfigView {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error("hook item must be an object");
|
||||
function parseHookArrayItem(item: unknown, validEvents: string[], t: ReturnType<typeof useT>): HookConfigView {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(t("settings.hooksJsonItemObject"));
|
||||
const obj = item as Record<string, unknown>;
|
||||
const event = stringField(obj, "event") || "PreToolUse";
|
||||
if (validEvents.length > 0 && !validEvents.includes(event)) throw new Error(`unknown hook event ${event}`);
|
||||
if (validEvents.length > 0 && !validEvents.includes(event)) throw new Error(t("settings.hooksJsonUnknownEvent", { event }));
|
||||
return {
|
||||
event,
|
||||
match: stringField(obj, "match"),
|
||||
@@ -6411,14 +6471,14 @@ function parseHookArrayItem(item: unknown, validEvents: string[]): HookConfigVie
|
||||
};
|
||||
}
|
||||
|
||||
function flattenHooksMap(hooks: Record<string, unknown>, validEvents: string[]): HookConfigView[] {
|
||||
function flattenHooksMap(hooks: Record<string, unknown>, validEvents: string[], t: ReturnType<typeof useT>): HookConfigView[] {
|
||||
const valid = new Set(validEvents);
|
||||
const out: HookConfigView[] = [];
|
||||
for (const [event, value] of Object.entries(hooks)) {
|
||||
if (valid.size > 0 && !valid.has(event)) throw new Error(`unknown hook event ${event}`);
|
||||
if (valid.size > 0 && !valid.has(event)) throw new Error(t("settings.hooksJsonUnknownEvent", { event }));
|
||||
const items = Array.isArray(value) ? value : [value];
|
||||
for (const item of items) {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(`hook ${event} item must be an object`);
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(t("settings.hooksJsonEventItemObject", { event }));
|
||||
const obj = item as Record<string, unknown>;
|
||||
out.push(normalizeHookConfig({
|
||||
event,
|
||||
@@ -6582,6 +6642,15 @@ function AppearanceSection({
|
||||
const themeOptions: Theme[] = ["auto", "light", "dark"];
|
||||
const availableFontFamilies = useMemo(() => getAvailableFontFamilies(fontFamily), [fontFamily]);
|
||||
const availableMonoFontFamilies = useMemo(() => getAvailableMonoFontFamilies(monoFontFamily), [monoFontFamily]);
|
||||
const zoomMinPct = zoomToPercent(MIN_ZOOM);
|
||||
const zoomMaxPct = zoomToPercent(MAX_ZOOM);
|
||||
const zoomStepPct = Math.round(ZOOM_STEP * 100);
|
||||
const zoomProgressPct = Math.min(100, Math.max(0, ((zoomPct - zoomMinPct) / (zoomMaxPct - zoomMinPct)) * 100));
|
||||
const canDecreaseZoom = zoomPct > zoomMinPct;
|
||||
const canIncreaseZoom = zoomPct < zoomMaxPct;
|
||||
const setZoomPercent = (pct: number) => {
|
||||
void onRestartZoom(pct / 100);
|
||||
};
|
||||
return (
|
||||
<SettingsSection title={t("settings.appearance")}>
|
||||
<SettingsField label={t("settings.theme")}>
|
||||
@@ -6647,30 +6716,62 @@ function AppearanceSection({
|
||||
{showDisplayZoom && (
|
||||
<SettingsField label={t("settings.displayZoom")}>
|
||||
<div className="zoom-slider-wrap">
|
||||
<div className="zoom-slider__value">{zoomPct}%</div>
|
||||
<div className="zoom-slider__head">
|
||||
<div className="zoom-slider__value">{zoomPct}%</div>
|
||||
<div className="zoom-stepper">
|
||||
<button
|
||||
type="button"
|
||||
className="zoom-stepper__btn"
|
||||
aria-label={t("settings.displayZoomDecrease")}
|
||||
title={t("settings.displayZoomDecrease")}
|
||||
disabled={!canDecreaseZoom}
|
||||
onClick={() => setZoomPercent(zoomPct - zoomStepPct)}
|
||||
>
|
||||
<Minus size={13} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="zoom-stepper__reset"
|
||||
aria-label={t("settings.displayZoomReset")}
|
||||
title={t("settings.displayZoomReset")}
|
||||
disabled={zoomPct === zoomToPercent(DEFAULT_ZOOM)}
|
||||
onClick={() => { void onRestartZoom(DEFAULT_ZOOM); }}
|
||||
>
|
||||
<RotateCcw size={12} aria-hidden="true" />
|
||||
<span>100%</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="zoom-stepper__btn"
|
||||
aria-label={t("settings.displayZoomIncrease")}
|
||||
title={t("settings.displayZoomIncrease")}
|
||||
disabled={!canIncreaseZoom}
|
||||
onClick={() => setZoomPercent(zoomPct + zoomStepPct)}
|
||||
>
|
||||
<Plus size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="zoom-slider-row">
|
||||
<span className="zoom-slider__label">50%</span>
|
||||
<span className="zoom-slider__label">{zoomMinPct}%</span>
|
||||
<div className="slider-track">
|
||||
<div className="slider-track__bg" />
|
||||
<div
|
||||
className="slider-track__fill"
|
||||
style={{ width: `calc(${((zoomPct - 50) / 150) * 100}% + 15px)` }}
|
||||
style={{ width: `calc(${zoomProgressPct}% + 15px)` }}
|
||||
/>
|
||||
<div className="slider-thumb" style={{ left: `${((zoomPct - 50) / 150) * 100}%` }}>
|
||||
<div className="slider-thumb__left" />
|
||||
<div className="slider-thumb__mid" />
|
||||
<div className="slider-thumb__right" />
|
||||
</div>
|
||||
<div className="slider-thumb" style={{ left: `${zoomProgressPct}%` }} />
|
||||
<input
|
||||
aria-label={t("settings.displayZoom")}
|
||||
type="range"
|
||||
min={50}
|
||||
max={200}
|
||||
step={5}
|
||||
min={zoomMinPct}
|
||||
max={zoomMaxPct}
|
||||
step={zoomStepPct}
|
||||
value={zoomPct}
|
||||
onChange={(e) => { void onRestartZoom(Number(e.target.value) / 100); }}
|
||||
onChange={(e) => setZoomPercent(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<span className="zoom-slider__label">200%</span>
|
||||
<span className="zoom-slider__label">{zoomMaxPct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsField>
|
||||
|
||||
@@ -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,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";
|
||||
@@ -16,6 +16,8 @@ const SUBAGENT_TOOLS = new Set(["task", "run_skill", "explore", "research", "rev
|
||||
|
||||
/** Lines shown by default in a shell output block before the "show all" button. */
|
||||
const SHELL_PREVIEW_LINES = 10;
|
||||
const ERROR_SUMMARY_MAX_CHARS = 140;
|
||||
const ERROR_DETAILS_THRESHOLD = 220;
|
||||
|
||||
function pretty(json: string): string {
|
||||
try {
|
||||
@@ -30,6 +32,41 @@ function formatToolDuration(ms?: number): string {
|
||||
return `${Math.round(ms)} ms`;
|
||||
}
|
||||
|
||||
function normalizeErrorText(text: string): string {
|
||||
return text.replace(/\r\n/g, "\n").trim();
|
||||
}
|
||||
|
||||
function withoutErrorPrefix(text: string): string {
|
||||
return normalizeErrorText(text).replace(/^error:\s*/i, "");
|
||||
}
|
||||
|
||||
function toolOutputDuplicatesError(output: string | undefined, error: string | undefined): boolean {
|
||||
if (!output || !error) return false;
|
||||
const normalizedOutput = normalizeErrorText(output);
|
||||
const normalizedError = normalizeErrorText(error);
|
||||
if (!normalizedOutput || !normalizedError) return false;
|
||||
return normalizedOutput === normalizedError || withoutErrorPrefix(normalizedOutput) === withoutErrorPrefix(normalizedError);
|
||||
}
|
||||
|
||||
function summarizeToolError(error: string, receiptMismatchText: string): string {
|
||||
const text = withoutErrorPrefix(error);
|
||||
if (!text) return "";
|
||||
if (/has no matching successful receipt/i.test(text)) {
|
||||
return receiptMismatchText;
|
||||
}
|
||||
const firstLine = text.split("\n")[0]?.trim() ?? "";
|
||||
if (firstLine.length <= ERROR_SUMMARY_MAX_CHARS) return firstLine;
|
||||
return `${firstLine.slice(0, ERROR_SUMMARY_MAX_CHARS - 1)}…`;
|
||||
}
|
||||
|
||||
function errorNeedsDetails(error: string, summary: string): boolean {
|
||||
const normalizedError = withoutErrorPrefix(error);
|
||||
if (!normalizedError) return false;
|
||||
return normalizedError.includes("\n") ||
|
||||
normalizedError.length > ERROR_DETAILS_THRESHOLD ||
|
||||
(summary !== "" && normalizedError !== summary);
|
||||
}
|
||||
|
||||
/** Returns the first n lines of text and the total line count. */
|
||||
function splitPreview(text: string, n: number): { preview: string; total: number; hasMore: boolean } {
|
||||
const lines = text.split("\n");
|
||||
@@ -60,12 +97,14 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [showErrorDetails, setShowErrorDetails] = useState(false);
|
||||
// Lazy-load full tool data from the backend when the card is expanded and
|
||||
// the in-memory copy was archived for memory efficiency.
|
||||
const [fullData, setFullData] = useState<{ args: string; output?: string } | null>(null);
|
||||
const archivedWithoutFullData = Boolean(item.dataArchived && !fullData);
|
||||
const effectiveArgs = archivedWithoutFullData ? "" : fullData?.args ?? item.args;
|
||||
const effectiveOutput = fullData?.output ?? item.output;
|
||||
const displayOutput = toolOutputDuplicatesError(effectiveOutput, item.error) ? undefined : effectiveOutput;
|
||||
const previewDiff = item.fileDiff?.diff ? item.fileDiff : undefined;
|
||||
const diffs = previewDiff || archivedWithoutFullData ? [] : diffsFor(item.name, effectiveArgs);
|
||||
const subject = fullData ? subjectOf(item.name, effectiveArgs) : item.subject || subjectOf(item.name, effectiveArgs);
|
||||
@@ -78,12 +117,15 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
// else folds its args/output away by default. Open while running so the
|
||||
// user sees progress; closed by default once settled.
|
||||
const hasArchivedOnDemandBody = Boolean(item.dataArchived && tabId);
|
||||
const hasArgsOrOutput = !previewDiff && diffs.length === 0 && (!!effectiveArgs || !!effectiveOutput || hasArchivedOnDemandBody);
|
||||
const hasArgsOrOutput = !previewDiff && diffs.length === 0 && (!!effectiveArgs || !!displayOutput || hasArchivedOnDemandBody);
|
||||
|
||||
// Shell output: split into preview + "show all" toggle.
|
||||
const shellOutput = item.isShell && effectiveOutput ? effectiveOutput : null;
|
||||
const shellOutput = item.isShell && displayOutput ? displayOutput : null;
|
||||
const shellPreview = shellOutput ? splitPreview(shellOutput, SHELL_PREVIEW_LINES) : null;
|
||||
const hasBody = Boolean(previewDiff || diffs.length || hasNested || shellPreview || (!shellPreview && hasArgsOrOutput) || item.error);
|
||||
const errorText = item.error ? normalizeErrorText(item.error) : "";
|
||||
const errorSummary = errorText ? summarizeToolError(errorText, t("tool.errorReceiptMismatch")) : "";
|
||||
const hasErrorDetails = errorText ? errorNeedsDetails(errorText, errorSummary) : false;
|
||||
useEffect(() => {
|
||||
if (!open || !item.dataArchived || fullData || !tabId) return;
|
||||
let cancelled = false;
|
||||
@@ -91,7 +133,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]);
|
||||
|
||||
@@ -111,7 +153,7 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
item.readOnly && !hasNested && item.status !== "error" && item.status !== "stopped";
|
||||
|
||||
const duration = item.status === "running" ? "" : formatToolDuration(item.durationMs);
|
||||
const summary = item.status === "running" ? "" : item.summary || summarizeFileDiff(item.fileDiff) || (archivedWithoutFullData ? "" : summarize(item.name, effectiveArgs, effectiveOutput, item.error));
|
||||
const summary = item.status === "running" ? "" : item.summary || summarizeFileDiff(item.fileDiff) || (item.error ? errorSummary : archivedWithoutFullData ? "" : summarize(item.name, effectiveArgs, displayOutput, item.error));
|
||||
|
||||
// GSAP-driven collapse/expand for tool body
|
||||
const toolBodyRef = useRef<HTMLDivElement>(null);
|
||||
@@ -127,7 +169,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>}
|
||||
@@ -202,16 +249,36 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
|
||||
{!shellPreview && hasArgsOrOutput && (
|
||||
<>
|
||||
{effectiveArgs && <CodeViewer value={pretty(effectiveArgs)} language="json" maxHeight={180} />}
|
||||
{effectiveOutput && (
|
||||
{displayOutput && (
|
||||
<>
|
||||
<CodeViewer value={effectiveOutput} maxHeight={280} />
|
||||
<CodeViewer value={displayOutput} maxHeight={280} />
|
||||
{item.truncated && <div className="tool__note">{t("tool.truncated")}</div>}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{item.error && <div className="tool__err">{item.error}</div>}
|
||||
{errorText && (
|
||||
<div className={`tool__err${hasErrorDetails ? " tool__err--compact" : ""}`}>
|
||||
{hasErrorDetails ? (
|
||||
<>
|
||||
<div className="tool__err-summary">{errorSummary || t("tool.error")}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="tool__err-toggle"
|
||||
onClick={() => setShowErrorDetails((value) => !value)}
|
||||
aria-expanded={showErrorDetails}
|
||||
>
|
||||
<ChevronRight className={`tool__err-toggle-icon${showErrorDetails ? " tool__err-toggle-icon--open" : ""}`} size={12} aria-hidden="true" />
|
||||
<span>{showErrorDetails ? t("tool.hideErrorDetails") : t("tool.showErrorDetails")}</span>
|
||||
</button>
|
||||
{showErrorDetails && <div className="tool__err-details">{errorText}</div>}
|
||||
</>
|
||||
) : (
|
||||
errorText
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -584,7 +587,7 @@ export function Transcript({
|
||||
out.push(<ToolCard key={it.id} item={it} subcalls={subcallsByParent.get(it.id)} tabId={tabId} />);
|
||||
break;
|
||||
case "phase": out.push(<PhaseCard key={it.id} text={it.text} />); break;
|
||||
case "notice": out.push(<NoticeCard key={it.id} level={it.level} text={it.text} />); break;
|
||||
case "notice": out.push(<NoticeCard key={it.id} level={it.level} text={it.text} detail={it.detail} />); break;
|
||||
case "compaction": out.push(<CompactionCard key={it.id} item={it} />); 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;
|
||||
@@ -939,7 +949,7 @@ function WarmTurnItems({
|
||||
break;
|
||||
}
|
||||
case "phase": nodes.push(<PhaseCard key={it.id} text={it.text} />); break;
|
||||
case "notice": nodes.push(<NoticeCard key={it.id} level={it.level} text={it.text} />); break;
|
||||
case "notice": nodes.push(<NoticeCard key={it.id} level={it.level} text={it.text} detail={it.detail} />); break;
|
||||
case "compaction": nodes.push(<CompactionCard key={it.id} item={it} />); break;
|
||||
}
|
||||
}
|
||||
@@ -1258,11 +1268,20 @@ function PhaseCard({ text }: { text: string }) {
|
||||
return <div className="phase" data-entrance="true"><ProcessPhaseIcon size={12} /><span>{text}</span></div>;
|
||||
}
|
||||
|
||||
function NoticeCard({ level, text }: { level: NoticeItem["level"]; text: string }) {
|
||||
function NoticeCard({ level, text, detail }: { level: NoticeItem["level"]; text: string; detail?: string }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className={`notice-line notice-line--${level}`} data-entrance="true">
|
||||
<span className="notice-line__icon">{level === "warn" ? "⚠ " : "ℹ "}</span>
|
||||
<span className="notice-line__text">{text}</span>
|
||||
<div className="notice-line__text">
|
||||
{text}
|
||||
{detail ? (
|
||||
<details className="notice-line__details">
|
||||
<summary>{t("notice.details")}</summary>
|
||||
<div>{detail}</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
|
||||
notifyChannels: false,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, [open, startNew]);
|
||||
|
||||
@@ -210,18 +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,
|
||||
notifyChannels: 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) => {
|
||||
@@ -238,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],
|
||||
);
|
||||
|
||||
@@ -294,12 +294,14 @@ export interface AppBindings {
|
||||
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>;
|
||||
@@ -572,6 +574,16 @@ export function onFilesDropped(cb: (paths: string[]) => void): () => void {
|
||||
|
||||
// onReady subscribes to the agent:ready event fired when boot.Build completes.
|
||||
// The frontend re-fetches Meta/Context/History when this lands.
|
||||
// onRuntimeRebuilt fires when a tab's controller is replaced in place
|
||||
// (model/effort/token-mode switch, clear-while-running). The rebuilt
|
||||
// controller restarts prompt ids, so per-tab id-keyed state must reset.
|
||||
export function onRuntimeRebuilt(cb: (tabId?: string) => void): () => void {
|
||||
if (realApp() && typeof window !== "undefined" && window.runtime) {
|
||||
return window.runtime.EventsOn("runtime:rebuilt", (tabId?: unknown) => cb(typeof tabId === "string" ? tabId : undefined));
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
export function onReady(cb: (tabId?: string) => void): () => void {
|
||||
if (realApp() && typeof window !== "undefined" && window.runtime) {
|
||||
return window.runtime.EventsOn("agent:ready", (tabId?: unknown) => cb(typeof tabId === "string" ? tabId : undefined));
|
||||
@@ -612,7 +624,7 @@ function bridgeBreadcrumb(method: string): string {
|
||||
return `model ${method}`;
|
||||
if (/^(SetDesktop|SetCloseBehavior|SetDisplayMode|SetStatusBar|SetExpandThinking|SetAutoPlan|SetDefaultToolApprovalMode|SetMemoryCompilerEnabled|SetReasoningLanguage)/.test(method))
|
||||
return `settings ${method}`;
|
||||
if (/^(SaveProvider|AddOfficialProviderAccess|AddProviderPresetAccess|ResetProviderPresetAccess|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))
|
||||
@@ -625,6 +637,11 @@ function bridgeBreadcrumb(method: string): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function elapsedMs(startedAt: number): number {
|
||||
const now = typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
return Math.max(0, Math.round(now - startedAt));
|
||||
}
|
||||
|
||||
export const app: AppBindings = new Proxy({} as AppBindings, {
|
||||
get(_t, prop) {
|
||||
const target = realApp() ?? getMock();
|
||||
@@ -633,18 +650,26 @@ export const app: AppBindings = new Proxy({} as AppBindings, {
|
||||
return (...args: unknown[]) => {
|
||||
const method = String(prop);
|
||||
const crumb = bridgeBreadcrumb(method);
|
||||
const startedAt = crumb ? (typeof performance !== "undefined" ? performance.now() : Date.now()) : 0;
|
||||
if (crumb) addBreadcrumb("bridge", crumb);
|
||||
try {
|
||||
const result = (v as (...a: unknown[]) => unknown).apply(target, args);
|
||||
if (result && typeof (result as Promise<unknown>).then === "function") {
|
||||
return (result as Promise<unknown>).catch((err) => {
|
||||
if (crumb) addBreadcrumb("bridge.error", method);
|
||||
throw err;
|
||||
});
|
||||
return (result as Promise<unknown>).then(
|
||||
(value) => {
|
||||
if (crumb) addBreadcrumb("bridge", `${crumb} done ms=${elapsedMs(startedAt)}`);
|
||||
return value;
|
||||
},
|
||||
(err) => {
|
||||
if (crumb) addBreadcrumb("bridge.error", `${method} ms=${elapsedMs(startedAt)}`);
|
||||
throw err;
|
||||
},
|
||||
);
|
||||
}
|
||||
if (crumb) addBreadcrumb("bridge", `${crumb} done ms=${elapsedMs(startedAt)}`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (crumb) addBreadcrumb("bridge.error", method);
|
||||
if (crumb) addBreadcrumb("bridge.error", `${method} ms=${elapsedMs(startedAt)}`);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -717,12 +742,14 @@ function browserPlatformOverride(): "darwin" | "windows" | "linux" | "" {
|
||||
return value === "darwin" || value === "windows" || value === "linux" ? value : "";
|
||||
}
|
||||
|
||||
function mockScenario(): "demo" | "fresh" | "running" | "guidance" {
|
||||
function mockScenario(): "demo" | "fresh" | "running" | "guidance" | "sandbox_escape" | "notice" {
|
||||
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";
|
||||
if (value === "notice" || value === "notices" || value === "notice-preview") return "notice";
|
||||
return "demo";
|
||||
}
|
||||
|
||||
@@ -766,6 +793,7 @@ function mockPreset(id: string, label: string, description: string, keyEnv: stri
|
||||
}
|
||||
|
||||
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"];
|
||||
@@ -784,6 +812,8 @@ const mockVercelModels = ["anthropic/claude-sonnet-4.6", "anthropic/claude-opus-
|
||||
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 })),
|
||||
@@ -814,8 +844,8 @@ const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
|
||||
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.ai/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.ai/step_plan", models: mockStepFunModels, default: "step-3.7-flash", apiKeyEnv: "STEPFUN_API_KEY", thinking: "adaptive", supportedEfforts: ["low", "medium", "high"], defaultEffort: "medium" })),
|
||||
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 })),
|
||||
@@ -826,7 +856,7 @@ const mockProviderPresetTemplates: MockProviderPresetTemplate[] = [
|
||||
];
|
||||
|
||||
function mockProviderPresetViews(): ProviderPresetView[] {
|
||||
return mockProviderPresetTemplates.map((template) => ({
|
||||
return [...mockProviderPresetTemplates].sort((a, b) => mockProviderPresetDisplayRank(a.id) - mockProviderPresetDisplayRank(b.id)).map((template) => ({
|
||||
id: template.id,
|
||||
label: template.label,
|
||||
description: template.description,
|
||||
@@ -842,6 +872,14 @@ function mockProviderPresetViews(): ProviderPresetView[] {
|
||||
}));
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -859,6 +897,8 @@ function makeMockApp(): AppBindings {
|
||||
const freshMock = scenario === "fresh";
|
||||
const guidanceMock = scenario === "guidance";
|
||||
const runningMock = scenario === "running" || guidanceMock;
|
||||
const sandboxEscapeMock = scenario === "sandbox_escape";
|
||||
const noticePreviewMock = scenario === "notice";
|
||||
const mockAttachmentDataURLs = new Map<string, string>();
|
||||
let cancelled = false;
|
||||
let pendingAskPreview = false;
|
||||
@@ -867,6 +907,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.
|
||||
@@ -966,7 +1007,7 @@ function makeMockApp(): AppBindings {
|
||||
// Mutable so delete/rename are observable in browser dev.
|
||||
const sessions: SessionMeta[] = [
|
||||
{ path: "/mock/sessions/a.jsonl", preview: "fix the login bug in auth.go", turns: 12, createdAt: t0 - 2 * day, lastActivityAt: t0 - 3_600_000, modTime: t0 - 3_600_000, current: true, open: true },
|
||||
{ path: "/mock/sessions/b.jsonl", preview: "refactor the payment module", turns: 5, createdAt: t0 - 3 * day, lastActivityAt: t0 - 6 * 3_600_000, modTime: t0 - 6 * 3_600_000, current: false, open: true },
|
||||
{ path: "/mock/sessions/b-recovery-0123456789abcdef.jsonl", preview: "refactor the payment module", turns: 5, createdAt: t0 - 3 * day, lastActivityAt: t0 - 6 * 3_600_000, modTime: t0 - 6 * 3_600_000, current: false, open: true, recovered: true },
|
||||
{ path: "/mock/sessions/c.jsonl", preview: "write the README and badges", turns: 8, createdAt: t0 - 4 * day, lastActivityAt: t0 - day - 3_600_000, modTime: t0 - day - 3_600_000, current: false, open: false },
|
||||
{ path: "/mock/sessions/d.jsonl", preview: "explain the plugin host design", turns: 3, createdAt: t0 - 5 * day, lastActivityAt: t0 - 4 * day, modTime: t0 - 4 * day, current: false, open: false },
|
||||
];
|
||||
@@ -1017,6 +1058,7 @@ function makeMockApp(): AppBindings {
|
||||
scope: "global",
|
||||
topicId: "topic_product",
|
||||
topicTitle: t("mock.trashGlobalProductTitle"),
|
||||
recovered: true,
|
||||
},
|
||||
];
|
||||
if (freshMock) {
|
||||
@@ -1541,7 +1583,28 @@ function makeMockApp(): AppBindings {
|
||||
setMockTabRunning(currentMockTurnTabId(), false);
|
||||
emit({ kind: "turn_done" });
|
||||
};
|
||||
let mockTabs: TabMeta[] = freshMock ? [
|
||||
let mockTabs: TabMeta[] = noticePreviewMock ? [
|
||||
{
|
||||
id: "tab_notice_preview",
|
||||
scope: "project",
|
||||
workspaceRoot: "~/projects/reasonix",
|
||||
workspaceName: "reasonix",
|
||||
workspacePath: "~/projects/reasonix",
|
||||
gitBranch: "codex/compact-chat-notices-i18n",
|
||||
topicId: "topic_notice_preview",
|
||||
topicTitle: "Compact notice preview",
|
||||
projectColor: "green",
|
||||
label: "DeepSeek-R1",
|
||||
ready: true,
|
||||
running: false,
|
||||
mode: "normal",
|
||||
collaborationMode: "normal",
|
||||
toolApprovalMode: "ask",
|
||||
tokenMode: "full",
|
||||
active: true,
|
||||
cwd: "~/projects/reasonix",
|
||||
},
|
||||
] : freshMock ? [
|
||||
{
|
||||
id: "tab_global",
|
||||
scope: "global",
|
||||
@@ -1620,6 +1683,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" },
|
||||
@@ -1716,6 +1796,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" ||
|
||||
@@ -1830,6 +1930,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.
|
||||
@@ -2958,6 +3119,14 @@ 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: "", thinking: "", supportedEfforts: [], defaultEffort: "" },
|
||||
@@ -3020,6 +3189,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;
|
||||
@@ -3172,11 +3347,11 @@ function makeMockApp(): AppBindings {
|
||||
async SetDesktopLayoutStyle(style: string) {
|
||||
settings.desktopLayoutStyle = style === "workbench" || style === "creation" ? style : "classic";
|
||||
},
|
||||
async SetDesktopZoomFactor(_factor: number) {
|
||||
// no-op in mock; in production this writes desktop-zoom.json via Go
|
||||
async SetDesktopZoomFactor(factor: number) {
|
||||
mockDesktopZoomFactor = Math.min(2.0, Math.max(0.5, Number.isFinite(factor) ? factor : 1.0));
|
||||
},
|
||||
async GetDesktopZoomFactor() {
|
||||
return 1.0; // default in mock
|
||||
return mockDesktopZoomFactor;
|
||||
},
|
||||
async RestartApplication() {
|
||||
// no-op in mock
|
||||
|
||||
@@ -635,6 +635,7 @@ export function installPerformancePressureMonitor() {
|
||||
const isFocused = () => typeof document === "undefined" || document.hasFocus?.() !== false;
|
||||
let visibleSince = isHidden() ? Number.POSITIVE_INFINITY : startedAt;
|
||||
let expected = performance.now() + 1000;
|
||||
let eventLoopLagPrimed = false;
|
||||
|
||||
const pastGrace = () => performance.now() >= graceUntil;
|
||||
const inspectLongTasks = () => {
|
||||
@@ -651,6 +652,7 @@ export function installPerformancePressureMonitor() {
|
||||
longTasks.length = 0;
|
||||
lagSamples.length = 0;
|
||||
expected = performance.now() + 1000;
|
||||
eventLoopLagPrimed = false;
|
||||
if (!isHidden()) visibleSince = performance.now();
|
||||
});
|
||||
}
|
||||
@@ -673,9 +675,17 @@ export function installPerformancePressureMonitor() {
|
||||
|
||||
window.setInterval(() => {
|
||||
const now = performance.now();
|
||||
if (!pastGrace()) {
|
||||
expected = now + 1000;
|
||||
return;
|
||||
}
|
||||
if (!eventLoopLagPrimed) {
|
||||
expected = now + 1000;
|
||||
eventLoopLagPrimed = true;
|
||||
return;
|
||||
}
|
||||
const lagMs = Math.max(0, now - expected);
|
||||
expected = now + 1000;
|
||||
if (!pastGrace()) return;
|
||||
if (!shouldRecordEventLoopLagSample(isHidden(), now - visibleSince, isFocused())) return;
|
||||
lagSamples.push(lagMs);
|
||||
if (lagSamples.length > MAX_LAG_SAMPLES) lagSamples.shift();
|
||||
|
||||
@@ -114,7 +114,7 @@ export function LocaleProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.lang = locale === "zh" ? "zh-CN" : "en";
|
||||
document.documentElement.lang = locale === "zh" ? "zh-CN" : locale === "zh-TW" ? "zh-TW" : "en";
|
||||
}, [locale]);
|
||||
|
||||
// setPref updates only the live UI; persistence is handled by desktop config.
|
||||
|
||||
@@ -156,3 +156,56 @@ export function playAttentionChime(): void {
|
||||
void playWav(pref, 0.25, playSynthAttention);
|
||||
}
|
||||
}
|
||||
|
||||
export type AttentionChimeEvent = {
|
||||
kind?: string;
|
||||
tabId?: string;
|
||||
approval?: { id?: string };
|
||||
ask?: { id?: string };
|
||||
};
|
||||
|
||||
export function attentionChimeEventKey(event: AttentionChimeEvent): string | undefined {
|
||||
if (event.kind === "approval_request" && event.approval?.id) return `approval:${event.tabId ?? ""}:${event.approval.id}`;
|
||||
if (event.kind === "ask_request" && event.ask?.id) return `ask:${event.tabId ?? ""}:${event.ask.id}`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// attentionChimeSeenCap bounds the dedupe set. Prompt ids are unique per
|
||||
// prompt, so the set only ever grows; past the cap the oldest half is dropped
|
||||
// (insertion order) — replay dedupe only needs to cover recently replayed
|
||||
// prompts, not the whole session history.
|
||||
const attentionChimeSeenCap = 512;
|
||||
|
||||
// clearAttentionChimeKeys drops dedupe keys after a runtime rebuild. Approval
|
||||
// and ask ids are per-controller counters starting at "1", so a rebuilt
|
||||
// controller (model/effort/settings switch) reissues ids an earlier prompt on
|
||||
// the same tab already used — without this, the first prompt after a rebuild
|
||||
// is misread as a replay and stays silent. A ready event without a tab id
|
||||
// (settings rebuilds emit tab-less ready) clears everything: over-clearing
|
||||
// only re-chimes a replayed pending prompt, which is a desirable reminder,
|
||||
// while under-clearing mutes a live prompt.
|
||||
export function clearAttentionChimeKeys(seen: Set<string>, tabId?: string): void {
|
||||
if (tabId === undefined || tabId === "") {
|
||||
seen.clear();
|
||||
return;
|
||||
}
|
||||
for (const key of [...seen]) {
|
||||
if (key.startsWith(`approval:${tabId}:`) || key.startsWith(`ask:${tabId}:`)) {
|
||||
seen.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldPlayAttentionChimeForEvent(event: AttentionChimeEvent, seen: Set<string>): boolean {
|
||||
const key = attentionChimeEventKey(event);
|
||||
if (!key || seen.has(key)) return false;
|
||||
if (seen.size >= attentionChimeSeenCap) {
|
||||
let drop = seen.size - attentionChimeSeenCap / 2;
|
||||
for (const k of seen) {
|
||||
if (drop-- <= 0) break;
|
||||
seen.delete(k);
|
||||
}
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -155,6 +155,7 @@ export interface MemoryCompilerStats {
|
||||
export interface WireEvent {
|
||||
kind: EventKind;
|
||||
text?: string;
|
||||
detail?: string;
|
||||
reasoning?: string;
|
||||
memoryCitations?: MemoryCitation[];
|
||||
memoryCompiler?: MemoryCompilerStats;
|
||||
@@ -324,6 +325,7 @@ export interface ChangedFileInfo {
|
||||
export interface HistoryMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
detail?: string;
|
||||
submitText?: string;
|
||||
checkpointTurn?: number;
|
||||
createdAt?: number;
|
||||
@@ -413,6 +415,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.
|
||||
@@ -668,6 +671,7 @@ export interface ServerView {
|
||||
tools: number;
|
||||
prompts: number;
|
||||
resources: number;
|
||||
hasTools?: boolean;
|
||||
error?: string;
|
||||
toolList?: MCPToolView[];
|
||||
trustedReadOnlyTools?: string[];
|
||||
|
||||
@@ -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 {
|
||||
@@ -53,7 +53,7 @@ export type Item =
|
||||
| { kind: "user"; id: string; text: string; submitText?: string; failed?: boolean; createdAt?: number; checkpointTurn?: number }
|
||||
| { kind: "assistant"; id: string; text: string; reasoning: string; streaming: boolean; reasoningComplete?: boolean; memoryCitations?: MemoryCitation[] }
|
||||
| { kind: "phase"; id: string; text: string }
|
||||
| { kind: "notice"; id: string; level: "info" | "warn"; text: string }
|
||||
| { kind: "notice"; id: string; level: "info" | "warn"; text: string; detail?: string }
|
||||
| {
|
||||
kind: "compaction";
|
||||
id: string;
|
||||
@@ -244,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,
|
||||
@@ -379,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) {
|
||||
@@ -394,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, m.detail);
|
||||
items = next.items;
|
||||
seq = next.seq;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -725,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 ?? "", e.detail);
|
||||
case "phase":
|
||||
return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "phase", id: `p${s.seq}`, text: e.text ?? "" }] };
|
||||
case "compaction_started":
|
||||
@@ -953,6 +956,278 @@ 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] });
|
||||
}
|
||||
const backgroundJob = /^background (.+) failed: needs attention$/s.exec(msg);
|
||||
if (backgroundJob) {
|
||||
return t("notice.backgroundJobFailed", { kind: backgroundJob[1] });
|
||||
}
|
||||
const canonicalNoticeKey = backendNoticeKey(msg);
|
||||
if (canonicalNoticeKey) {
|
||||
return t(canonicalNoticeKey);
|
||||
}
|
||||
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 backendNoticeKey(msg: string): DictKey | "" {
|
||||
switch (msg) {
|
||||
case "Task status needs one more check; asking the assistant to finish or explain what is blocking it.":
|
||||
return "notice.finalReadiness";
|
||||
case "No visible answer was produced; asking the assistant to respond again.":
|
||||
return "notice.emptyFinal";
|
||||
case "The assistant answered before taking action; asking it to use the required tools.":
|
||||
return "notice.executorHandoff";
|
||||
case "Tool round limit reached; asking the assistant to summarize progress.":
|
||||
return "notice.toolBudget";
|
||||
case "The assistant is stuck retrying a blocked action; asking it to change approach.":
|
||||
return "notice.loopGuard";
|
||||
case "Context is getting large; preserving cache until cleanup is needed.":
|
||||
return "notice.contextLarge";
|
||||
case "Context cleanup skipped for now.":
|
||||
return "notice.contextCleanupSkipped";
|
||||
case "Automatic context cleanup paused because the context window is too small.":
|
||||
return "notice.contextCleanupPaused";
|
||||
case "Context was compacted without a generated summary.":
|
||||
return "notice.compactionNoSummary";
|
||||
case "Planning mode enabled for this multi-step task.":
|
||||
return "notice.autoPlanEnabled";
|
||||
case "Plan detection requested a plan.":
|
||||
return "notice.autoPlanRequested";
|
||||
case "Plan detection was uncertain; using the fallback planner heuristic.":
|
||||
return "notice.autoPlanFallback";
|
||||
case "Goal is not ready to complete yet; continuing the remaining work.":
|
||||
return "notice.goalNotReady";
|
||||
case "Goal still has unfinished task state; continuing the remaining work.":
|
||||
return "notice.goalUnfinished";
|
||||
case "AutoResearch status update failed.":
|
||||
return "notice.autoresearchStatusFailed";
|
||||
case "AutoResearch task marked blocked.":
|
||||
return "notice.autoresearchBlocked";
|
||||
case "Job artifact migration failed.":
|
||||
return "notice.jobArtifactMigrationFailed";
|
||||
case "Background job teardown timed out.":
|
||||
return "notice.jobTeardownTimeout";
|
||||
case "Some plan-mode tool settings were ignored.":
|
||||
return "notice.planModeToolSettingsIgnored";
|
||||
case "Some plan-mode command settings were ignored.":
|
||||
return "notice.planModeCommandSettingsIgnored";
|
||||
case "Config migration did not complete.":
|
||||
return "notice.configMigrationIncomplete";
|
||||
case "Selected model is missing its API key.":
|
||||
return "notice.modelMissingApiKey";
|
||||
case "An MCP server failed to start.":
|
||||
return "notice.mcpServerFailed";
|
||||
case "Some MCP servers failed to start; run /mcp for details.":
|
||||
return "notice.mcpServersFailed";
|
||||
case "Guardian was disabled because its model was not found.":
|
||||
return "notice.guardianModelMissing";
|
||||
case "Guardian was disabled because it could not start.":
|
||||
return "notice.guardianStartFailed";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
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 quietTranscriptNoticeKey(text: string): string {
|
||||
const recovery = recoveryNoticeDedupeKey(text);
|
||||
if (recovery) return recovery;
|
||||
|
||||
const msg = text.trim();
|
||||
if (/^guardian enabled · model=.+$/i.test(msg)) {
|
||||
return "startup:guardian-enabled";
|
||||
}
|
||||
if (/^\d+ MCP server\(s\) failed to start: .+ \u2014 run \/mcp for details$/i.test(msg)) {
|
||||
return "startup:mcp-failures";
|
||||
}
|
||||
const directMCPFailure = /^mcp\s+([A-Za-z0-9._-]+):\s+.+$/i.exec(msg);
|
||||
if (directMCPFailure) {
|
||||
const name = directMCPFailure[1].toLowerCase();
|
||||
if (!["add", "auth", "config", "connect", "import", "mode", "remove"].includes(name)) {
|
||||
return "startup:mcp-failure";
|
||||
}
|
||||
}
|
||||
if (/^plugin ".+" has been slow \d+ startups in a row \(last \d+ms, budget \d+ms\); demoting to background startup this session$/i.test(msg)) {
|
||||
return "startup:plugin-demote";
|
||||
}
|
||||
if (/^.+ applied: session refreshed after the lease was released$/i.test(msg)) {
|
||||
return "settings:deferred-refresh-applied";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string, detail?: string): { items: Item[]; seq: number } {
|
||||
if (quietTranscriptNoticeKey(rawText)) {
|
||||
return { items, seq };
|
||||
}
|
||||
const text = localizedBackendNoticeText(rawText);
|
||||
if (quietTranscriptNoticeKey(text)) {
|
||||
return { items, seq };
|
||||
}
|
||||
const trimmedDetail = detail?.trim();
|
||||
return { items: [...items, { kind: "notice", id, level, text, ...(trimmedDetail ? { detail: trimmedDetail } : {}) }], seq: seq + 1 };
|
||||
}
|
||||
|
||||
function appendNoticeToState(s: State, level: "info" | "warn", text: string, detail?: string): State {
|
||||
const next = appendNoticeItem(s.items, s.seq, `n${s.seq}`, level, text, detail);
|
||||
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) });
|
||||
@@ -963,6 +1238,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(() => {});
|
||||
@@ -984,6 +1269,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;
|
||||
|
||||
@@ -1073,23 +1360,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" });
|
||||
@@ -1120,21 +1415,20 @@ 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 loadAncillary = async <T,>(label: string, load: () => Promise<T>): Promise<T | undefined> => {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const value = await load();
|
||||
addBreadcrumb("tab.hydrate", `ancillary ${label} ${reason} ${tabId} ms=${Date.now() - startedAt}`);
|
||||
return value;
|
||||
} catch (err) {
|
||||
noteFailure(label, err);
|
||||
return undefined;
|
||||
}
|
||||
return loadTimed(`ancillary ${label}`, load);
|
||||
};
|
||||
const [effort, jobs, context] = await Promise.all([
|
||||
loadAncillary("effort", () => app.EffortForTab(tabId)),
|
||||
@@ -1142,22 +1436,30 @@ export function useController() {
|
||||
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 (context !== undefined) dispatchTo(tabId, { type: "context", context });
|
||||
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", `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); });
|
||||
})();
|
||||
@@ -1355,11 +1657,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);
|
||||
@@ -1380,6 +1680,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
|
||||
@@ -1412,7 +1757,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();
|
||||
@@ -1438,7 +1783,7 @@ 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);
|
||||
@@ -1448,7 +1793,7 @@ export function useController() {
|
||||
}, [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);
|
||||
@@ -1459,7 +1804,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).
|
||||
@@ -1697,7 +2042,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 {
|
||||
@@ -1709,7 +2054,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) });
|
||||
@@ -1722,7 +2072,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 {
|
||||
|
||||
@@ -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
|
||||
@@ -470,7 +471,10 @@ export const en = {
|
||||
"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.steerPlaceholder": "Running — type guidance, Enter adds it to the queue",
|
||||
"composer.runWaitingApproval": "Waiting for your approval — {tool}",
|
||||
"composer.runWaitingAsk": "Waiting for your answer",
|
||||
"composer.runAnnounceRunning": "Reasonix is working",
|
||||
"composer.guidanceQueue": "Queued guidance",
|
||||
"composer.guidanceCount": "Queued guidance {n}",
|
||||
"composer.guidanceRemaining": "{n} more queued",
|
||||
@@ -531,8 +535,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})",
|
||||
@@ -643,6 +676,13 @@ export const en = {
|
||||
"approval.sendRevision": "Send update",
|
||||
"approval.exitPlan": "Exit plan",
|
||||
"approval.toolPending": "Allow tool call",
|
||||
"approval.modeSwitchPendingHint": "Approval mode was relaxed — this pending request still needs your decision.",
|
||||
"approval.toolLabelExitPlan": "Plan approval",
|
||||
"approval.allowOnceDesc": "Allow this call only; the next one asks again.",
|
||||
"approval.allowRuleSessionDesc": "Allow matching calls until this session ends; resets on restart.",
|
||||
"approval.allowRulePersistentDesc": "Save a persistent allow rule; future sessions stop asking too.",
|
||||
"approval.denyDesc": "Reject this call; the model sees the refusal and continues.",
|
||||
"approval.allowSandboxEscapeSessionDesc": "Run follow-up commands in the real environment for the rest of this session.",
|
||||
"approval.details": "Details",
|
||||
"approval.hideDetails": "Hide",
|
||||
"approval.deny": "Deny",
|
||||
@@ -657,6 +697,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",
|
||||
@@ -695,6 +758,12 @@ export const en = {
|
||||
"history.clearTrash": "Empty trash",
|
||||
"history.confirmClearTrash": "Confirm empty trash",
|
||||
"history.clearTrashMenu": "Empty trash…",
|
||||
"history.clearRecoveryCopies": "Clear copies",
|
||||
"history.confirmClearRecoveryCopies": "Confirm clear copies",
|
||||
"history.clearRecoveryCopiesMenu": "Clear recovery copies…",
|
||||
"history.trashRecoveryCopies": "Trash recovery copies",
|
||||
"history.confirmTrashRecoveryCopies": "Confirm trash copies",
|
||||
"history.recoveryCopiesGroup": "Recovery copies",
|
||||
"history.moveToTrash": "Move to trash",
|
||||
"history.confirmMoveToTrash": "Confirm move to trash",
|
||||
"history.restore": "Restore",
|
||||
@@ -726,22 +795,19 @@ 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": "Recovery branch",
|
||||
"recovery.badge": "Recovery",
|
||||
"recovery.open": "Open recovery branch",
|
||||
"recovery.toast": "Unsaved local changes were saved as {title}.",
|
||||
"recovery.failedLease": "The recovery branch is in use by another runtime, so this tab did not switch to it.",
|
||||
"recovery.failedUnavailable": "The recovery branch could not be opened, so this tab stayed on the original session.",
|
||||
"recovery.banner": "This session was recovered from a save conflict. Compare it with the original before continuing.",
|
||||
"recovery.reason": "Reason: {reason}",
|
||||
"recovery.digest": "Digest: {digest}",
|
||||
"recovery.parent": "Parent: {parent}",
|
||||
"recovery.branch": "Conflict copy",
|
||||
"recovery.badge": "Copy",
|
||||
"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",
|
||||
@@ -757,6 +823,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",
|
||||
@@ -1396,7 +1463,7 @@ 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. Use Permissions or approval mode for deploy-style commands.",
|
||||
"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",
|
||||
@@ -1405,7 +1472,7 @@ export const en = {
|
||||
"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",
|
||||
@@ -1498,6 +1565,9 @@ export const en = {
|
||||
"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",
|
||||
@@ -1532,6 +1602,15 @@ 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.",
|
||||
@@ -1558,6 +1637,8 @@ export const en = {
|
||||
"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.",
|
||||
@@ -1642,6 +1723,8 @@ export const en = {
|
||||
"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…",
|
||||
@@ -1724,6 +1807,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}",
|
||||
@@ -1798,6 +1885,34 @@ export const en = {
|
||||
"transcript.processedDuration": "Processed {s}s",
|
||||
"notice.info": "Notice",
|
||||
"notice.warning": "Warning",
|
||||
"notice.details": "Details",
|
||||
"notice.finalReadiness": "Task status needs one more check; asking the assistant to finish or explain what is blocking it.",
|
||||
"notice.emptyFinal": "No visible answer was produced; asking the assistant to respond again.",
|
||||
"notice.executorHandoff": "The assistant answered before taking action; asking it to use the required tools.",
|
||||
"notice.toolBudget": "Tool round limit reached; asking the assistant to summarize progress.",
|
||||
"notice.loopGuard": "The assistant is stuck retrying a blocked action; asking it to change approach.",
|
||||
"notice.contextLarge": "Context is getting large; preserving cache until cleanup is needed.",
|
||||
"notice.contextCleanupSkipped": "Context cleanup skipped for now.",
|
||||
"notice.contextCleanupPaused": "Automatic context cleanup paused because the context window is too small.",
|
||||
"notice.compactionNoSummary": "Context was compacted without a generated summary.",
|
||||
"notice.autoPlanEnabled": "Planning mode enabled for this multi-step task.",
|
||||
"notice.autoPlanRequested": "Plan detection requested a plan.",
|
||||
"notice.autoPlanFallback": "Plan detection was uncertain; using the fallback planner heuristic.",
|
||||
"notice.goalNotReady": "Goal is not ready to complete yet; continuing the remaining work.",
|
||||
"notice.goalUnfinished": "Goal still has unfinished task state; continuing the remaining work.",
|
||||
"notice.autoresearchStatusFailed": "AutoResearch status update failed.",
|
||||
"notice.autoresearchBlocked": "AutoResearch task marked blocked.",
|
||||
"notice.backgroundJobFailed": "Background {kind} needs attention.",
|
||||
"notice.jobArtifactMigrationFailed": "Job artifact migration failed.",
|
||||
"notice.jobTeardownTimeout": "Background job teardown timed out.",
|
||||
"notice.planModeToolSettingsIgnored": "Some plan-mode tool settings were ignored.",
|
||||
"notice.planModeCommandSettingsIgnored": "Some plan-mode command settings were ignored.",
|
||||
"notice.configMigrationIncomplete": "Config migration did not complete.",
|
||||
"notice.modelMissingApiKey": "Selected model is missing its API key.",
|
||||
"notice.mcpServerFailed": "An MCP server failed to start.",
|
||||
"notice.mcpServersFailed": "Some MCP servers failed to start; run /mcp for details.",
|
||||
"notice.guardianModelMissing": "Guardian was disabled because its model was not found.",
|
||||
"notice.guardianStartFailed": "Guardian was disabled because it could not start.",
|
||||
"questionNav.label": "Question navigation",
|
||||
"questionNav.progress": "Question {current} / {total}",
|
||||
"questionNav.jump": "Jump to question {n}",
|
||||
@@ -1813,6 +1928,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",
|
||||
@@ -1833,6 +1949,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",
|
||||
@@ -1843,8 +1960,12 @@ export const en = {
|
||||
// tool card summaries
|
||||
"tool.stepOne": "{n} step",
|
||||
"tool.stepOther": "{n} steps",
|
||||
"tool.error": "error",
|
||||
"tool.errorReceiptMismatch": "verification command has no matching successful receipt",
|
||||
"tool.truncated": "output truncated",
|
||||
"tool.showAllLines": "show all {n} lines",
|
||||
"tool.showErrorDetails": "show error details",
|
||||
"tool.hideErrorDetails": "hide error details",
|
||||
"tool.readCount": "Read {n} files",
|
||||
"tool.searchCount": "Search {n} files",
|
||||
"tool.otherReadCount": "{n} read calls",
|
||||
@@ -2015,6 +2136,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",
|
||||
|
||||
@@ -22,6 +22,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"common.busyHint": "請先完成或停止當前回合",
|
||||
"common.loading": "載入中…",
|
||||
"composer.readOnlyChannel": "渠道會話唯讀",
|
||||
"composer.workspaceStarting": "工作區還在啟動",
|
||||
"app.splashSubtitle": "Agent Workspace",
|
||||
|
||||
// 頂欄
|
||||
@@ -349,7 +350,10 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"composer.send": "傳送(Enter)",
|
||||
"composer.queueGuidance": "加入引導佇列(Enter)",
|
||||
"composer.steer": "追加到目前任務(Enter)",
|
||||
"composer.steerPlaceholder": "輸入要加入佇列的引導…",
|
||||
"composer.steerPlaceholder": "正在執行——輸入補充指示,Enter 加入佇列",
|
||||
"composer.runWaitingApproval": "等待你核准 — {tool}",
|
||||
"composer.runWaitingAsk": "等待你回答",
|
||||
"composer.runAnnounceRunning": "正在執行",
|
||||
"composer.guidanceQueue": "待處理引導",
|
||||
"composer.guidanceCount": "待處理引導 {n}",
|
||||
"composer.guidanceRemaining": "還有 {n} 條",
|
||||
@@ -385,7 +389,31 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"modelSwitcher.searchPlaceholder": "搜尋模型…",
|
||||
"modelSwitcher.noMatches": "沒有匹配的模型",
|
||||
"status.noModels": "沒有可切換模型",
|
||||
"status.modelSwitchBusy": "暫時不能切換模型。請先停止目前回答、處理待確認問題,或等待背景任務結束。",
|
||||
"status.modelSwitchLeaseHeld": "這個會話正在另一個 Reasonix 視窗或背景執行。請關閉那個視窗、停止背景執行,或開啟副本後再切換模型。",
|
||||
"status.modelSwitchStarting": "會話還在啟動,稍後再切換模型。",
|
||||
"status.modelSwitchStartupFailed": "目前會話啟動失敗,無法切換模型:{err}",
|
||||
"status.modelSwitchRetry": "目前會話已變更,請再試一次切換模型。",
|
||||
"status.modelSwitchUnknown": "未知模型 {model}。",
|
||||
"status.modelSwitchProviderUnavailable": "模型 {model} 不可用,因為提供商 {provider} 尚未添加。",
|
||||
"status.modelSwitchFailed": "模型切換失敗:{err}",
|
||||
"status.modelFallbackSwitched": "模型 {model} 已不可用,已切換到 {fallback}。",
|
||||
"status.effortSwitchBusy": "暫時不能切換推理力度。請先停止目前回答、處理待確認問題,或等待背景任務結束。",
|
||||
"status.effortSwitchLeaseHeld": "這個會話正在另一個 Reasonix 視窗或背景執行。請關閉那個視窗、停止背景執行,或開啟副本後再切換推理力度。",
|
||||
"status.effortSwitchStarting": "會話還在啟動,稍後再切換推理力度。",
|
||||
"status.effortSwitchStartupFailed": "目前會話啟動失敗,無法切換推理力度:{err}",
|
||||
"status.effortSwitchRetry": "目前會話已變更,請再試一次切換推理力度。",
|
||||
"status.effortSwitchFailed": "推理力度切換失敗:{err}",
|
||||
"status.sessionAutosaveFailed": "會話自動儲存失敗:{err}",
|
||||
"status.sessionSaveFailedBefore": "在{action}前儲存會話失敗:{err}",
|
||||
"status.actionChangingModel": "切換模型",
|
||||
"status.actionChangingEffort": "切換推理力度",
|
||||
"status.actionChangingTokenMode": "切換省 token 模式",
|
||||
"status.actionRebuildingSettings": "重新整理設定",
|
||||
"status.actionSwitchingSessions": "切換會話",
|
||||
"status.actionSwitchingTabs": "切換標籤頁",
|
||||
"status.actionAutosave": "自動儲存",
|
||||
"status.actionCurrentSession": "儲存目前會話",
|
||||
"status.effort": "effort {level}",
|
||||
"status.effortTitle": "推理力度",
|
||||
"status.effortAutoTitle": "推理力度:auto(模型預設:{def})",
|
||||
@@ -495,6 +523,13 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"approval.sendRevision": "傳送修改意見",
|
||||
"approval.exitPlan": "退出計畫",
|
||||
"approval.toolPending": "允許工具呼叫",
|
||||
"approval.modeSwitchPendingHint": "核准檔位已放寬——這筆擱置的請求仍需你確認。",
|
||||
"approval.toolLabelExitPlan": "計畫確認",
|
||||
"approval.allowOnceDesc": "僅允許本次呼叫,下次仍會詢問。",
|
||||
"approval.allowRuleSessionDesc": "本會話內符合的呼叫不再詢問,重啟後失效。",
|
||||
"approval.allowRulePersistentDesc": "儲存為持久允許規則,之後的會話也不再詢問。",
|
||||
"approval.denyDesc": "拒絕本次呼叫,模型會收到拒絕結果並繼續。",
|
||||
"approval.allowSandboxEscapeSessionDesc": "本會話後續命令改在真實環境執行,跳過沙箱。",
|
||||
"approval.details": "詳情",
|
||||
"approval.hideDetails": "收起",
|
||||
"approval.deny": "拒絕",
|
||||
@@ -509,6 +544,29 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"approval.allowRulePersistent": "總是允許(儲存)",
|
||||
"approval.allowEditsSession": "本會話允許編輯",
|
||||
"approval.allowFilePersistent": "總是允許此檔案(儲存)",
|
||||
"approval.allowSandboxEscapeSession": "本會話使用真實環境",
|
||||
"approval.toolLabelBash": "bash",
|
||||
"approval.toolLabelEditFile": "編輯檔案",
|
||||
"approval.toolLabelWriteFile": "寫入檔案",
|
||||
"approval.toolLabelMultiEdit": "批次編輯",
|
||||
"approval.toolLabelMoveFile": "移動檔案",
|
||||
"approval.toolLabelWebFetch": "讀取網頁",
|
||||
"approval.toolLabelRunSkill": "執行技能",
|
||||
"approval.toolLabelRemember": "儲存記憶",
|
||||
"approval.toolLabelForget": "刪除記憶",
|
||||
"approval.toolLabelSandboxEscape": "bash 沙箱降級核准",
|
||||
"approval.toolLabelPlanModeReadOnly": "計劃模式唯讀命令",
|
||||
"approval.memorySaveUpdate": "儲存/更新記憶",
|
||||
"approval.memoryBodyLabel": "正文",
|
||||
"approval.memoryArchivePrefix": "封存記憶 ",
|
||||
"approval.planModeMcpTrustSubject": "將 MCP {target} 作為計劃/研究唯讀工具信任",
|
||||
"approval.planModeMcpTrustReason": "這個 MCP 工具宣告自己是唯讀的,但外部唯讀提示需要你確認後,計劃模式才能使用。選擇總是允許可為後續計劃和唯讀研究記住這份信任。",
|
||||
"approval.planModeBashTrustSubject": "在計劃模式中信任 {prefix} 為唯讀命令前綴\n命令:{command}",
|
||||
"approval.planModeBashTrustReason": "這條 bash 命令不在 Reasonix 內建唯讀集合中。只有在確認這個精確前綴用於計劃和研究時是唯讀的,才應核准。自動/YOLO 核准不能回答這個信任提示。",
|
||||
"approval.sandboxEscapeSubjectFallback": "僅本次不進沙箱執行 shell 命令",
|
||||
"approval.sandboxEscapeSubjectPrefix": "僅本次不進沙箱執行:",
|
||||
"approval.sandboxEscapeWrapReason": "Windows 沙箱無法包裝這條命令。是否僅本次不進 OS 沙箱執行?這只會對此命令繞過 OS 沙箱。",
|
||||
"approval.sandboxEscapeRuntimeReason": "Windows 沙箱啟動這條命令時失敗。是否僅本次不進 OS 沙箱執行?這只會對此命令繞過 OS 沙箱。",
|
||||
|
||||
// 提問卡片
|
||||
"ask.title": "需要你決定",
|
||||
@@ -538,6 +596,12 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"history.clearTrash": "清空回收站",
|
||||
"history.confirmClearTrash": "確認清空回收站",
|
||||
"history.clearTrashMenu": "清空回收站…",
|
||||
"history.clearRecoveryCopies": "清理副本",
|
||||
"history.confirmClearRecoveryCopies": "確認清理副本",
|
||||
"history.clearRecoveryCopiesMenu": "清理恢復副本…",
|
||||
"history.trashRecoveryCopies": "副本移入回收站",
|
||||
"history.confirmTrashRecoveryCopies": "確認移入回收站",
|
||||
"history.recoveryCopiesGroup": "恢復副本",
|
||||
"history.moveToTrash": "移到回收站",
|
||||
"history.confirmMoveToTrash": "確認移入回收站",
|
||||
"history.restore": "還原",
|
||||
@@ -569,22 +633,19 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"history.failedOpenProject": "無法開啟會話,{name}不存在:{path}",
|
||||
"history.failedOpenSession": "無法開啟會話",
|
||||
"history.failedOpenGlobal": "無法開啟全域會話",
|
||||
"history.missingWorkspaceRoot": "無法開啟會話:缺少工作區路徑。",
|
||||
"history.selectSession": "選擇一個會話以預覽。",
|
||||
"history.trashSessionActions": "回收站會話操作",
|
||||
"history.historySessionActions": "歷史會話操作",
|
||||
"history.trashActions": "回收站操作",
|
||||
|
||||
// 會話恢復
|
||||
"recovery.branch": "恢復分支",
|
||||
"recovery.badge": "恢復",
|
||||
"recovery.open": "開啟恢復分支",
|
||||
"recovery.toast": "未儲存的本機內容已儲存為 {title}。",
|
||||
"recovery.failedLease": "恢復分支被另一個執行階段占用,未切換過去。",
|
||||
"recovery.failedUnavailable": "無法開啟恢復分支,目前標籤仍停留在原會話。",
|
||||
"recovery.banner": "目前會話來自儲存衝突恢復。繼續前建議與原會話對比。",
|
||||
"recovery.reason": "原因:{reason}",
|
||||
"recovery.digest": "摘要:{digest}",
|
||||
"recovery.parent": "父會話:{parent}",
|
||||
"recovery.branch": "衝突副本",
|
||||
"recovery.badge": "副本",
|
||||
"recovery.noticeSavedCopy": "偵測到會話在磁碟上發生變化,未儲存的本機記錄已保留為衝突副本。",
|
||||
"recovery.noticeKeptCurrent": "偵測到重複儲存衝突,目前衝突副本已繼續儲存。",
|
||||
"recovery.noticeAdopted": "偵測到會話在磁碟上發生變化,已採用較新的記錄。",
|
||||
"recovery.noticeAdoptedCovered": "偵測到會話在磁碟上發生變化,已採用較新的記錄;本機改動已包含其中。",
|
||||
|
||||
// 專案樹
|
||||
"projectTree.workspaceTitle": "專案",
|
||||
@@ -885,11 +946,11 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.yoloHint": "所有工具呼叫(寫檔案、bash)自動執行、不再詢問。deny 規則仍會攔截。僅執行時生效,重啟後失效。",
|
||||
"settings.addRule": "新增 {list} 規則…",
|
||||
"settings.sandboxTitle": "沙箱與工作區",
|
||||
"settings.sandboxBoundaryHint": "沙箱可寫根只限制已核准的 Bash 和檔案工具能寫到哪裡;它不會自動核准命令。mvn deploy 這類命令還需要權限規則或核准模式放行。",
|
||||
"settings.sandboxBoundaryHint": "沙箱可寫根只限制已核准的 Bash 和檔案工具能寫到哪裡;它不會自動核准命令。若 Bash 沙箱為 enforce 但宿主機沒有 OS 沙箱後端,shell 命令會被拒絕;只在可信工作區把 Bash 沙箱切到 off。",
|
||||
"settings.reloadSessionConfig": "套用手動設定修改",
|
||||
"settings.reloadSessionConfigHint": "設定頁內修改會自動生效;此按鈕用於套用手動編輯 config.toml / reasonix.toml 後的變更。",
|
||||
"settings.bashSandbox": "Bash 沙箱",
|
||||
"settings.bashEnforce": "enforce(隔離 bash)",
|
||||
"settings.bashEnforce": "enforce(隔離 bash;失敗即拒絕)",
|
||||
"settings.bashOff": "off(不受限執行)",
|
||||
"settings.allowNetwork": "允許沙箱內 bash 存取網路",
|
||||
"settings.workspaceRoot": "工作區根目錄",
|
||||
@@ -913,6 +974,9 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.textSizeXLarge": "特大",
|
||||
"settings.textSizeXXLarge": "超大",
|
||||
"settings.displayZoom": "顯示縮放(需重啟生效)",
|
||||
"settings.displayZoomDecrease": "縮小顯示縮放",
|
||||
"settings.displayZoomIncrease": "放大顯示縮放",
|
||||
"settings.displayZoomReset": "恢復顯示縮放到 100%",
|
||||
"settings.fontFamily": "介面字型",
|
||||
"settings.fontFamilySystem": "系統預設",
|
||||
"settings.fontFamilyYaHei": "微軟正黑體",
|
||||
@@ -941,6 +1005,15 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.modelUsage": "使用模型",
|
||||
"settings.modelUnavailable": "{ref} 不可用。請選擇已設定模型,或先新增模型服務接入。",
|
||||
"settings.modelNeedsKey": "{provider} 需要設定 API key 後才能執行該模型。",
|
||||
"settings.errorUnknown": "操作失敗。",
|
||||
"settings.errorUnknownModel": "未知模型 {model}。",
|
||||
"settings.errorModelProviderMissing": "模型 {model} 不可用,因為提供商 {provider} 尚未添加。",
|
||||
"settings.errorModelProviderNoKey": "模型 {model} 不可用,因為提供商 {provider} 尚未設定 API Key。",
|
||||
"settings.errorRemoveAccessBusy": "請先停止正在使用 {provider} 的任務,再移除模型服務接入。",
|
||||
"settings.errorDeleteProviderBusy": "請先停止正在使用 {provider} 的任務,再刪除模型服務。",
|
||||
"settings.errorSaveBeforeRemoveAccess": "移除模型服務接入前儲存目前會話失敗:{err}",
|
||||
"settings.errorSaveBeforeDeleteProvider": "刪除模型服務前儲存目前會話失敗:{err}",
|
||||
"settings.errorRemoveProviderNoFallback": "模型服務 {provider} 正被開啟的標籤頁使用,且沒有其他可用模型服務可切換。",
|
||||
"settings.customProviders": "自訂供應商",
|
||||
"settings.customProvidersHint": "你自己新增的 OpenAI-compatible 或代理接入,可以編輯、刪除並持久化模型列表。",
|
||||
"settings.customProvidersEmpty": "還沒有自訂供應商。常用服務優先使用上方內建接入。",
|
||||
@@ -967,6 +1040,8 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.addProvider.customHint": "適合代理、聚合平台或其它 OpenAI-compatible 服務。",
|
||||
"settings.addProvider.official.deepseek": "DeepSeek 官方",
|
||||
"settings.addProvider.official.deepseekDesc": "官方 API Key 接入,可用 DeepSeek V4 Flash / Pro。",
|
||||
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat 開放平台 OpenAI-compatible 端點,預設 LongCat-2.0。",
|
||||
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat 開放平台 Anthropic-compatible Messages 端點,使用 Bearer 認證。",
|
||||
"settings.addProvider.preset.kimiCnDesc": "Kimi 中國 API,走 Moonshot CN 端點,預置 K2 視覺模型。",
|
||||
"settings.addProvider.preset.kimiGlobalDesc": "Kimi 國際 API,走 Moonshot global 端點,預置 K2 視覺模型。",
|
||||
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan,走獨立 Anthropic-compatible 端點,不歸屬 CN/Global。",
|
||||
@@ -1051,6 +1126,8 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
|
||||
"settings.providerExtraBodyHint": "會合併到聊天請求體頂層 JSON。model、messages、tools、stream、thinking 等核心欄位仍由 Reasonix 控制。",
|
||||
"settings.providerExtraBodyError": "請輸入不含 null 值的 JSON 物件,例如 { \"enable_thinking\": true }。",
|
||||
"settings.providerExtraBodyNull": "{path} 不能包含 null。",
|
||||
"settings.providerExtraBodyObjectRequired": "額外請求體必須是 JSON 物件。",
|
||||
"settings.providerAuthHeader": "使用 Authorization: Bearer 發送金鑰",
|
||||
"settings.providerAuthHeaderHint": "適用於 MiniMax Global、Vercel AI Gateway 等不使用 x-api-key 的 Anthropic-compatible 閘道。",
|
||||
"settings.fetchingModels": "重新整理中…",
|
||||
@@ -1144,6 +1221,34 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"transcript.processedDuration": "已處理 {s}s",
|
||||
"notice.info": "提示",
|
||||
"notice.warning": "警告",
|
||||
"notice.details": "詳情",
|
||||
"notice.finalReadiness": "任務狀態還需要確認,已要求助手完成或說明阻塞原因。",
|
||||
"notice.emptyFinal": "沒有產生可見回覆,已要求助手重新作答。",
|
||||
"notice.executorHandoff": "助手尚未執行必要操作,已要求它使用相應工具。",
|
||||
"notice.toolBudget": "工具呼叫輪數已達上限,已要求助手總結目前進度。",
|
||||
"notice.loopGuard": "助手卡在重複受阻操作上,已要求它換一種處理方式。",
|
||||
"notice.contextLarge": "上下文正在變大,暫時保留快取,等需要時再清理。",
|
||||
"notice.contextCleanupSkipped": "本次暫未清理上下文。",
|
||||
"notice.contextCleanupPaused": "自動上下文清理已暫停:目前上下文視窗太小。",
|
||||
"notice.compactionNoSummary": "上下文已壓縮,但未產生摘要。",
|
||||
"notice.autoPlanEnabled": "已為這個多步驟任務開啟規劃模式。",
|
||||
"notice.autoPlanRequested": "任務判斷建議先制定計畫。",
|
||||
"notice.autoPlanFallback": "規劃判斷不確定,已使用備用規則。",
|
||||
"notice.goalNotReady": "目標暫未達到完成條件,繼續處理剩餘工作。",
|
||||
"notice.goalUnfinished": "目標還有未完成的任務狀態,繼續處理。",
|
||||
"notice.autoresearchStatusFailed": "AutoResearch 狀態更新失敗。",
|
||||
"notice.autoresearchBlocked": "AutoResearch 任務已標記為阻塞。",
|
||||
"notice.backgroundJobFailed": "背景 {kind} 需要處理。",
|
||||
"notice.jobArtifactMigrationFailed": "任務產物遷移失敗。",
|
||||
"notice.jobTeardownTimeout": "背景任務清理逾時。",
|
||||
"notice.planModeToolSettingsIgnored": "部分規劃模式工具設定已被忽略。",
|
||||
"notice.planModeCommandSettingsIgnored": "部分規劃模式指令設定已被忽略。",
|
||||
"notice.configMigrationIncomplete": "設定遷移未完成。",
|
||||
"notice.modelMissingApiKey": "目前模型缺少 API key。",
|
||||
"notice.mcpServerFailed": "一個 MCP 服務啟動失敗。",
|
||||
"notice.mcpServersFailed": "部分 MCP 服務啟動失敗,可執行 /mcp 查看詳情。",
|
||||
"notice.guardianModelMissing": "Guardian 已停用:未找到對應模型。",
|
||||
"notice.guardianStartFailed": "Guardian 啟動失敗,已停用。",
|
||||
"questionNav.label": "問題導航",
|
||||
"questionNav.progress": "問題 {current} / {total}",
|
||||
"questionNav.jump": "跳轉到問題 {n}",
|
||||
@@ -1159,6 +1264,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"rewind.disabledNoBoundary": "這條訊息的會話邊界不可用",
|
||||
"rewind.disabledNoCode": "這條訊息之後沒有捕獲到程式碼變更",
|
||||
"rewind.disabledNoEarlier": "這條訊息之前沒有可總結內容",
|
||||
"rewind.disabledNoLater": "這條訊息之後沒有可總結內容",
|
||||
"rewind.filesChanged": "{count} 個檔案",
|
||||
"rewind.turnFiles": "本輪 {count} 個",
|
||||
"rewind.both": "回滾程式碼和對話",
|
||||
@@ -1179,6 +1285,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"rewind.busyConversation": "正在回滾對話…",
|
||||
"rewind.busyCode": "正在回滾程式碼…",
|
||||
"rewind.busyBoth": "正在回滾程式碼和對話…",
|
||||
"rewind.failed": "回滾失敗",
|
||||
"undoRewind.turns": "已回滾 {n} 輪對話",
|
||||
"undoRewind.filesRestored": "還原了 {n} 個檔案",
|
||||
"undoRewind.filesRemoved": "移除了 {n} 個檔案",
|
||||
@@ -1189,8 +1296,12 @@ export const zhTW: Record<DictKey, string> = {
|
||||
// 工具卡片摘要
|
||||
"tool.stepOne": "{n} 步",
|
||||
"tool.stepOther": "{n} 步",
|
||||
"tool.error": "錯誤",
|
||||
"tool.errorReceiptMismatch": "證據命令沒有匹配的成功執行記錄",
|
||||
"tool.truncated": "輸出已截斷",
|
||||
"tool.showAllLines": "顯示全部 {n} 行",
|
||||
"tool.showErrorDetails": "顯示錯誤詳情",
|
||||
"tool.hideErrorDetails": "隱藏錯誤詳情",
|
||||
"tool.lineOne": "{n} 行",
|
||||
"tool.lineOther": "{n} 行",
|
||||
"tool.matchOne": "{n} 處匹配",
|
||||
@@ -1305,6 +1416,9 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"mock.topicAi": "AI 工程化最佳實踐",
|
||||
"mock.topicLab": "臨時探索與實驗",
|
||||
"mock.approvalSubject": "npm run build\n\n需要執行建構命令來驗證前端產物和樣式打包是否正常。",
|
||||
"mock.sandboxEscapeReasoning": "Windows 沙箱 helper 失敗,需要確認是否僅本次不進沙箱重跑。",
|
||||
"mock.sandboxEscapeSubject": "僅本次不進沙箱執行:go test ./...",
|
||||
"mock.sandboxEscapeReason": "Windows 沙箱啟動這條命令時失敗。是否僅本次不進 OS 沙箱執行?這只會對此命令繞過 OS 沙箱。",
|
||||
"mock.askQ1Header": "處理方向",
|
||||
"mock.askQ1Prompt": "git pull 的衝突你想怎麼處理?",
|
||||
"mock.askQ1Opt1Label": "git stash 後 pull",
|
||||
@@ -1476,6 +1590,11 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"status.costLabel": "會話費用",
|
||||
"status.balanceLabel": "餘額",
|
||||
"status.jobsLabel": "任務",
|
||||
"status.tokenModeSwitchBusy": "暫時不能切換省 token 模式。請先停止目前回答、處理待確認問題,或等待背景任務結束。",
|
||||
"status.tokenModeSwitchLeaseHeld": "這個會話正在另一個 Reasonix 視窗或背景執行。請關閉那個視窗、停止背景執行,或開啟副本後再切換省 token 模式。",
|
||||
"status.tokenModeSwitchStarting": "會話還在啟動,稍後再切換省 token 模式。",
|
||||
"status.tokenModeSwitchStartupFailed": "目前會話啟動失敗,無法切換省 token 模式:{err}",
|
||||
"status.tokenModeSwitchRetry": "目前會話已變更,請再試一次切換省 token 模式。",
|
||||
"status.tokenModeSwitchFailed": "省 token 模式切換失敗:{err}",
|
||||
"status.modelTitle": "當前模型",
|
||||
"clearContext.title": "清空當前上下文",
|
||||
@@ -1486,6 +1605,7 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"clearContext.done": "已清空當前上下文",
|
||||
"clearContext.failed": "清空當前上下文失敗",
|
||||
"projectTree.justNow": "剛剛",
|
||||
"projectTree.previously": "之前",
|
||||
"projectTree.running": "輸出中",
|
||||
"projectTree.status.thinking": "思考中",
|
||||
"projectTree.status.streaming": "輸出中",
|
||||
@@ -1954,6 +2074,10 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"settings.hooksJsonApplied": "JSON 已校驗,儲存後生效。",
|
||||
"settings.hooksJsonFormatted": "JSON 已格式化,儲存後生效。",
|
||||
"settings.hooksJsonInvalid": "JSON 無效:{error}",
|
||||
"settings.hooksJsonExpectedObjectArray": "應為物件或陣列。",
|
||||
"settings.hooksJsonItemObject": "Hook 項必須是物件。",
|
||||
"settings.hooksJsonUnknownEvent": "未知 Hook 事件 {event}。",
|
||||
"settings.hooksJsonEventItemObject": "Hook {event} 的項目必須是物件。",
|
||||
"settings.hooksJsonClipboardEmpty": "剪貼簿為空",
|
||||
"settings.hooksJsonClipboardUnavailable": "無法存取剪貼簿;可手動複製文字框內容。",
|
||||
"settings.hooksJsonPasteFailed": "貼上失敗:{error}",
|
||||
|
||||
@@ -22,6 +22,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"common.busyHint": "请先完成或停止当前回合",
|
||||
"common.loading": "加载中…",
|
||||
"composer.readOnlyChannel": "渠道会话只读",
|
||||
"composer.workspaceStarting": "工作区还在启动",
|
||||
"app.splashSubtitle": "Agent Workspace",
|
||||
|
||||
// 顶栏
|
||||
@@ -471,7 +472,10 @@ export const zh: Record<DictKey, string> = {
|
||||
"composer.send": "发送(Enter)",
|
||||
"composer.queueGuidance": "加入引导队列(Enter)",
|
||||
"composer.steer": "追加到当前任务(Enter)",
|
||||
"composer.steerPlaceholder": "输入要加入队列的引导…",
|
||||
"composer.steerPlaceholder": "正在运行——输入补充指示,回车加入队列",
|
||||
"composer.runWaitingApproval": "等待你批准 — {tool}",
|
||||
"composer.runWaitingAsk": "等待你回答",
|
||||
"composer.runAnnounceRunning": "正在运行",
|
||||
"composer.guidanceQueue": "待处理引导",
|
||||
"composer.guidanceCount": "待处理引导 {n}",
|
||||
"composer.guidanceRemaining": "还有 {n} 条",
|
||||
@@ -532,8 +536,37 @@ export const zh: Record<DictKey, string> = {
|
||||
"modelSwitcher.searchPlaceholder": "搜索模型…",
|
||||
"modelSwitcher.noMatches": "没有匹配的模型",
|
||||
"status.noModels": "没有可切换模型",
|
||||
"status.modelSwitchBusy": "暂时不能切换模型。请先停止当前回答、处理待确认问题,或等待后台任务结束。",
|
||||
"status.modelSwitchLeaseHeld": "这个会话正在另一个 Reasonix 窗口或后台运行。请关闭那个窗口、停止后台运行,或打开副本后再切换模型。",
|
||||
"status.modelSwitchStarting": "会话还在启动,稍后再切换模型。",
|
||||
"status.modelSwitchStartupFailed": "当前会话启动失败,无法切换模型:{err}",
|
||||
"status.modelSwitchRetry": "当前会话已变化,请再试一次切换模型。",
|
||||
"status.modelSwitchUnknown": "未知模型 {model}。",
|
||||
"status.modelSwitchProviderUnavailable": "模型 {model} 不可用,因为提供商 {provider} 尚未添加。",
|
||||
"status.modelSwitchFailed": "模型切换失败:{err}",
|
||||
"status.modelFallbackSwitched": "模型 {model} 已不可用,已切换到 {fallback}。",
|
||||
"status.effortSwitchBusy": "暂时不能切换推理力度。请先停止当前回答、处理待确认问题,或等待后台任务结束。",
|
||||
"status.effortSwitchLeaseHeld": "这个会话正在另一个 Reasonix 窗口或后台运行。请关闭那个窗口、停止后台运行,或打开副本后再切换推理力度。",
|
||||
"status.effortSwitchStarting": "会话还在启动,稍后再切换推理力度。",
|
||||
"status.effortSwitchStartupFailed": "当前会话启动失败,无法切换推理力度:{err}",
|
||||
"status.effortSwitchRetry": "当前会话已变化,请再试一次切换推理力度。",
|
||||
"status.effortSwitchFailed": "推理力度切换失败:{err}",
|
||||
"status.tokenModeSwitchBusy": "暂时不能切换省 token 模式。请先停止当前回答、处理待确认问题,或等待后台任务结束。",
|
||||
"status.tokenModeSwitchLeaseHeld": "这个会话正在另一个 Reasonix 窗口或后台运行。请关闭那个窗口、停止后台运行,或打开副本后再切换省 token 模式。",
|
||||
"status.tokenModeSwitchStarting": "会话还在启动,稍后再切换省 token 模式。",
|
||||
"status.tokenModeSwitchStartupFailed": "当前会话启动失败,无法切换省 token 模式:{err}",
|
||||
"status.tokenModeSwitchRetry": "当前会话已变化,请再试一次切换省 token 模式。",
|
||||
"status.tokenModeSwitchFailed": "省 token 模式切换失败:{err}",
|
||||
"status.sessionAutosaveFailed": "会话自动保存失败:{err}",
|
||||
"status.sessionSaveFailedBefore": "在{action}前保存会话失败:{err}",
|
||||
"status.actionChangingModel": "切换模型",
|
||||
"status.actionChangingEffort": "切换推理力度",
|
||||
"status.actionChangingTokenMode": "切换省 token 模式",
|
||||
"status.actionRebuildingSettings": "刷新设置",
|
||||
"status.actionSwitchingSessions": "切换会话",
|
||||
"status.actionSwitchingTabs": "切换标签页",
|
||||
"status.actionAutosave": "自动保存",
|
||||
"status.actionCurrentSession": "保存当前会话",
|
||||
"status.effort": "effort {level}",
|
||||
"status.effortTitle": "推理力度",
|
||||
"status.effortAutoTitle": "推理力度:auto(模型默认:{def})",
|
||||
@@ -644,6 +677,13 @@ export const zh: Record<DictKey, string> = {
|
||||
"approval.sendRevision": "发送修改意见",
|
||||
"approval.exitPlan": "退出计划",
|
||||
"approval.toolPending": "允许工具调用",
|
||||
"approval.modeSwitchPendingHint": "审批档位已放宽——本次挂起的请求仍需你确认。",
|
||||
"approval.toolLabelExitPlan": "计划确认",
|
||||
"approval.allowOnceDesc": "仅允许本次调用,下次仍会询问。",
|
||||
"approval.allowRuleSessionDesc": "本会话内匹配的调用不再询问,重启后失效。",
|
||||
"approval.allowRulePersistentDesc": "保存为持久允许规则,之后的会话也不再询问。",
|
||||
"approval.denyDesc": "拒绝本次调用,模型会收到拒绝结果并继续。",
|
||||
"approval.allowSandboxEscapeSessionDesc": "本会话后续命令改在真实环境运行,跳过沙箱。",
|
||||
"approval.details": "详情",
|
||||
"approval.hideDetails": "收起",
|
||||
"approval.deny": "拒绝",
|
||||
@@ -658,6 +698,29 @@ export const zh: Record<DictKey, string> = {
|
||||
"approval.allowRulePersistent": "总是允许(保存)",
|
||||
"approval.allowEditsSession": "本会话允许编辑",
|
||||
"approval.allowFilePersistent": "总是允许此文件(保存)",
|
||||
"approval.allowSandboxEscapeSession": "本会话使用真实环境",
|
||||
"approval.toolLabelBash": "bash",
|
||||
"approval.toolLabelEditFile": "编辑文件",
|
||||
"approval.toolLabelWriteFile": "写入文件",
|
||||
"approval.toolLabelMultiEdit": "批量编辑",
|
||||
"approval.toolLabelMoveFile": "移动文件",
|
||||
"approval.toolLabelWebFetch": "读取网页",
|
||||
"approval.toolLabelRunSkill": "运行技能",
|
||||
"approval.toolLabelRemember": "保存记忆",
|
||||
"approval.toolLabelForget": "删除记忆",
|
||||
"approval.toolLabelSandboxEscape": "bash 沙箱降级审批",
|
||||
"approval.toolLabelPlanModeReadOnly": "计划模式只读命令",
|
||||
"approval.memorySaveUpdate": "保存/更新记忆",
|
||||
"approval.memoryBodyLabel": "正文",
|
||||
"approval.memoryArchivePrefix": "归档记忆 ",
|
||||
"approval.planModeMcpTrustSubject": "将 MCP {target} 作为计划/研究只读工具信任",
|
||||
"approval.planModeMcpTrustReason": "这个 MCP 工具声明自己是只读的,但外部只读提示需要你确认后,计划模式才能使用。选择总是允许可为后续计划和只读研究记住这份信任。",
|
||||
"approval.planModeBashTrustSubject": "在计划模式中信任 {prefix} 为只读命令前缀\n命令:{command}",
|
||||
"approval.planModeBashTrustReason": "这条 bash 命令不在 Reasonix 内置只读集合中。只有在确认这个精确前缀用于计划和研究时是只读的,才应批准。自动/YOLO 审批不能回答这个信任提示。",
|
||||
"approval.sandboxEscapeSubjectFallback": "仅本次不进沙箱运行 shell 命令",
|
||||
"approval.sandboxEscapeSubjectPrefix": "仅本次不进沙箱运行:",
|
||||
"approval.sandboxEscapeWrapReason": "Windows 沙箱无法包装这条命令。是否仅本次不进 OS 沙箱运行?这会只对此命令绕过 OS 沙箱。",
|
||||
"approval.sandboxEscapeRuntimeReason": "Windows 沙箱启动这条命令时失败。是否仅本次不进 OS 沙箱运行?这会只对此命令绕过 OS 沙箱。",
|
||||
|
||||
// 提问卡片
|
||||
"ask.title": "需要你决定",
|
||||
@@ -696,6 +759,12 @@ export const zh: Record<DictKey, string> = {
|
||||
"history.clearTrash": "清空回收站",
|
||||
"history.confirmClearTrash": "确认清空回收站",
|
||||
"history.clearTrashMenu": "清空回收站…",
|
||||
"history.clearRecoveryCopies": "清理副本",
|
||||
"history.confirmClearRecoveryCopies": "确认清理副本",
|
||||
"history.clearRecoveryCopiesMenu": "清理恢复副本…",
|
||||
"history.trashRecoveryCopies": "副本移入回收站",
|
||||
"history.confirmTrashRecoveryCopies": "确认移入回收站",
|
||||
"history.recoveryCopiesGroup": "恢复副本",
|
||||
"history.moveToTrash": "移到回收站",
|
||||
"history.confirmMoveToTrash": "确认移入回收站",
|
||||
"history.restore": "恢复",
|
||||
@@ -727,22 +796,19 @@ export const zh: Record<DictKey, string> = {
|
||||
"history.failedOpenProject": "无法打开会话,{name}不存在:{path}",
|
||||
"history.failedOpenSession": "无法打开会话",
|
||||
"history.failedOpenGlobal": "无法打开全局会话",
|
||||
"history.missingWorkspaceRoot": "无法打开会话:缺少工作区路径。",
|
||||
"history.selectSession": "选择一个会话以预览。",
|
||||
"history.trashSessionActions": "回收站会话操作",
|
||||
"history.historySessionActions": "历史会话操作",
|
||||
"history.trashActions": "回收站操作",
|
||||
|
||||
// 会话恢复
|
||||
"recovery.branch": "恢复分支",
|
||||
"recovery.badge": "恢复",
|
||||
"recovery.open": "打开恢复分支",
|
||||
"recovery.toast": "未保存的本地内容已保存为 {title}。",
|
||||
"recovery.failedLease": "恢复分支被另一个运行时占用,未切换过去。",
|
||||
"recovery.failedUnavailable": "无法打开恢复分支,当前标签仍停留在原会话。",
|
||||
"recovery.banner": "当前会话来自保存冲突恢复。继续前建议与原会话对比。",
|
||||
"recovery.reason": "原因:{reason}",
|
||||
"recovery.digest": "摘要:{digest}",
|
||||
"recovery.parent": "父会话:{parent}",
|
||||
"recovery.branch": "冲突副本",
|
||||
"recovery.badge": "副本",
|
||||
"recovery.noticeSavedCopy": "检测到会话在磁盘上发生变化,未保存的本地记录已保留为冲突副本。",
|
||||
"recovery.noticeKeptCurrent": "检测到重复保存冲突,当前冲突副本已继续保存。",
|
||||
"recovery.noticeAdopted": "检测到会话在磁盘上发生变化,已采用较新的记录。",
|
||||
"recovery.noticeAdoptedCovered": "检测到会话在磁盘上发生变化,已采用较新的记录;本地改动已包含其中。",
|
||||
|
||||
// 项目树
|
||||
"projectTree.workspaceTitle": "项目",
|
||||
@@ -758,6 +824,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"projectTree.renameProjectWorkbench": "重命名项目",
|
||||
"projectTree.projectHistory": "查看项目历史",
|
||||
"projectTree.justNow": "刚刚",
|
||||
"projectTree.previously": "之前",
|
||||
"projectTree.running": "输出中",
|
||||
"projectTree.status.thinking": "思考中",
|
||||
"projectTree.status.streaming": "输出中",
|
||||
@@ -1398,7 +1465,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.yoloHint": "Yolo批准会跳过所有工具权限审批(写文件、bash)。deny 规则仍会拦截,ask 问题和计划确认仍会等待你选择。仅运行时生效,重启后失效。",
|
||||
"settings.addRule": "添加 {list} 规则…",
|
||||
"settings.sandboxTitle": "沙箱与工作区",
|
||||
"settings.sandboxBoundaryHint": "沙箱可写根只限制已批准的 Bash 和文件工具能写到哪里;它不会自动批准命令。mvn deploy 这类命令还需要权限规则或审批模式放行。",
|
||||
"settings.sandboxBoundaryHint": "沙箱可写根只限制已批准的 Bash 和文件工具能写到哪里;它不会自动批准命令。若 Bash 沙箱为 enforce 但宿主机没有 OS 沙箱后端,shell 命令会被拒绝;只在可信工作区把 Bash 沙箱切到 off。",
|
||||
"settings.reloadSessionConfig": "应用手动配置修改",
|
||||
"settings.reloadSessionConfigHint": "设置页内修改会自动生效;此按钮用于应用手动编辑 config.toml / reasonix.toml 后的变更。",
|
||||
"settings.shellInterpreter": "Shell 解释器",
|
||||
@@ -1407,7 +1474,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.shellPowershell": "PowerShell(Windows PowerShell)",
|
||||
"settings.shellPwsh": "pwsh(PowerShell 7+)",
|
||||
"settings.bashSandbox": "Bash 沙箱",
|
||||
"settings.bashEnforce": "enforce(隔离 bash)",
|
||||
"settings.bashEnforce": "enforce(隔离 bash;失败即拒绝)",
|
||||
"settings.bashOff": "off(不受限运行)",
|
||||
"settings.allowNetwork": "允许沙箱内 bash 访问网络",
|
||||
"settings.workspaceRoot": "工作区根目录",
|
||||
@@ -1500,6 +1567,9 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.textSizeXLarge": "特大",
|
||||
"settings.textSizeXXLarge": "超大",
|
||||
"settings.displayZoom": "显示缩放(需重启生效)",
|
||||
"settings.displayZoomDecrease": "缩小显示缩放",
|
||||
"settings.displayZoomIncrease": "放大显示缩放",
|
||||
"settings.displayZoomReset": "恢复显示缩放到 100%",
|
||||
"settings.fontFamily": "界面字体",
|
||||
"settings.fontFamilySystem": "系统默认",
|
||||
"settings.fontFamilyYaHei": "微软雅黑",
|
||||
@@ -1534,6 +1604,15 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.modelUsage": "使用模型",
|
||||
"settings.modelUnavailable": "{ref} 不可用。请选择已配置模型,或先添加模型服务接入。",
|
||||
"settings.modelNeedsKey": "{provider} 需要配置 API key 后才能运行该模型。",
|
||||
"settings.errorUnknown": "操作失败。",
|
||||
"settings.errorUnknownModel": "未知模型 {model}。",
|
||||
"settings.errorModelProviderMissing": "模型 {model} 不可用,因为提供商 {provider} 尚未添加。",
|
||||
"settings.errorModelProviderNoKey": "模型 {model} 不可用,因为提供商 {provider} 尚未配置 API Key。",
|
||||
"settings.errorRemoveAccessBusy": "请先停止正在使用 {provider} 的任务,再移除模型服务接入。",
|
||||
"settings.errorDeleteProviderBusy": "请先停止正在使用 {provider} 的任务,再删除模型服务。",
|
||||
"settings.errorSaveBeforeRemoveAccess": "移除模型服务接入前保存当前会话失败:{err}",
|
||||
"settings.errorSaveBeforeDeleteProvider": "删除模型服务前保存当前会话失败:{err}",
|
||||
"settings.errorRemoveProviderNoFallback": "模型服务 {provider} 正被打开的标签页使用,且没有其他可用模型服务可切换。",
|
||||
"settings.customProviders": "自定义供应商",
|
||||
"settings.customProvidersHint": "你自己添加的 OpenAI-compatible 或代理接入,可以编辑、删除并持久化模型列表。",
|
||||
"settings.customProvidersEmpty": "还没有自定义供应商。常用服务优先使用上方内置接入。",
|
||||
@@ -1560,6 +1639,8 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.addProvider.customHint": "适合代理、聚合平台或其它 OpenAI-compatible 服务。",
|
||||
"settings.addProvider.official.deepseek": "DeepSeek 官方",
|
||||
"settings.addProvider.official.deepseekDesc": "官方 API Key 接入,可用 DeepSeek V4 Flash / Pro。",
|
||||
"settings.addProvider.preset.longcatOpenAIDesc": "LongCat 开放平台 OpenAI-compatible 端点,默认 LongCat-2.0。",
|
||||
"settings.addProvider.preset.longcatAnthropicDesc": "LongCat 开放平台 Anthropic-compatible Messages 端点,使用 Bearer 认证。",
|
||||
"settings.addProvider.preset.kimiCnDesc": "Kimi 国内 API,走 Moonshot CN 端点,预置 K2 视觉模型。",
|
||||
"settings.addProvider.preset.kimiGlobalDesc": "Kimi 国际 API,走 Moonshot global 端点,预置 K2 视觉模型。",
|
||||
"settings.addProvider.preset.kimiCodingPlanDesc": "Kimi Coding Plan,走独立 Anthropic-compatible 端点,不归属 CN/Global。",
|
||||
@@ -1644,6 +1725,8 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.providerExtraBodyPlaceholder": "{\n \"enable_thinking\": true\n}",
|
||||
"settings.providerExtraBodyHint": "会合并到聊天请求体顶层 JSON。model、messages、tools、stream、thinking 等核心字段仍由 Reasonix 控制。",
|
||||
"settings.providerExtraBodyError": "请输入不含 null 值的 JSON 对象,例如 { \"enable_thinking\": true }。",
|
||||
"settings.providerExtraBodyNull": "{path} 不能包含 null。",
|
||||
"settings.providerExtraBodyObjectRequired": "额外请求体必须是 JSON 对象。",
|
||||
"settings.providerAuthHeader": "使用 Authorization: Bearer 发送密钥",
|
||||
"settings.providerAuthHeaderHint": "适用于 MiniMax Global、Vercel AI Gateway 等不使用 x-api-key 的 Anthropic-compatible 网关。",
|
||||
"settings.fetchingModels": "刷新中…",
|
||||
@@ -1726,6 +1809,10 @@ export const zh: Record<DictKey, string> = {
|
||||
"settings.hooksJsonApplied": "JSON 已校验,保存后生效。",
|
||||
"settings.hooksJsonFormatted": "JSON 已格式化,保存后生效。",
|
||||
"settings.hooksJsonInvalid": "JSON 无效:{error}",
|
||||
"settings.hooksJsonExpectedObjectArray": "应为对象或数组。",
|
||||
"settings.hooksJsonItemObject": "Hook 项必须是对象。",
|
||||
"settings.hooksJsonUnknownEvent": "未知 Hook 事件 {event}。",
|
||||
"settings.hooksJsonEventItemObject": "Hook {event} 的项目必须是对象。",
|
||||
"settings.hooksJsonClipboardEmpty": "剪贴板为空",
|
||||
"settings.hooksJsonClipboardUnavailable": "无法访问剪贴板;可手动复制文本框内容。",
|
||||
"settings.hooksJsonPasteFailed": "粘贴失败:{error}",
|
||||
@@ -1800,6 +1887,34 @@ export const zh: Record<DictKey, string> = {
|
||||
"transcript.processedDuration": "已处理 {s}s",
|
||||
"notice.info": "提示",
|
||||
"notice.warning": "警告",
|
||||
"notice.details": "详情",
|
||||
"notice.finalReadiness": "任务状态还需要确认,已要求助手完成或说明阻塞原因。",
|
||||
"notice.emptyFinal": "没有生成可见回复,已要求助手重新作答。",
|
||||
"notice.executorHandoff": "助手尚未执行必要操作,已要求它使用相应工具。",
|
||||
"notice.toolBudget": "工具调用轮数已达上限,已要求助手总结当前进展。",
|
||||
"notice.loopGuard": "助手卡在重复受阻操作上,已要求它换一种处理方式。",
|
||||
"notice.contextLarge": "上下文正在变大,暂时保留缓存,等需要时再清理。",
|
||||
"notice.contextCleanupSkipped": "本次暂未清理上下文。",
|
||||
"notice.contextCleanupPaused": "自动上下文清理已暂停:当前上下文窗口太小。",
|
||||
"notice.compactionNoSummary": "上下文已压缩,但未生成摘要。",
|
||||
"notice.autoPlanEnabled": "已为这个多步骤任务开启规划模式。",
|
||||
"notice.autoPlanRequested": "任务判断建议先制定计划。",
|
||||
"notice.autoPlanFallback": "规划判断不确定,已使用备用规则。",
|
||||
"notice.goalNotReady": "目标暂未达到完成条件,继续处理剩余工作。",
|
||||
"notice.goalUnfinished": "目标还有未完成的任务状态,继续处理。",
|
||||
"notice.autoresearchStatusFailed": "AutoResearch 状态更新失败。",
|
||||
"notice.autoresearchBlocked": "AutoResearch 任务已标记为阻塞。",
|
||||
"notice.backgroundJobFailed": "后台 {kind} 需要处理。",
|
||||
"notice.jobArtifactMigrationFailed": "任务产物迁移失败。",
|
||||
"notice.jobTeardownTimeout": "后台任务清理超时。",
|
||||
"notice.planModeToolSettingsIgnored": "部分规划模式工具设置已被忽略。",
|
||||
"notice.planModeCommandSettingsIgnored": "部分规划模式命令设置已被忽略。",
|
||||
"notice.configMigrationIncomplete": "配置迁移未完成。",
|
||||
"notice.modelMissingApiKey": "当前模型缺少 API key。",
|
||||
"notice.mcpServerFailed": "一个 MCP 服务启动失败。",
|
||||
"notice.mcpServersFailed": "部分 MCP 服务启动失败,可运行 /mcp 查看详情。",
|
||||
"notice.guardianModelMissing": "Guardian 已停用:未找到对应模型。",
|
||||
"notice.guardianStartFailed": "Guardian 启动失败,已停用。",
|
||||
"questionNav.label": "问题导航",
|
||||
"questionNav.progress": "问题 {current} / {total}",
|
||||
"questionNav.jump": "跳转到问题 {n}",
|
||||
@@ -1815,6 +1930,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"rewind.disabledNoBoundary": "这条消息的会话边界不可用",
|
||||
"rewind.disabledNoCode": "这条消息之后没有捕获到代码改动",
|
||||
"rewind.disabledNoEarlier": "这条消息之前没有可总结内容",
|
||||
"rewind.disabledNoLater": "这条消息之后没有可总结内容",
|
||||
"rewind.filesChanged": "{count} 个文件",
|
||||
"rewind.turnFiles": "本轮 {count} 个",
|
||||
"rewind.both": "回滚代码和对话",
|
||||
@@ -1835,6 +1951,7 @@ export const zh: Record<DictKey, string> = {
|
||||
"rewind.busyConversation": "正在回滚对话…",
|
||||
"rewind.busyCode": "正在回滚代码…",
|
||||
"rewind.busyBoth": "正在回滚代码和对话…",
|
||||
"rewind.failed": "回滚失败",
|
||||
"undoRewind.turns": "已回滚 {n} 轮对话",
|
||||
"undoRewind.filesRestored": "还原了 {n} 个文件",
|
||||
"undoRewind.filesRemoved": "移除了 {n} 个文件",
|
||||
@@ -1845,8 +1962,12 @@ export const zh: Record<DictKey, string> = {
|
||||
// 工具卡片摘要
|
||||
"tool.stepOne": "{n} 步",
|
||||
"tool.stepOther": "{n} 步",
|
||||
"tool.error": "错误",
|
||||
"tool.errorReceiptMismatch": "证据命令没有匹配的成功执行记录",
|
||||
"tool.truncated": "输出已截断",
|
||||
"tool.showAllLines": "显示全部 {n} 行",
|
||||
"tool.showErrorDetails": "显示错误详情",
|
||||
"tool.hideErrorDetails": "隐藏错误详情",
|
||||
"tool.readCount": "已读 {n} 个文件",
|
||||
"tool.searchCount": "搜索 {n} 个文件",
|
||||
"tool.otherReadCount": "{n} 个只读调用",
|
||||
@@ -2017,6 +2138,9 @@ export const zh: Record<DictKey, string> = {
|
||||
"mock.topicAi": "AI 工程化最佳实践",
|
||||
"mock.topicLab": "临时探索与实验",
|
||||
"mock.approvalSubject": "npm run build\n\n需要运行构建命令来验证前端产物和样式打包是否正常。",
|
||||
"mock.sandboxEscapeReasoning": "Windows 沙箱 helper 失败,需要确认是否仅本次不进沙箱重跑。",
|
||||
"mock.sandboxEscapeSubject": "仅本次不进沙箱运行:go test ./...",
|
||||
"mock.sandboxEscapeReason": "Windows 沙箱启动这条命令时失败。是否仅本次不进 OS 沙箱运行?这会只对此命令绕过 OS 沙箱。",
|
||||
"mock.askQ1Header": "处理方向",
|
||||
"mock.askQ1Prompt": "git pull 的冲突你想怎么处理?",
|
||||
"mock.askQ1Opt1Label": "git stash 后 pull",
|
||||
|
||||
+296
-227
@@ -2665,27 +2665,6 @@ a[href] {
|
||||
color: var(--ok);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.banner--recovery {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: color-mix(in srgb, var(--warn) 9%, var(--bg-elev));
|
||||
color: color-mix(in srgb, var(--warn) 72%, var(--fg));
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--warn) 22%, var(--border-soft));
|
||||
}
|
||||
.banner__badge {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 18px;
|
||||
padding: 0 6px;
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--warn) 13%, transparent);
|
||||
color: color-mix(in srgb, var(--warn) 84%, var(--fg));
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
}
|
||||
.banner--update {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3310,6 +3289,20 @@ a[href] {
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
.notice-line__details {
|
||||
margin-top: 4px;
|
||||
color: var(--fg-faint);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.notice-line__details summary {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.notice-line__details div {
|
||||
margin-top: 4px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ── ReadOnly batch (parent fold for read/search tools) ────────────── */
|
||||
.readonly-batch {
|
||||
@@ -5207,11 +5200,22 @@ body > .mermaid-diagram--fullscreen {
|
||||
min-width: 0;
|
||||
}
|
||||
.tool__nested-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 34px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
color: color-mix(in srgb, var(--fg-dim) 86%, var(--fg));
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tool__nested-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: inherit;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.tool__status-icon {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
@@ -5272,6 +5276,46 @@ body > .mermaid-diagram--fullscreen {
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.tool__err--compact {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0 0 6px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.tool__err-summary {
|
||||
min-width: 0;
|
||||
}
|
||||
.tool__err-toggle {
|
||||
justify-self: start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.82;
|
||||
}
|
||||
.tool__err-toggle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.tool__err-toggle-icon {
|
||||
flex: 0 0 auto;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
.tool__err-toggle-icon--open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.tool__err-details {
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--err) 18%, transparent);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.tag {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
@@ -5420,13 +5464,59 @@ body > .mermaid-diagram--fullscreen {
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
.composer-card--resized {
|
||||
height: var(--composer-height);
|
||||
/* --composer-height is always the user's logical height; the run strip's
|
||||
reservation lives in this calc so the live resize drag (which writes the
|
||||
raw logical height) and the React render path can never disagree. */
|
||||
height: calc(var(--composer-height) + var(--composer-run-strip-reserved, 0px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.composer-card:focus-within {
|
||||
border-color: var(--fg-faint);
|
||||
}
|
||||
/* Run strip: an in-flow bar at the top of the card. For user-resized cards
|
||||
(fixed --composer-height), the JS adds the strip's reserved height so the
|
||||
meta row stays fully visible. For auto-height cards the card grows
|
||||
naturally. */
|
||||
.composer-run-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 24px;
|
||||
padding: 4px 14px 0;
|
||||
color: var(--accent);
|
||||
font-family: var(--sans);
|
||||
font-size: 12px;
|
||||
}
|
||||
.composer-card--waiting .composer-run-strip {
|
||||
/* inherited from the card border tint; no extra rule needed */
|
||||
}
|
||||
.composer-run-strip__dot {
|
||||
flex: 0 0 auto;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.composer-run-strip__text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.composer-run-strip--waiting {
|
||||
color: color-mix(in srgb, var(--warn) 74%, var(--fg));
|
||||
}
|
||||
.composer-run-strip--waiting .composer-run-strip__dot {
|
||||
animation: none;
|
||||
}
|
||||
/* Attention handoff: while a prompt card is waiting on the user, the card
|
||||
drops the accent running tint and takes a calm warn edge instead. */
|
||||
.composer-card--waiting {
|
||||
border-color: color-mix(in srgb, var(--warn) 30%, var(--border));
|
||||
}
|
||||
.composer-resize-handle {
|
||||
--wails-draggable: no-drag;
|
||||
appearance: none;
|
||||
@@ -5680,6 +5770,22 @@ body > .mermaid-diagram--fullscreen {
|
||||
cursor: default;
|
||||
transform: none;
|
||||
}
|
||||
.composer__btn--stop {
|
||||
margin-right: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--err) 42%, transparent);
|
||||
background: color-mix(in srgb, var(--err) 14%, var(--bg-elev-2));
|
||||
color: var(--err);
|
||||
}
|
||||
.composer__btn--stop:hover,
|
||||
.composer__btn--stop:focus-visible {
|
||||
color: var(--fg);
|
||||
background: color-mix(in srgb, var(--err) 38%, var(--bg-elev-2));
|
||||
border-color: color-mix(in srgb, var(--err) 70%, var(--border));
|
||||
}
|
||||
.composer__btn--stop:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
.composer-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -7197,6 +7303,16 @@ body > .mermaid-diagram--fullscreen {
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.approval-mode-hint {
|
||||
margin: 0;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--warning, #f59e0b) 30%, var(--border-soft));
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--warning, #f59e0b) 8%, var(--bg-soft));
|
||||
color: color-mix(in srgb, var(--warning, #f59e0b) 62%, var(--fg));
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.approval-reason {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
@@ -7378,14 +7494,14 @@ body > .mermaid-diagram--fullscreen {
|
||||
}
|
||||
.prompt-shelf--ask .prompt-shelf__actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
max-height: 154px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
max-height: min(220px, 30vh);
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
.prompt-shelf--ask .prompt-action {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
padding: 5px 8px;
|
||||
@@ -7401,6 +7517,69 @@ body > .mermaid-diagram--fullscreen {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.prompt-shelf--ask .prompt-shelf__quick-actions {
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
padding-top: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.prompt-shelf--ask .prompt-shelf__quick-actions > .prompt-action {
|
||||
width: auto;
|
||||
min-height: 28px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.prompt-shelf__footnote {
|
||||
min-width: 0;
|
||||
}
|
||||
.ask-shelf__detail {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 0 2px;
|
||||
color: var(--fg-dim);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.ask-shelf__detail-label {
|
||||
flex: 0 0 auto;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fg);
|
||||
font-weight: 620;
|
||||
}
|
||||
.ask-shelf__detail-text {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* Tool-approval consequence preview: same quick-pick pattern as the ask
|
||||
detail row — pill buttons stay compact, the row spells out what the
|
||||
hovered/selected action will do. */
|
||||
.approval-consequence {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 0 2px;
|
||||
color: var(--fg-dim);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.approval-consequence__label {
|
||||
flex: 0 0 auto;
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fg);
|
||||
font-weight: 620;
|
||||
}
|
||||
.approval-consequence__text {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.prompt-shelf--ask .ask-shelf__custom-row {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -14257,10 +14436,18 @@ body > .mermaid-diagram--fullscreen {
|
||||
.zoom-slider-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.zoom-slider__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.zoom-slider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -14275,14 +14462,67 @@ body > .mermaid-diagram--fullscreen {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 百分比值 — 居中在轨道上方 */
|
||||
/* 百分比值,与右侧步进控件同排显示 */
|
||||
.zoom-slider__value {
|
||||
font-size: var(--font-control-small);
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
min-width: 42px;
|
||||
}
|
||||
|
||||
.zoom-stepper {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.zoom-stepper__btn,
|
||||
.zoom-stepper__reset {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
height: 25px;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--fg-dim);
|
||||
font: inherit;
|
||||
font-size: var(--font-control-small);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.zoom-stepper__btn {
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.zoom-stepper__reset {
|
||||
min-width: 58px;
|
||||
padding: 0 8px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.zoom-stepper__btn:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.zoom-stepper__btn:not(:disabled):hover,
|
||||
.zoom-stepper__reset:not(:disabled):hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.zoom-stepper__btn:disabled,
|
||||
.zoom-stepper__reset:disabled {
|
||||
color: var(--fg-faint);
|
||||
opacity: .55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 让 label 在 settings-field 网格中靠下对齐 */
|
||||
@@ -14329,40 +14569,17 @@ body > .mermaid-diagram--fullscreen {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 28px;
|
||||
height: 18px;
|
||||
width: 26px;
|
||||
height: 16px;
|
||||
pointer-events: none;
|
||||
z-index: var(--z-local-raised);
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
background: #fff;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.slider-thumb__left {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 9px;
|
||||
height: 18px;
|
||||
background: #fff;
|
||||
border-radius: 9px 0 0 9px;
|
||||
}
|
||||
.slider-thumb__mid {
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
top: 0;
|
||||
width: 10px;
|
||||
height: 18px;
|
||||
background: #fff;
|
||||
}
|
||||
.slider-thumb__right {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 9px;
|
||||
height: 18px;
|
||||
background: #fff;
|
||||
border-radius: 0 9px 9px 0;
|
||||
}
|
||||
/* slider-thumb is now a single element; child segments were removed. */
|
||||
|
||||
/* 透明 input range — 事件捕获 */
|
||||
.slider-track input[type="range"] {
|
||||
@@ -17242,21 +17459,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
/* ── composer run status ──────────────────────────────────────────────────── */
|
||||
.composer-toolbar {
|
||||
--wails-draggable: no-drag;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
max-width: var(--maxw);
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
|
||||
.composer-toolbar--status-only {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.composer-guidance-shelf {
|
||||
--wails-draggable: no-drag;
|
||||
display: grid;
|
||||
@@ -17413,79 +17615,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
}
|
||||
}
|
||||
|
||||
.composer-runstatus {
|
||||
--wails-draggable: no-drag;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
height: 34px;
|
||||
padding: 0 5px 0 11px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border));
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--accent) 9%, var(--bg-elev));
|
||||
color: var(--accent);
|
||||
font-family: var(--sans);
|
||||
font-size: 12px;
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 6%, transparent);
|
||||
}
|
||||
|
||||
.composer-runstatus__dot {
|
||||
flex: 0 0 auto;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.composer-runstatus__text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.composer-runstatus__stop {
|
||||
--wails-draggable: no-drag;
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
height: 26px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid color-mix(in srgb, var(--err) 42%, transparent);
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--err) 14%, var(--bg-elev-2));
|
||||
color: var(--err);
|
||||
font: inherit;
|
||||
font-weight: 620;
|
||||
}
|
||||
|
||||
.composer-runstatus__stop:hover,
|
||||
.composer-runstatus__stop:focus-visible {
|
||||
color: var(--fg);
|
||||
background: color-mix(in srgb, var(--err) 38%, var(--bg-elev-2));
|
||||
border-color: color-mix(in srgb, var(--err) 70%, var(--border));
|
||||
}
|
||||
.composer-runstatus__stop:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
color: var(--fg-faint);
|
||||
background: var(--button-bg);
|
||||
border-color: var(--border-soft);
|
||||
}
|
||||
.composer-runstatus__stop:disabled:hover,
|
||||
.composer-runstatus__stop:disabled:focus-visible {
|
||||
color: var(--fg-faint);
|
||||
background: var(--button-bg);
|
||||
border-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.composer-context {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -20475,26 +20604,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
color: color-mix(in srgb, var(--err) 84%, var(--fg));
|
||||
}
|
||||
|
||||
.project-tree__topic--recovered {
|
||||
color: color-mix(in srgb, var(--warn) 18%, var(--fg-dim));
|
||||
}
|
||||
|
||||
.project-tree__topic-recovery {
|
||||
flex: 0 0 auto;
|
||||
height: 17px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 5px;
|
||||
border-radius: 5px;
|
||||
background: color-mix(in srgb, var(--warn) 12%, transparent);
|
||||
color: color-mix(in srgb, var(--warn) 82%, var(--fg));
|
||||
font-size: 10.5px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-tree__topic-meta {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
@@ -22738,10 +22847,6 @@ body > .mermaid-diagram--fullscreen {
|
||||
padding: 5px 10px 10px 30px;
|
||||
}
|
||||
|
||||
.composer-toolbar {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.composer-modebar {
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--bg-elev-2) 72%, transparent);
|
||||
@@ -26816,41 +26921,29 @@ body > .mermaid-diagram--fullscreen {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.app--creation .composer-toolbar,
|
||||
:root[data-theme-style] .app--creation .composer-toolbar {
|
||||
max-width: 880px;
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
|
||||
.app--creation .composer-toolbar--status-only,
|
||||
:root[data-theme-style] .app--creation .composer-toolbar--status-only {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app--creation .composer-runstatus,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus {
|
||||
max-width: min(100%, 340px);
|
||||
height: 28px;
|
||||
gap: 7px;
|
||||
padding: 0 4px 0 10px;
|
||||
border-color: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--bg-elev) 24%, transparent);
|
||||
.app--creation .composer-run-strip,
|
||||
:root[data-theme-style] .app--creation .composer-run-strip {
|
||||
color: color-mix(in srgb, var(--accent) 88%, var(--fg-muted));
|
||||
font-size: 11.5px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app--creation .composer-runstatus__dot,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus__dot {
|
||||
.app--creation .composer-run-strip__dot,
|
||||
:root[data-theme-style] .app--creation .composer-run-strip__dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 48%, transparent);
|
||||
animation: creation-runstatus-dot-breathe 1.65s ease-in-out infinite;
|
||||
animation: creation-run-strip-dot-breathe 1.65s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes creation-runstatus-dot-breathe {
|
||||
.app--creation .composer-run-strip--waiting .composer-run-strip__dot,
|
||||
:root[data-theme-style] .app--creation .composer-run-strip--waiting .composer-run-strip__dot {
|
||||
animation: none;
|
||||
background: currentColor;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@keyframes creation-run-strip-dot-breathe {
|
||||
0%, 100% {
|
||||
opacity: 0.42;
|
||||
box-shadow: 0 0 4px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
@@ -26862,46 +26955,22 @@ body > .mermaid-diagram--fullscreen {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app--creation .composer-runstatus__dot,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus__dot {
|
||||
.app--creation .composer-run-strip__dot,
|
||||
:root[data-theme-style] .app--creation .composer-run-strip__dot {
|
||||
animation: none;
|
||||
opacity: 0.78;
|
||||
}
|
||||
}
|
||||
|
||||
.app--creation .composer-runstatus__text,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus__text {
|
||||
.app--creation .composer-run-strip__text,
|
||||
:root[data-theme-style] .app--creation .composer-run-strip__text {
|
||||
color: color-mix(in srgb, var(--fg-muted) 72%, var(--accent));
|
||||
font-weight: 540;
|
||||
}
|
||||
|
||||
.app--creation .composer-runstatus__stop,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus__stop {
|
||||
height: 22px;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
border-color: color-mix(in srgb, var(--err) 24%, transparent);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: color-mix(in srgb, var(--err) 76%, var(--fg-muted));
|
||||
font-size: 11.5px;
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
.app--creation .composer-runstatus__stop svg,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus__stop svg {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.app--creation .composer-runstatus__stop:hover,
|
||||
.app--creation .composer-runstatus__stop:focus-visible,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus__stop:hover,
|
||||
:root[data-theme-style] .app--creation .composer-runstatus__stop:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--err) 42%, transparent);
|
||||
background: color-mix(in srgb, var(--err) 9%, transparent);
|
||||
color: color-mix(in srgb, var(--err) 90%, var(--fg));
|
||||
.app--creation .composer-run-strip--waiting .composer-run-strip__text,
|
||||
:root[data-theme-style] .app--creation .composer-run-strip--waiting .composer-run-strip__text {
|
||||
color: color-mix(in srgb, var(--warn) 64%, var(--fg-muted));
|
||||
}
|
||||
|
||||
.app--creation .composer-card,
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
mainThreadHeartbeatInterval = time.Second
|
||||
mainThreadHangThreshold = 12 * time.Second
|
||||
mainThreadHangCheckInterval = 2 * time.Second
|
||||
mainThreadSleepSkip = 30 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
mainThreadClockBase = time.Now()
|
||||
mainThreadLastHeartbeatElapsed atomic.Int64
|
||||
mainThreadLastHeartbeatWall atomic.Int64
|
||||
mainThreadHangReported atomic.Bool
|
||||
)
|
||||
|
||||
func recordMainThreadHeartbeat(t time.Time) {
|
||||
elapsed := t.Sub(mainThreadClockBase)
|
||||
if elapsed < 0 {
|
||||
elapsed = 0
|
||||
}
|
||||
mainThreadLastHeartbeatElapsed.Store(int64(elapsed))
|
||||
mainThreadLastHeartbeatWall.Store(t.UnixNano())
|
||||
}
|
||||
|
||||
func mainThreadHeartbeatAge(now time.Time) (time.Duration, time.Time, bool) {
|
||||
lastElapsed := time.Duration(mainThreadLastHeartbeatElapsed.Load())
|
||||
lastWall := mainThreadLastHeartbeatWall.Load()
|
||||
if lastWall <= 0 {
|
||||
return 0, time.Time{}, false
|
||||
}
|
||||
age := now.Sub(mainThreadClockBase) - lastElapsed
|
||||
if age < 0 {
|
||||
age = 0
|
||||
}
|
||||
return age, time.Unix(0, lastWall), true
|
||||
}
|
||||
|
||||
func (a *App) startMainThreadWatchdog() {
|
||||
if !mainThreadWatchdogSupported() {
|
||||
return
|
||||
}
|
||||
a.hangWatchdogMu.Lock()
|
||||
if a.hangWatchdogCancel != nil {
|
||||
a.hangWatchdogMu.Unlock()
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
a.hangWatchdogCancel = cancel
|
||||
mainThreadHangReported.Store(false)
|
||||
recordMainThreadHeartbeat(time.Now())
|
||||
startNativeMainThreadHeartbeat(uint64(mainThreadHeartbeatInterval / time.Millisecond))
|
||||
a.hangWatchdogMu.Unlock()
|
||||
|
||||
a.goSafe("mainThreadHangWatchdog", func() {
|
||||
a.watchMainThreadHeartbeat(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) stopMainThreadWatchdog() {
|
||||
if !mainThreadWatchdogSupported() {
|
||||
return
|
||||
}
|
||||
a.hangWatchdogMu.Lock()
|
||||
cancel := a.hangWatchdogCancel
|
||||
a.hangWatchdogCancel = nil
|
||||
a.hangWatchdogMu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
stopNativeMainThreadHeartbeat()
|
||||
}
|
||||
|
||||
func (a *App) watchMainThreadHeartbeat(ctx context.Context) {
|
||||
ticker := time.NewTicker(mainThreadHangCheckInterval)
|
||||
defer ticker.Stop()
|
||||
lastCheck := time.Now()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
if now.Sub(lastCheck) > mainThreadSleepSkip {
|
||||
lastCheck = now
|
||||
continue
|
||||
}
|
||||
lastCheck = now
|
||||
age, last, ok := mainThreadHeartbeatAge(now)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if age < mainThreadHangThreshold {
|
||||
continue
|
||||
}
|
||||
if mainThreadHangReported.CompareAndSwap(false, true) {
|
||||
a.recordMainThreadHang(age, last, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) recordMainThreadHang(age time.Duration, lastHeartbeat, observedAt time.Time) {
|
||||
report := mainThreadHangReport(age, lastHeartbeat, observedAt)
|
||||
wrote := writePendingReport(report, false)
|
||||
if m := a.metrics.Load(); m != nil {
|
||||
m.inc("desktop_hang", "main_thread")
|
||||
m.inc("desktop_hang_age", hangAgeBucket(age))
|
||||
m.persist()
|
||||
}
|
||||
slog.Warn("desktop: mac main thread heartbeat stalled",
|
||||
"age", age.Round(time.Millisecond).String(),
|
||||
"lastHeartbeat", lastHeartbeat.Format(time.RFC3339),
|
||||
"pendingReport", wrote,
|
||||
)
|
||||
}
|
||||
|
||||
func mainThreadHangReport(age time.Duration, lastHeartbeat, observedAt time.Time) crashReport {
|
||||
age = age.Round(time.Second)
|
||||
message := fmt.Sprintf(`[mac.main_thread.hang]
|
||||
|
||||
Reasonix detected that the macOS main-thread heartbeat stopped for %s.
|
||||
|
||||
--- watchdog context ---
|
||||
last heartbeat: %s
|
||||
observed at: %s
|
||||
threshold: %s
|
||||
bucket: %s
|
||||
|
||||
--- native runtime context ---
|
||||
%s`,
|
||||
age,
|
||||
lastHeartbeat.UTC().Format(time.RFC3339),
|
||||
observedAt.UTC().Format(time.RFC3339),
|
||||
mainThreadHangThreshold,
|
||||
hangAgeBucket(age),
|
||||
nativeResourceContext(),
|
||||
)
|
||||
report := baseCrashReport("performance")
|
||||
report.SchemaVersion = 2
|
||||
report.Source = "native.watchdog"
|
||||
report.Label = "mac.main_thread.hang"
|
||||
report.ErrorType = "MacMainThreadHang"
|
||||
report.ErrorMessage = sanitizeCrashText("macOS main thread heartbeat stopped; AppKit/Wails run loop may be blocked.", maxCrashFieldBytes)
|
||||
report.TopFrame = "mac.main_thread.heartbeat"
|
||||
report.OccurredAt = observedAt.UTC().Format(time.RFC3339)
|
||||
report.Message = sanitizeCrashText(message, maxCrashDetailBytes)
|
||||
return report
|
||||
}
|
||||
|
||||
func hangAgeBucket(age time.Duration) string {
|
||||
seconds := age.Seconds()
|
||||
switch {
|
||||
case seconds < 15:
|
||||
return "s_10_15"
|
||||
case seconds < 30:
|
||||
return "s_15_30"
|
||||
case seconds < 60:
|
||||
return "s_30_60"
|
||||
case seconds < 300:
|
||||
return "m_1_5"
|
||||
default:
|
||||
return "m_5_plus"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
/*
|
||||
#include <stdint.h>
|
||||
#include <dispatch/dispatch.h>
|
||||
|
||||
extern void reasonixDesktopMainHeartbeat(void);
|
||||
|
||||
static dispatch_source_t reasonix_main_heartbeat_timer;
|
||||
|
||||
static void reasonix_main_heartbeat_handler(void *ctx) {
|
||||
reasonixDesktopMainHeartbeat();
|
||||
}
|
||||
|
||||
static void reasonix_start_main_heartbeat(uint64_t interval_ms) {
|
||||
if (reasonix_main_heartbeat_timer != NULL) {
|
||||
return;
|
||||
}
|
||||
reasonix_main_heartbeat_timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue());
|
||||
dispatch_set_context(reasonix_main_heartbeat_timer, NULL);
|
||||
dispatch_source_set_event_handler_f(reasonix_main_heartbeat_timer, reasonix_main_heartbeat_handler);
|
||||
dispatch_source_set_timer(reasonix_main_heartbeat_timer, dispatch_time(DISPATCH_TIME_NOW, 0), interval_ms * NSEC_PER_MSEC, 100 * NSEC_PER_MSEC);
|
||||
dispatch_resume(reasonix_main_heartbeat_timer);
|
||||
}
|
||||
|
||||
static void reasonix_stop_main_heartbeat(void) {
|
||||
if (reasonix_main_heartbeat_timer == NULL) {
|
||||
return;
|
||||
}
|
||||
dispatch_source_cancel(reasonix_main_heartbeat_timer);
|
||||
reasonix_main_heartbeat_timer = NULL;
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import "time"
|
||||
|
||||
func mainThreadWatchdogSupported() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func startNativeMainThreadHeartbeat(intervalMS uint64) {
|
||||
C.reasonix_start_main_heartbeat(C.uint64_t(intervalMS))
|
||||
}
|
||||
|
||||
func stopNativeMainThreadHeartbeat() {
|
||||
C.reasonix_stop_main_heartbeat()
|
||||
}
|
||||
|
||||
//export reasonixDesktopMainHeartbeat
|
||||
func reasonixDesktopMainHeartbeat() {
|
||||
recordMainThreadHeartbeat(time.Now())
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build !darwin
|
||||
|
||||
package main
|
||||
|
||||
func mainThreadWatchdogSupported() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func startNativeMainThreadHeartbeat(uint64) {}
|
||||
|
||||
func stopNativeMainThreadHeartbeat() {}
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
func TestHangAgeBucket(t *testing.T) {
|
||||
cases := []struct {
|
||||
age time.Duration
|
||||
want string
|
||||
}{
|
||||
{12 * time.Second, "s_10_15"},
|
||||
{16 * time.Second, "s_15_30"},
|
||||
{45 * time.Second, "s_30_60"},
|
||||
{2 * time.Minute, "m_1_5"},
|
||||
{10 * time.Minute, "m_5_plus"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := hangAgeBucket(c.age); got != c.want {
|
||||
t.Errorf("hangAgeBucket(%s) = %q, want %q", c.age, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainThreadHangReportIsStructuredPerformanceReport(t *testing.T) {
|
||||
last := time.Date(2026, 7, 4, 9, 19, 11, 0, time.UTC)
|
||||
observed := last.Add(16 * time.Second)
|
||||
|
||||
r := mainThreadHangReport(16*time.Second, last, observed)
|
||||
|
||||
if r.Kind != "performance" || r.Source != "native.watchdog" || r.Label != "mac.main_thread.hang" {
|
||||
t.Fatalf("unexpected report identity: %+v", r)
|
||||
}
|
||||
if r.SchemaVersion != 2 || r.ErrorType != "MacMainThreadHang" || r.TopFrame == "" || r.OccurredAt == "" {
|
||||
t.Fatalf("structured fields missing: %+v", r)
|
||||
}
|
||||
for _, want := range []string{"Reasonix detected", "last heartbeat:", "bucket: s_15_30", "goroutines:"} {
|
||||
if !strings.Contains(r.Message, want) {
|
||||
t.Fatalf("hang report message missing %q:\n%s", want, r.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainThreadHeartbeatAgeIgnoresWallClockJump(t *testing.T) {
|
||||
oldBase := mainThreadClockBase
|
||||
oldElapsed := mainThreadLastHeartbeatElapsed.Load()
|
||||
oldWall := mainThreadLastHeartbeatWall.Load()
|
||||
t.Cleanup(func() {
|
||||
mainThreadClockBase = oldBase
|
||||
mainThreadLastHeartbeatElapsed.Store(oldElapsed)
|
||||
mainThreadLastHeartbeatWall.Store(oldWall)
|
||||
})
|
||||
|
||||
base := time.Now()
|
||||
mainThreadClockBase = base
|
||||
mainThreadLastHeartbeatElapsed.Store(int64(time.Second))
|
||||
mainThreadLastHeartbeatWall.Store(base.Add(-time.Hour).UnixNano())
|
||||
|
||||
age, _, ok := mainThreadHeartbeatAge(base.Add(2 * time.Second))
|
||||
if !ok {
|
||||
t.Fatal("expected heartbeat age")
|
||||
}
|
||||
if age != time.Second {
|
||||
t.Fatalf("age = %s, want monotonic elapsed 1s despite wall-clock jump", age)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordMainThreadHangWritesPendingReportAndMetrics(t *testing.T) {
|
||||
t.Cleanup(func() {
|
||||
os.Remove(pendingCrashPath())
|
||||
os.Remove(filepath.Join(config.MemoryUserDir(), metricsPendingFile))
|
||||
})
|
||||
app := NewApp()
|
||||
app.metrics.Store(newMetricsAggregator(config.MemoryUserDir()))
|
||||
|
||||
last := time.Now().Add(-20 * time.Second)
|
||||
app.recordMainThreadHang(20*time.Second, last, time.Now())
|
||||
|
||||
r, ok := readPending(t)
|
||||
if !ok {
|
||||
t.Fatal("expected pending hang report")
|
||||
}
|
||||
if r.Kind != "performance" || r.Source != "native.watchdog" || r.Label != "mac.main_thread.hang" {
|
||||
t.Fatalf("pending report = %+v", r)
|
||||
}
|
||||
c := readCounters(filepath.Join(config.MemoryUserDir(), metricsPendingFile))
|
||||
if got := c["desktop_hang"]["main_thread"]; got != 1 {
|
||||
t.Fatalf("desktop_hang/main_thread = %d, want 1", got)
|
||||
}
|
||||
if got := c["desktop_hang_age"]["s_15_30"]; got != 1 {
|
||||
t.Fatalf("desktop_hang_age/s_15_30 = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
+46
-4
@@ -57,6 +57,7 @@ type heartbeatConfig struct {
|
||||
type HeartbeatEngine struct {
|
||||
mu sync.Mutex
|
||||
tasks []HeartbeatTask
|
||||
cfgMod time.Time // config-file mtime as of the engine's last read/write (external-edit probe)
|
||||
pendingTopics map[string]heartbeatPendingTopic // in-memory retry/in-flight safety for NewConversationEachRun
|
||||
done chan struct{}
|
||||
running bool
|
||||
@@ -99,6 +100,28 @@ func (e *HeartbeatEngine) loadTasks() []HeartbeatTask {
|
||||
return cfg.Tasks
|
||||
}
|
||||
|
||||
// noteConfigModLocked records the config file's current mtime so the tick
|
||||
// external-edit probe does not re-read a file the engine itself just wrote.
|
||||
func (e *HeartbeatEngine) noteConfigModLocked() {
|
||||
if info, err := os.Stat(e.configPath()); err == nil {
|
||||
e.cfgMod = info.ModTime()
|
||||
}
|
||||
}
|
||||
|
||||
// adoptExternalEditsLocked re-reads the config when its mtime moved since the
|
||||
// engine last touched the file: heartbeat-tasks.json is documented as human-
|
||||
// and AI-editable, so an external edit should be scheduled by the next tick
|
||||
// rather than waiting for a manual Refresh or an app restart.
|
||||
func (e *HeartbeatEngine) adoptExternalEditsLocked() {
|
||||
info, err := os.Stat(e.configPath())
|
||||
if err != nil || info.ModTime().Equal(e.cfgMod) {
|
||||
return
|
||||
}
|
||||
e.cfgMod = info.ModTime()
|
||||
e.tasks = e.loadTasks()
|
||||
e.prunePendingTopicsLocked(e.tasks)
|
||||
}
|
||||
|
||||
// saveTasks writes tasks to disk atomically.
|
||||
func (e *HeartbeatEngine) saveTasks(tasks []HeartbeatTask) error {
|
||||
if tasks == nil {
|
||||
@@ -129,6 +152,7 @@ func (e *HeartbeatEngine) Start() {
|
||||
return
|
||||
}
|
||||
e.tasks = e.loadTasks()
|
||||
e.noteConfigModLocked()
|
||||
e.running = true
|
||||
go e.loop()
|
||||
log.Printf("[heartbeat] engine started (%d tasks)", len(e.tasks))
|
||||
@@ -160,10 +184,12 @@ func (e *HeartbeatEngine) loop() {
|
||||
}
|
||||
|
||||
// tick checks every enabled task and runs those whose interval has elapsed.
|
||||
// It merges results (topicId, lastRunAt) rather than replacing the full list,
|
||||
// so concurrent HeartbeatSaveTasks edits are not lost.
|
||||
// It first adopts any external edit to the config file (human/AI-editable),
|
||||
// then merges results (topicId, lastRunAt) rather than replacing the full
|
||||
// list, so concurrent HeartbeatSaveTasks edits are not lost.
|
||||
func (e *HeartbeatEngine) tick() {
|
||||
e.mu.Lock()
|
||||
e.adoptExternalEditsLocked()
|
||||
tasks := append([]HeartbeatTask(nil), e.tasks...)
|
||||
e.mu.Unlock()
|
||||
|
||||
@@ -374,6 +400,7 @@ func (e *HeartbeatEngine) ReloadTasks() []HeartbeatTask {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.tasks = e.loadTasks()
|
||||
e.noteConfigModLocked()
|
||||
e.prunePendingTopicsLocked(e.tasks)
|
||||
out := make([]HeartbeatTask, len(e.tasks))
|
||||
copy(out, e.tasks)
|
||||
@@ -386,7 +413,9 @@ func (e *HeartbeatEngine) ReplaceTasks(tasks []HeartbeatTask) error {
|
||||
defer e.mu.Unlock()
|
||||
e.tasks = tasks
|
||||
e.prunePendingTopicsLocked(tasks)
|
||||
return e.saveTasks(tasks)
|
||||
err := e.saveTasks(tasks)
|
||||
e.noteConfigModLocked()
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *HeartbeatEngine) prunePendingTopicsLocked(tasks []HeartbeatTask) {
|
||||
@@ -431,7 +460,18 @@ func (e *HeartbeatEngine) mergeRunUpdatesLocked(updates map[string]HeartbeatTask
|
||||
if len(updates) == 0 {
|
||||
return
|
||||
}
|
||||
tasks := append([]HeartbeatTask(nil), e.tasks...)
|
||||
// Rebase onto the on-disk list before the full-list save: the config file
|
||||
// is documented as human- and AI-editable, so an external edit may have
|
||||
// landed after the in-memory snapshot this tick ran from. The engine owns
|
||||
// only the run-state fields (TopicID, LastRunAt, CreatedAt backfill);
|
||||
// task definitions added, edited, or deleted externally are adopted from
|
||||
// disk, so the save below can never silently roll an external edit back.
|
||||
tasks := e.loadTasks()
|
||||
if tasks == nil {
|
||||
// Missing or unreadable file: fall back to the in-memory list so the
|
||||
// run state still lands (the historical behavior).
|
||||
tasks = append([]HeartbeatTask(nil), e.tasks...)
|
||||
}
|
||||
for i := range tasks {
|
||||
update, ok := updates[tasks[i].ID]
|
||||
if !ok {
|
||||
@@ -448,7 +488,9 @@ func (e *HeartbeatEngine) mergeRunUpdatesLocked(updates map[string]HeartbeatTask
|
||||
}
|
||||
}
|
||||
e.tasks = tasks
|
||||
e.prunePendingTopicsLocked(tasks)
|
||||
_ = e.saveTasks(tasks)
|
||||
e.noteConfigModLocked()
|
||||
}
|
||||
|
||||
// parseInterval converts a string like "5m", "1h", "30s" to time.Duration.
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -362,3 +363,73 @@ func TestHeartbeatInactiveOpenDoesNotChangeActiveTab(t *testing.T) {
|
||||
t.Fatalf("inactive open meta = %+v, want heartbeat and inactive", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatMergeRunUpdatesAdoptsExternalFileEdits(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
engine := &HeartbeatEngine{
|
||||
tasks: []HeartbeatTask{
|
||||
{ID: "a", Title: "stale title", Prompt: "stale", Interval: "1h", Enabled: true},
|
||||
},
|
||||
}
|
||||
// An external editor (the documented human/AI flow) rewrote the file after
|
||||
// the engine's in-memory snapshot: task a was edited and task b was added.
|
||||
external := []HeartbeatTask{
|
||||
{ID: "a", Title: "edited externally", Prompt: "new prompt", Interval: "2h", Enabled: true},
|
||||
{ID: "b", Title: "added externally", Prompt: "hello", Interval: "1h", Enabled: false},
|
||||
}
|
||||
if err := engine.saveTasks(external); err != nil {
|
||||
t.Fatalf("seed external file: %v", err)
|
||||
}
|
||||
|
||||
engine.mergeRunUpdatesLocked(map[string]HeartbeatTask{
|
||||
"a": {ID: "a", TopicID: "topic-a", LastRunAt: 4242},
|
||||
})
|
||||
|
||||
if len(engine.tasks) != 2 {
|
||||
t.Fatalf("tasks len = %d, want 2 (external addition adopted): %+v", len(engine.tasks), engine.tasks)
|
||||
}
|
||||
got := engine.tasks[0]
|
||||
if got.Title != "edited externally" || got.Prompt != "new prompt" || got.Interval != "2h" {
|
||||
t.Fatalf("external edit was rolled back by the run-state save: %+v", got)
|
||||
}
|
||||
if got.TopicID != "topic-a" || got.LastRunAt != 4242 {
|
||||
t.Fatalf("run state was not merged onto the disk copy: %+v", got)
|
||||
}
|
||||
// The full-list save must have preserved the externally added task on disk.
|
||||
onDisk := engine.loadTasks()
|
||||
if len(onDisk) != 2 || onDisk[1].ID != "b" || onDisk[1].Title != "added externally" {
|
||||
t.Fatalf("externally added task was lost on save: %+v", onDisk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatTickAdoptsExternalFileEdits(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
engine := newHeartbeatEngine(nil)
|
||||
if err := engine.saveTasks([]HeartbeatTask{{ID: "a", Title: "A", Interval: "1h", Enabled: false}}); err != nil {
|
||||
t.Fatalf("seed file: %v", err)
|
||||
}
|
||||
engine.mu.Lock()
|
||||
engine.tasks = engine.loadTasks()
|
||||
engine.noteConfigModLocked()
|
||||
engine.mu.Unlock()
|
||||
|
||||
// External edit lands after the engine last touched the file. Force the
|
||||
// mtime forward so coarse filesystem timestamps cannot make this flaky.
|
||||
if err := engine.saveTasks([]HeartbeatTask{
|
||||
{ID: "a", Title: "A", Interval: "1h", Enabled: false},
|
||||
{ID: "b", Title: "added externally", Interval: "1h", Enabled: false},
|
||||
}); err != nil {
|
||||
t.Fatalf("external edit: %v", err)
|
||||
}
|
||||
future := time.Now().Add(2 * time.Second)
|
||||
if err := os.Chtimes(engine.configPath(), future, future); err != nil {
|
||||
t.Fatalf("chtimes: %v", err)
|
||||
}
|
||||
|
||||
engine.tick() // disabled tasks only: adoption runs, nothing executes
|
||||
|
||||
tasks := engine.ListTasks()
|
||||
if len(tasks) != 2 || tasks[1].ID != "b" {
|
||||
t.Fatalf("tick did not adopt the external edit: %+v", tasks)
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ func (a *App) SaveHooksSettingsForRoot(scope, projectRoot string, hooks []HookCo
|
||||
if cmd == "" {
|
||||
continue
|
||||
}
|
||||
cmd = hook.NormalizeCommand(cmd)
|
||||
settings.Hooks[event] = append(settings.Hooks[event], hook.HookConfig{
|
||||
Match: strings.TrimSpace(h.Match),
|
||||
Command: cmd,
|
||||
|
||||
@@ -24,9 +24,21 @@ import (
|
||||
// cmd/reasonix does — boot.Build resolves providers/tools from these registries.
|
||||
_ "reasonix/internal/provider/anthropic"
|
||||
_ "reasonix/internal/provider/openai"
|
||||
"reasonix/internal/sandbox"
|
||||
_ "reasonix/internal/tool/builtin"
|
||||
)
|
||||
|
||||
// runWindowsSandboxHelperIfRequested reports whether argv (os.Args-shaped, so
|
||||
// argv[0] is the program name) asks this process to act as the hidden Windows
|
||||
// sandbox helper, and runs it when so. Split from main so tests can pin that
|
||||
// the desktop binary keeps the helper route the sandbox wrapper depends on.
|
||||
func runWindowsSandboxHelperIfRequested(argv []string) (int, bool) {
|
||||
if len(argv) > 1 && argv[1] == sandbox.WindowsHelperCommand {
|
||||
return sandbox.RunWindowsSandboxHelper(argv[2:], os.Stdin, os.Stdout, os.Stderr), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// assets embeds the built frontend. `all:` so dotfiles (e.g. the dist .gitkeep
|
||||
// that keeps this directive compilable before the first `pnpm build`) are
|
||||
// included. A real run requires `pnpm build` (or `wails build`) to populate dist.
|
||||
@@ -90,6 +102,16 @@ func linuxWebviewGpuPolicy(pattern string) linux.WebviewGpuPolicy {
|
||||
}
|
||||
|
||||
func main() {
|
||||
// The Windows bash sandbox relaunches the current executable as a hidden
|
||||
// helper process. Dispatch it before any Wails or single-instance setup:
|
||||
// otherwise every sandboxed command starts a second GUI instance that
|
||||
// forwards to the running app and exits 0 with no output, so bash silently
|
||||
// returns empty on Windows (#6051, #6067, #6072).
|
||||
if code, ok := runWindowsSandboxHelperIfRequested(os.Args); ok {
|
||||
os.Exit(code)
|
||||
}
|
||||
sandbox.RegisterHelperDispatch()
|
||||
|
||||
app := NewApp()
|
||||
|
||||
// Restore saved window size, or fall back to the default.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -23,6 +24,12 @@ func TestMain(m *testing.M) {
|
||||
os.Setenv("REASONIX_STATE_HOME", dir+"/state")
|
||||
os.Setenv("REASONIX_CACHE_HOME", dir+"/cache")
|
||||
os.Setenv("AppData", dir)
|
||||
// Neutralize the Wails runtime-event bridge for the whole test binary:
|
||||
// outside a running Wails app, runtime.EventsEmit log.Fatals on the plain
|
||||
// contexts tests use, killing the process from any emitting code path.
|
||||
// Tests that assert on runtime events install their own capture through
|
||||
// the per-instance runtimeEvents.emit hook, which takes precedence.
|
||||
runtimeEventsEmitFallback = func(context.Context, string, ...interface{}) {}
|
||||
code := m.Run()
|
||||
os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -116,6 +117,9 @@ func (a *App) MemorySuggestionsForTab(tabID string) MemorySuggestionsView {
|
||||
|
||||
sessions := loadSuggestionSessions(sessionDir, suggestionSessionLimit)
|
||||
view.Memories = suggestMemories(set, sessions)
|
||||
// Stable Memory v5 execution learnings join the same candidate list and
|
||||
// the same explicit-confirmation flow as history-derived suggestions.
|
||||
view.Memories = append(view.Memories, suggestCompilerMemories(workspaceRoot, set, view.Memories)...)
|
||||
view.Skills = suggestSkills(workspaceRoot, ctrl.AllSkills(), sessions)
|
||||
return view
|
||||
}
|
||||
@@ -137,7 +141,7 @@ func (a *App) AcceptMemorySuggestionForTab(tabID string, in MemorySuggestion) (s
|
||||
if desc == "" || body == "" {
|
||||
return "", fmt.Errorf("memory suggestion requires description and body")
|
||||
}
|
||||
name := suggestionName(in.Name, desc, "memory-candidate")
|
||||
name := acceptedSuggestionName(in.Name, desc)
|
||||
return ctrl.SaveMemory(memory.Memory{
|
||||
Name: name,
|
||||
Title: oneLine(in.Title),
|
||||
@@ -229,7 +233,7 @@ func suggestMemories(set *memory.Set, sessions []suggestionSession) []MemorySugg
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
name := suggestionName("", statement, fmt.Sprintf("memory-candidate-%d", len(out)+1))
|
||||
name := stableSuggestionName(statement, "memory-candidate")
|
||||
title := suggestionTitle(statement, "Memory candidate")
|
||||
typ := inferMemoryType(statement)
|
||||
out = append(out, MemorySuggestion{
|
||||
@@ -504,6 +508,66 @@ func suggestionName(given, source, fallback string) string {
|
||||
return "candidate"
|
||||
}
|
||||
|
||||
// acceptedSuggestionName preserves the candidate name generated at suggestion
|
||||
// time. Re-running asciiSlug here would truncate back to 56 chars and strip
|
||||
// the uniqueness hash suffix, re-colliding long common-prefix candidates at
|
||||
// save time even though their generated Name/ID differed. A well-formed slug
|
||||
// is kept verbatim (memory.Store.Save's own slug pass cleans but never
|
||||
// truncates); anything else falls back to deriving from the description as
|
||||
// before.
|
||||
func acceptedSuggestionName(given, desc string) string {
|
||||
if isWellFormedSlug(given) {
|
||||
return given
|
||||
}
|
||||
return suggestionName("", desc, "memory-candidate")
|
||||
}
|
||||
|
||||
// isWellFormedSlug reports whether s already matches asciiSlug's output shape
|
||||
// (lowercase ASCII letters/digits separated by single dashes), possibly with a
|
||||
// hash suffix beyond asciiSlug's 56-char cap.
|
||||
func isWellFormedSlug(s string) bool {
|
||||
if s == "" || len(s) > 128 || s[0] == '-' || s[len(s)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
prevDash := false
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
prevDash = false
|
||||
case r == '-':
|
||||
if prevDash {
|
||||
return false
|
||||
}
|
||||
prevDash = true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// stableSuggestionName returns a slug that is unique per source text and stable
|
||||
// across suggestion refreshes. asciiSlug drops non-ASCII runes and truncates to
|
||||
// 56 chars, so two CJK-only statements (or long English statements sharing a
|
||||
// prefix) can collide — colliding Names make Store.Save overwrite the earlier
|
||||
// memory, and colliding IDs cross-wire the frontend's accepted-state map.
|
||||
//
|
||||
// When the ASCII slug is short enough that truncation cannot have caused a
|
||||
// collision, it is returned as-is for backward compatibility with old-version
|
||||
// candidate names. The hash suffix is only appended when the slug fell back to
|
||||
// the fallback (non-ASCII source) or when the slug hit the 56-char truncation
|
||||
// boundary.
|
||||
func stableSuggestionName(source, fallback string) string {
|
||||
slug := asciiSlug(source)
|
||||
if slug != "" && len(slug) < 56 {
|
||||
return slug
|
||||
}
|
||||
base := suggestionName("", source, fallback)
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(source))
|
||||
return fmt.Sprintf("%s-%08x", base, h.Sum32())
|
||||
}
|
||||
|
||||
func asciiSlug(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
var b strings.Builder
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/memory"
|
||||
"reasonix/internal/memorycompiler"
|
||||
)
|
||||
|
||||
const (
|
||||
// compilerSuggestionLimit bounds Memory v5 candidates separately from the
|
||||
// history-derived ones so a busy history cannot crowd them out.
|
||||
compilerSuggestionLimit = 3
|
||||
// compilerSuggestionMinCount requires a pattern to recur across at least
|
||||
// this many separate turns before it is stable enough to suggest.
|
||||
compilerSuggestionMinCount = 2
|
||||
)
|
||||
|
||||
// suggestCompilerMemories converts stable Memory v5 execution learnings into
|
||||
// memory candidates for the settings Memory page. Read-only: candidates are
|
||||
// only persisted through the regular AcceptMemorySuggestion confirmation flow.
|
||||
func suggestCompilerMemories(workspaceRoot string, set *memory.Set, already []MemorySuggestion) []MemorySuggestion {
|
||||
rt := memorycompiler.New(config.MemoryCompilerDir(workspaceRoot))
|
||||
if rt == nil || set == nil {
|
||||
return nil
|
||||
}
|
||||
existing := existingMemoryText(set)
|
||||
seen := map[string]bool{}
|
||||
for _, s := range already {
|
||||
seen[normalizeSuggestionKey(s.Description)] = true
|
||||
}
|
||||
var out []MemorySuggestion
|
||||
for _, p := range rt.StableNoisePatterns(compilerSuggestionMinCount, compilerSuggestionLimit*2) {
|
||||
statement := compilerPatternStatement(p.Pattern)
|
||||
key := normalizeSuggestionKey(statement)
|
||||
if key == "" || seen[key] || existingCovers(existing, key) {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
name := compilerCandidateName(p.Pattern)
|
||||
out = append(out, MemorySuggestion{
|
||||
ID: "memory-" + name,
|
||||
Name: name,
|
||||
Title: suggestionTitle(statement, "Memory v5 pattern"),
|
||||
Description: oneLine(statement),
|
||||
Type: string(memory.TypeProject),
|
||||
Body: compilerCandidateBody(statement, p.Count),
|
||||
Reason: fmt.Sprintf("Memory v5 observed this failure pattern in %d separate turns", p.Count),
|
||||
Evidence: []string{fmt.Sprintf("memory-v5 execution traces: %s (x%d)", truncateRunes(p.Pattern, 160), p.Count)},
|
||||
})
|
||||
if len(out) >= compilerSuggestionLimit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// compilerCandidateName always appends a hash: compiler patterns are
|
||||
// inherently homogeneous (same tool + similar error prefix) so the ASCII
|
||||
// slug alone cannot distinguish them even when it is non-empty.
|
||||
func compilerCandidateName(pattern string) string {
|
||||
base := suggestionName("", "memory-v5 "+pattern, "memory-v5-pattern")
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(pattern))
|
||||
return fmt.Sprintf("%s-%08x", base, h.Sum32())
|
||||
}
|
||||
|
||||
func compilerPatternStatement(pattern string) string {
|
||||
pattern = oneLine(pattern)
|
||||
if pattern == "" {
|
||||
return ""
|
||||
}
|
||||
return "Known repeated failure in this workspace: " + pattern + "."
|
||||
}
|
||||
|
||||
func compilerCandidateBody(statement string, count int) string {
|
||||
return statement + "\n\n" +
|
||||
fmt.Sprintf("**Why:** Memory v5 recorded this failure pattern in %d separate execution traces for this workspace.\n", count) +
|
||||
"**How to apply:** Address the known cause before retrying the same command; drop this memory once the failure stops reproducing.\n"
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/memory"
|
||||
"reasonix/internal/memorycompiler"
|
||||
)
|
||||
|
||||
func seedCompilerFailures(t *testing.T, workspaceRoot string, turns int, errText string) {
|
||||
t.Helper()
|
||||
rt := memorycompiler.New(config.MemoryCompilerDir(workspaceRoot))
|
||||
if rt == nil {
|
||||
t.Fatal("memory compiler dir did not resolve under isolated dirs")
|
||||
}
|
||||
for i := 0; i < turns; i++ {
|
||||
_, turn := rt.StartTurn(context.Background(), "fix a bug", nil)
|
||||
turn.RecordToolResults([]memorycompiler.ToolRecord{
|
||||
{Name: "bash", Error: errText},
|
||||
{Name: "bash", Error: errText},
|
||||
})
|
||||
turn.Finish(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemorySuggestionsIncludeCompilerCandidates(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
seedCompilerFailures(t, cwd, 2, "cannot find module providing package foo")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
view := app.MemorySuggestions()
|
||||
var candidate MemorySuggestion
|
||||
for _, item := range view.Memories {
|
||||
if strings.HasPrefix(item.Name, "memory-v5-") {
|
||||
candidate = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if candidate.ID == "" {
|
||||
t.Fatalf("memories = %+v, want a memory-v5 candidate", view.Memories)
|
||||
}
|
||||
if candidate.Type != string(memory.TypeProject) {
|
||||
t.Fatalf("candidate type = %q, want project", candidate.Type)
|
||||
}
|
||||
if !strings.Contains(candidate.Description, "cannot find module") {
|
||||
t.Fatalf("candidate description missing pattern: %q", candidate.Description)
|
||||
}
|
||||
if !strings.Contains(candidate.Reason, "Memory v5") {
|
||||
t.Fatalf("candidate reason missing provenance: %q", candidate.Reason)
|
||||
}
|
||||
|
||||
path, err := app.AcceptMemorySuggestion(candidate)
|
||||
if err != nil {
|
||||
t.Fatalf("AcceptMemorySuggestion: %v", err)
|
||||
}
|
||||
if path == "" {
|
||||
t.Fatal("AcceptMemorySuggestion returned empty path")
|
||||
}
|
||||
saved := store.List()
|
||||
if len(saved) != 1 || !strings.Contains(saved[0].Body, "cannot find module") {
|
||||
t.Fatalf("saved memories = %+v, want compiler candidate body", saved)
|
||||
}
|
||||
|
||||
// Once accepted, the same pattern is covered by an existing memory and
|
||||
// must not be suggested again.
|
||||
again := app.MemorySuggestions()
|
||||
for _, item := range again.Memories {
|
||||
if strings.HasPrefix(item.Name, "memory-v5-") {
|
||||
t.Fatalf("accepted pattern suggested again: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompilerCandidatesRequireStablePattern(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
// A pattern seen in only one turn is not stable enough to suggest.
|
||||
seedCompilerFailures(t, cwd, 1, "cannot find module providing package foo")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
view := app.MemorySuggestions()
|
||||
for _, item := range view.Memories {
|
||||
if strings.HasPrefix(item.Name, "memory-v5-") {
|
||||
t.Fatalf("unstable pattern was suggested: %+v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompilerCandidateNamesUniqueForSimilarPatterns covers the review
|
||||
// finding: asciiSlug drops non-ASCII runes and truncates to 56 chars, so
|
||||
// patterns differing only in CJK text (or sharing a long English prefix) used
|
||||
// to collide on Name/ID — colliding IDs cross-wire the frontend's accepted
|
||||
// state and colliding Names make Store.Save overwrite the earlier memory.
|
||||
func TestCompilerCandidateNamesUniqueForSimilarPatterns(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
// Same tool, errors differ only in CJK text: identical after asciiSlug.
|
||||
seedCompilerFailures(t, cwd, 2, "编译失败:找不到模块甲")
|
||||
seedCompilerFailures(t, cwd, 2, "编译失败:找不到模块乙")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
view := app.MemorySuggestions()
|
||||
var candidates []MemorySuggestion
|
||||
for _, item := range view.Memories {
|
||||
if strings.HasPrefix(item.Name, "memory-v5-") {
|
||||
candidates = append(candidates, item)
|
||||
}
|
||||
}
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("candidates = %+v, want both CJK-only-diff patterns", candidates)
|
||||
}
|
||||
if candidates[0].Name == candidates[1].Name || candidates[0].ID == candidates[1].ID {
|
||||
t.Fatalf("similar patterns collided on Name/ID: %q vs %q", candidates[0].Name, candidates[1].Name)
|
||||
}
|
||||
|
||||
// Accepting both must persist two distinct memories, not overwrite one.
|
||||
for _, c := range candidates {
|
||||
if _, err := app.AcceptMemorySuggestion(c); err != nil {
|
||||
t.Fatalf("AcceptMemorySuggestion(%s): %v", c.Name, err)
|
||||
}
|
||||
}
|
||||
saved := store.List()
|
||||
if len(saved) != 2 {
|
||||
t.Fatalf("saved memories = %d (%+v), want 2 distinct files", len(saved), saved)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompilerCandidateNamesStableAcrossRefreshes: the hash suffix must be
|
||||
// derived from the pattern, not from ordering or randomness, so a refresh
|
||||
// keeps the same ID and the frontend's accepted-state map stays valid.
|
||||
func TestCompilerCandidateNamesStableAcrossRefreshes(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
seedCompilerFailures(t, cwd, 2, "cannot find module providing package foo")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
first := app.MemorySuggestions()
|
||||
second := app.MemorySuggestions()
|
||||
firstIDs := compilerCandidateIDs(first.Memories)
|
||||
secondIDs := compilerCandidateIDs(second.Memories)
|
||||
if len(firstIDs) == 0 || strings.Join(firstIDs, ",") != strings.Join(secondIDs, ",") {
|
||||
t.Fatalf("candidate IDs changed across refreshes: %v vs %v", firstIDs, secondIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func compilerCandidateIDs(memories []MemorySuggestion) []string {
|
||||
var out []string
|
||||
for _, item := range memories {
|
||||
if strings.HasPrefix(item.Name, "memory-v5-") {
|
||||
out = append(out, item.ID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestCompilerCandidateLongPrefixNamesPersistDistinctly reproduces the review
|
||||
// finding: two patterns sharing a >56-char ASCII prefix get distinct generated
|
||||
// Name/ID (hash suffix), but the accept path used to re-run asciiSlug, which
|
||||
// truncates to 56 chars and strips the hash — so both saved to one file.
|
||||
func TestCompilerCandidateLongPrefixNamesPersistDistinctly(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
prefix := "cannot find module providing package example dot com slash very long prefix "
|
||||
seedCompilerFailures(t, cwd, 2, prefix+"alpha")
|
||||
seedCompilerFailures(t, cwd, 2, prefix+"beta")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
view := app.MemorySuggestions()
|
||||
var candidates []MemorySuggestion
|
||||
for _, item := range view.Memories {
|
||||
if strings.HasPrefix(item.Name, "memory-v5-") {
|
||||
candidates = append(candidates, item)
|
||||
}
|
||||
}
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("candidates = %+v, want both long-prefix patterns", candidates)
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if _, err := app.AcceptMemorySuggestion(c); err != nil {
|
||||
t.Fatalf("AcceptMemorySuggestion(%s): %v", c.Name, err)
|
||||
}
|
||||
}
|
||||
saved := store.List()
|
||||
if len(saved) != 2 {
|
||||
t.Fatalf("saved memories = %d, want 2 distinct files (accept path truncated the hash suffix)", len(saved))
|
||||
}
|
||||
if saved[0].Name == saved[1].Name {
|
||||
t.Fatalf("saved names collided: %q", saved[0].Name)
|
||||
}
|
||||
}
|
||||
@@ -210,3 +210,129 @@ func writeSuggestionSession(t *testing.T, dir, name string, messages ...provider
|
||||
t.Fatalf("save session %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryEnglishCandidateNameBackwardCompat: an English statement whose
|
||||
// asciiSlug is short (<56 chars) must produce the same Name as old code
|
||||
// (plain slug, no hash suffix), so an already-accepted memory under the old
|
||||
// Name is not duplicated after upgrade.
|
||||
func TestHistoryEnglishCandidateNameBackwardCompat(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
writeSuggestionSession(t, sessionDir, "en.jsonl",
|
||||
provider.Message{Role: provider.RoleUser, Content: "Always prefer English for code comments."},
|
||||
provider.Message{Role: provider.RoleAssistant, Content: "Got it."},
|
||||
)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
view := app.MemorySuggestions()
|
||||
if len(view.Memories) == 0 {
|
||||
t.Fatalf("no candidates")
|
||||
}
|
||||
// Old code: suggestionName("", statement, "memory-candidate-1") = asciiSlug(statement)
|
||||
oldName := asciiSlug("Always prefer English for code comments.")
|
||||
if view.Memories[0].Name != oldName {
|
||||
t.Fatalf("Name = %q, want old-compatible %q (no hash suffix for short ASCII slugs)", view.Memories[0].Name, oldName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryMemoryCandidateNamesUniqueForCJK: two pure-CJK statements that
|
||||
// differ in content but produce the same empty asciiSlug must still get
|
||||
// distinct Name/ID. Without the hash suffix they would both fall back to
|
||||
// "memory-candidate-<ordinal>" — but ordinals depend on iteration order and
|
||||
// wouldn't survive refresh, and Store.Save overwrites by name.
|
||||
func TestHistoryMemoryCandidateNamesUniqueForCJK(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
// Two pure-CJK "always" statements that pass extractMemoryStatement but
|
||||
// share the exact same empty asciiSlug.
|
||||
writeSuggestionSession(t, sessionDir, "zh-a.jsonl",
|
||||
provider.Message{Role: provider.RoleUser, Content: "以后始终使用甲方案处理合并冲突。"},
|
||||
provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
|
||||
)
|
||||
writeSuggestionSession(t, sessionDir, "zh-b.jsonl",
|
||||
provider.Message{Role: provider.RoleUser, Content: "以后始终使用乙方案处理部署回滚。"},
|
||||
provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
|
||||
)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
view := app.MemorySuggestions()
|
||||
if len(view.Memories) < 2 {
|
||||
t.Fatalf("memories = %+v, want at least 2 CJK candidates", view.Memories)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
ids := map[string]bool{}
|
||||
for _, m := range view.Memories {
|
||||
if names[m.Name] {
|
||||
t.Fatalf("duplicate Name %q among history candidates", m.Name)
|
||||
}
|
||||
if ids[m.ID] {
|
||||
t.Fatalf("duplicate ID %q among history candidates", m.ID)
|
||||
}
|
||||
names[m.Name] = true
|
||||
ids[m.ID] = true
|
||||
}
|
||||
|
||||
// Accept both → two distinct persisted memories.
|
||||
for _, c := range view.Memories {
|
||||
if _, err := app.AcceptMemorySuggestion(c); err != nil {
|
||||
t.Fatalf("AcceptMemorySuggestion(%s): %v", c.Name, err)
|
||||
}
|
||||
}
|
||||
saved := store.List()
|
||||
if len(saved) != len(view.Memories) {
|
||||
t.Fatalf("saved %d memories, want %d (Name collision caused overwrite)", len(saved), len(view.Memories))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryMemoryCandidateNamesStableAcrossRefreshes: the hash suffix must
|
||||
// be derived from the statement, not from iteration order or random state, so
|
||||
// a refresh keeps the same ID and the frontend's accepted-state map stays valid.
|
||||
func TestHistoryMemoryCandidateNamesStableAcrossRefreshes(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userDir := t.TempDir()
|
||||
cwd := t.TempDir()
|
||||
sessionDir := t.TempDir()
|
||||
store := memory.StoreFor(userDir, cwd)
|
||||
writeSuggestionSession(t, sessionDir, "pref.jsonl",
|
||||
provider.Message{Role: provider.RoleUser, Content: "以后请始终用中文回复,除非我明确要求英文。"},
|
||||
provider.Message{Role: provider.RoleAssistant, Content: "好的。"},
|
||||
)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{
|
||||
Memory: &memory.Set{Store: store, CWD: cwd, UserDir: userDir},
|
||||
SessionDir: sessionDir,
|
||||
}), "test-model")
|
||||
app.tabs["test"].WorkspaceRoot = cwd
|
||||
|
||||
first := app.MemorySuggestions()
|
||||
second := app.MemorySuggestions()
|
||||
if len(first.Memories) == 0 || len(first.Memories) != len(second.Memories) {
|
||||
t.Fatalf("memories counts differ across refreshes: %d vs %d", len(first.Memories), len(second.Memories))
|
||||
}
|
||||
for i := range first.Memories {
|
||||
if first.Memories[i].ID != second.Memories[i].ID || first.Memories[i].Name != second.Memories[i].Name {
|
||||
t.Fatalf("refresh changed candidate #%d: %q/%q → %q/%q",
|
||||
i, first.Memories[i].Name, first.Memories[i].ID,
|
||||
second.Memories[i].Name, second.Memories[i].ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,7 +309,7 @@ func (m *metricsAggregator) observe(e event.Event) {
|
||||
case event.MemoryCompilerStatsEvent:
|
||||
m.observeMemoryCompilerStats(e.MemoryCompiler)
|
||||
case event.Notice:
|
||||
if strings.HasPrefix(e.Text, "empty final answer blocked") {
|
||||
if e.Text == "No visible answer was produced; asking the assistant to respond again." || strings.HasPrefix(e.Detail, "empty final answer blocked") {
|
||||
m.inc("empty_final", "total")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestObserveClassifiesEvents(t *testing.T) {
|
||||
StrategyCount: 3,
|
||||
LearningCount: 6,
|
||||
}},
|
||||
{Kind: event.Notice, Text: "empty final answer blocked: model returned no visible answer text; retrying"},
|
||||
{Kind: event.Notice, Text: "No visible answer was produced; asking the assistant to respond again.", Detail: "empty final answer blocked: model returned no visible answer text; retrying"},
|
||||
{Kind: event.TurnDone, Err: errors.New("deepseek-flash: status 429: rate limited")},
|
||||
{Kind: event.TurnDone},
|
||||
}
|
||||
|
||||
@@ -136,6 +136,9 @@ func (a *App) InstallPlugin(source string, opts PluginInstallOptions) (string, e
|
||||
}
|
||||
a.invalidateSkillRootsCache()
|
||||
if rebuildErr := a.rebuild(); rebuildErr != nil {
|
||||
if _, ok := a.deferredRebuildWarning("plugins", rebuildErr); ok {
|
||||
return out, nil
|
||||
}
|
||||
return out, rebuildErr
|
||||
}
|
||||
return out, nil
|
||||
@@ -161,7 +164,13 @@ func (a *App) RemovePlugin(name string) error {
|
||||
return err
|
||||
}
|
||||
a.invalidateSkillRootsCache()
|
||||
return a.rebuild()
|
||||
if err := a.rebuild(); err != nil {
|
||||
if _, ok := a.deferredRebuildWarning("plugins", err); ok {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) SetPluginEnabled(name string, enabled bool) error {
|
||||
@@ -172,7 +181,13 @@ func (a *App) SetPluginEnabled(name string, enabled bool) error {
|
||||
return err
|
||||
}
|
||||
a.invalidateSkillRootsCache()
|
||||
return a.rebuild()
|
||||
if err := a.rebuild(); err != nil {
|
||||
if _, ok := a.deferredRebuildWarning("plugins", err); ok {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) UpdatePlugin(name string) (string, error) {
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
// TestSessionLeaseHelpersConcurrentAccess hammers the sessionLeaseMu helpers
|
||||
// (ensure/take/adopt/release/key) from concurrent goroutines. Run with -race:
|
||||
// any residual raw access to tab.sessionLease shows up as a data race, and the
|
||||
// final acquire asserts no lease leaked through an interleaving.
|
||||
func TestSessionLeaseHelpersConcurrentAccess(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "lease-helper-hammer.jsonl")
|
||||
key := sessionRuntimeKey(path)
|
||||
tabA := &WorkspaceTab{ID: "a"}
|
||||
tabB := &WorkspaceTab{ID: "b"}
|
||||
|
||||
const iterations = 300
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(3)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
_ = tabA.ensureSessionLease(path)
|
||||
_ = tabA.sessionLeaseRuntimeKey()
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
// The applyRuntimeTab transfer shape: move A's lease to B and back.
|
||||
tabB.adoptSessionLease(tabA.takeSessionLease())
|
||||
tabA.adoptSessionLease(tabB.takeSessionLease())
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
tabA.releaseSessionLease()
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
tabA.releaseSessionLease()
|
||||
tabB.releaseSessionLease()
|
||||
lease, err := agent.TryAcquireSessionLease(key)
|
||||
if err != nil {
|
||||
t.Fatalf("lease leaked through concurrent helper interleavings: %v", err)
|
||||
}
|
||||
lease.Release()
|
||||
}
|
||||
|
||||
// TestDetachRuntimeForReplacementTransfersLease asserts the detach clone takes
|
||||
// lease ownership through the locked helpers and the visible tab keeps none.
|
||||
func TestDetachRuntimeForReplacementTransfersLease(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "detach-transfer.jsonl")
|
||||
key := sessionRuntimeKey(path)
|
||||
tab := &WorkspaceTab{ID: "tab", Scope: "global", SessionPath: path}
|
||||
app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
|
||||
if err := tab.ensureSessionLease(path); err != nil {
|
||||
t.Fatalf("ensureSessionLease: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
tab.releaseSessionLease()
|
||||
for _, d := range app.detachedSessions {
|
||||
d.releaseSessionLease()
|
||||
}
|
||||
})
|
||||
|
||||
if !app.detachRuntimeForReplacement(tab) {
|
||||
t.Fatal("detachRuntimeForReplacement failed for a live tab")
|
||||
}
|
||||
detached := app.detachedSessions[key]
|
||||
if detached == nil {
|
||||
t.Fatal("detached runtime was not registered")
|
||||
}
|
||||
if got := detached.sessionLeaseRuntimeKey(); got != key {
|
||||
t.Fatalf("detached clone lease key = %q, want %q", got, key)
|
||||
}
|
||||
if got := tab.sessionLeaseRuntimeKey(); got != "" {
|
||||
t.Fatalf("visible tab still holds lease key %q after detach", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetachRuntimeForReplacementSkipsRemovedTab: a tab that DeleteSession /
|
||||
// CloseTab already unlinked must not be re-published into detachedSessions
|
||||
// (the "session resurrects" class, #4384).
|
||||
func TestDetachRuntimeForReplacementSkipsRemovedTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "detach-removed.jsonl")
|
||||
|
||||
removed := &WorkspaceTab{ID: "removed", SessionPath: path, removed: true}
|
||||
app := &App{tabs: map[string]*WorkspaceTab{"removed": removed}}
|
||||
if app.detachRuntimeForReplacement(removed) {
|
||||
t.Fatal("removed tab was detached into the background registry")
|
||||
}
|
||||
if len(app.detachedSessions) != 0 {
|
||||
t.Fatal("removed tab left an entry in detachedSessions")
|
||||
}
|
||||
|
||||
orphan := &WorkspaceTab{ID: "orphan", SessionPath: path}
|
||||
if app.detachRuntimeForReplacement(orphan) {
|
||||
t.Fatal("tab absent from a.tabs was detached into the background registry")
|
||||
}
|
||||
if len(app.detachedSessions) != 0 {
|
||||
t.Fatal("orphan tab left an entry in detachedSessions")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTabEventSinkEmitConcurrentRebind: Emit keeps running on the controller
|
||||
// goroutine while detach/reattach rebinds the sink's tab routing. Run with
|
||||
// -race; before setBinding existed the tabID write raced every Emit.
|
||||
func TestTabEventSinkEmitConcurrentRebind(t *testing.T) {
|
||||
sink := &tabEventSink{tabID: "before"}
|
||||
const iterations = 500
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
sink.Emit(event.Event{Kind: event.Notice, Text: "hammer"})
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
sink.setBinding(fmt.Sprintf("tab-%d", i%2), nil)
|
||||
sink.clearContext()
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
if tabID, _ := sink.binding(); tabID == "" {
|
||||
t.Fatal("sink lost its tab binding")
|
||||
}
|
||||
}
|
||||
|
||||
// TestTabBuildSupersededByRebindGeneration: a session rebind bumps
|
||||
// buildGeneration to strand any in-flight async build, so its swap (and every
|
||||
// mid-build field write) is rejected; synchronous rebuilds pass generation 0
|
||||
// and rely on runtimeRebuildMu instead.
|
||||
func TestTabBuildSupersededByRebindGeneration(t *testing.T) {
|
||||
tab := &WorkspaceTab{ID: "tab"}
|
||||
app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
|
||||
app.mu.Lock()
|
||||
tab.buildGeneration = 3
|
||||
app.mu.Unlock()
|
||||
if app.tabBuildSuperseded(tab, 3) {
|
||||
t.Fatal("build with the current generation must not be superseded")
|
||||
}
|
||||
app.mu.Lock()
|
||||
tab.buildGeneration++ // the rebind-side invalidation
|
||||
app.mu.Unlock()
|
||||
if !app.tabBuildSuperseded(tab, 3) {
|
||||
t.Fatal("stale-generation build must be superseded after a rebind bump")
|
||||
}
|
||||
if app.tabBuildSuperseded(tab, 0) {
|
||||
t.Fatal("synchronous rebuild (generation 0) must not be superseded by generation bumps")
|
||||
}
|
||||
app.mu.Lock()
|
||||
tab.removed = true
|
||||
app.mu.Unlock()
|
||||
if !app.tabBuildSuperseded(tab, 0) {
|
||||
t.Fatal("removed tab must supersede every build, including synchronous ones")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReleaseSessionLeaseForKeyOnlyMatchesOwnKey: superseded builds may only
|
||||
// release the lease bound to their own path; a mismatched key (the rebind's
|
||||
// replacement session) must be left untouched.
|
||||
func TestReleaseSessionLeaseForKeyOnlyMatchesOwnKey(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
pathA := filepath.Join(dir, "lease-key-a.jsonl")
|
||||
pathB := filepath.Join(dir, "lease-key-b.jsonl")
|
||||
tab := &WorkspaceTab{ID: "tab"}
|
||||
t.Cleanup(tab.releaseSessionLease)
|
||||
if err := tab.ensureSessionLease(pathB); err != nil {
|
||||
t.Fatalf("ensureSessionLease: %v", err)
|
||||
}
|
||||
|
||||
tab.releaseSessionLeaseForKey(sessionRuntimeKey(pathA))
|
||||
if _, err := agent.TryAcquireSessionLease(sessionRuntimeKey(pathB)); err == nil {
|
||||
t.Fatal("mismatched key released the tab's live lease")
|
||||
}
|
||||
|
||||
tab.releaseSessionLeaseForKey(sessionRuntimeKey(pathB))
|
||||
lease, err := agent.TryAcquireSessionLease(sessionRuntimeKey(pathB))
|
||||
if err != nil {
|
||||
t.Fatalf("matching key did not release the lease: %v", err)
|
||||
}
|
||||
lease.Release()
|
||||
}
|
||||
|
||||
// TestAbandonSupersededBuildPreservesNewBuildOwnership reproduces the rebind
|
||||
// interleaving from the #5968 review: a stale async build cleans up after a
|
||||
// rebind's replacement build already published its own SharedHostKey and
|
||||
// session lease on the same live tab. The stale build must drop only its own
|
||||
// host reference and lease, never the tab's.
|
||||
func TestAbandonSupersededBuildPreservesNewBuildOwnership(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
oldPath := filepath.Join(dir, "superseded-old.jsonl")
|
||||
newPath := filepath.Join(dir, "superseded-new.jsonl")
|
||||
newKey := sessionRuntimeKey(newPath)
|
||||
|
||||
app := &App{tabs: map[string]*WorkspaceTab{}}
|
||||
// The stale build and the replacement build each hold one reference on
|
||||
// the same workspace-root host (the common same-root rebind).
|
||||
app.acquireSharedHost("root")
|
||||
app.acquireSharedHost("root")
|
||||
|
||||
tab := &WorkspaceTab{ID: "tab", SharedHostKey: "root"}
|
||||
app.tabs["tab"] = tab
|
||||
t.Cleanup(tab.releaseSessionLease)
|
||||
if err := tab.ensureSessionLease(newPath); err != nil {
|
||||
t.Fatalf("ensureSessionLease(new): %v", err)
|
||||
}
|
||||
|
||||
// Stale build abandons with ITS key material: the old lease path and the
|
||||
// root key it acquired.
|
||||
app.abandonSupersededBuild(tab, nil, "root", sessionRuntimeKey(oldPath))
|
||||
|
||||
app.mu.RLock()
|
||||
hostKey := tab.SharedHostKey
|
||||
app.mu.RUnlock()
|
||||
if hostKey != "root" {
|
||||
t.Fatalf("stale build cleared the tab's SharedHostKey: %q", hostKey)
|
||||
}
|
||||
app.sharedHostsMu.Lock()
|
||||
entry := app.sharedHosts["root"]
|
||||
refs := 0
|
||||
if entry != nil {
|
||||
refs = entry.refs
|
||||
}
|
||||
app.sharedHostsMu.Unlock()
|
||||
if refs != 1 {
|
||||
t.Fatalf("shared host refs = %d after stale-build cleanup, want 1 (the live build's reference)", refs)
|
||||
}
|
||||
if got := tab.sessionLeaseRuntimeKey(); got != newKey {
|
||||
t.Fatalf("stale build released the replacement build's lease: key = %q, want %q", got, newKey)
|
||||
}
|
||||
|
||||
// Removal shape: the abandoned build's own key still on the tab is the
|
||||
// last reference and must be released.
|
||||
app.abandonSupersededBuild(tab, nil, "", newKey)
|
||||
lease, err := agent.TryAcquireSessionLease(newKey)
|
||||
if err != nil {
|
||||
t.Fatalf("matching-key abandon did not release the lease: %v", err)
|
||||
}
|
||||
lease.Release()
|
||||
}
|
||||
|
||||
// TestRebindInvalidatesInFlightAsyncBuildBeforeSnapshot reproduces the #5968
|
||||
// review scenario: an async startTabControllerBuild is stalled right after
|
||||
// binding its session lease (holding its controller, lease, and shared-host
|
||||
// reference), a rebind to a different session starts meanwhile, and the
|
||||
// stalled build then tries to finish. The rebind must bump the generation
|
||||
// BEFORE snapshotting tab.Ctrl, so the stale build can only fall into its
|
||||
// superseded branches: the rebound controller must survive un-overwritten,
|
||||
// the stale build's lease and host reference must be released, and the
|
||||
// replacement build's ownership must stay intact.
|
||||
func TestRebindInvalidatesInFlightAsyncBuildBeforeSnapshot(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
oldPath := filepath.Join(dir, "rebind-race-old.jsonl")
|
||||
newPath := filepath.Join(dir, "rebind-race-new.jsonl")
|
||||
writeHistoryTestSession(t, oldPath, "old prompt")
|
||||
writeHistoryTestSession(t, newPath, "new prompt")
|
||||
oldKey := sessionRuntimeKey(oldPath)
|
||||
newKey := sessionRuntimeKey(newPath)
|
||||
|
||||
app := NewApp()
|
||||
app.readyHook = func() {}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
SessionPath: oldPath,
|
||||
sink: &tabEventSink{tabID: "tab", app: app},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabs[tab.ID] = tab
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
t.Cleanup(func() {
|
||||
app.mu.RLock()
|
||||
ctrl := tab.Ctrl
|
||||
app.mu.RUnlock()
|
||||
if ctrl != nil {
|
||||
ctrl.Close()
|
||||
}
|
||||
tab.releaseSessionLease()
|
||||
})
|
||||
|
||||
// Stall the async build inside its lease bind: at that point it already
|
||||
// holds its controller, its lease, and its shared-host reference.
|
||||
stalled := make(chan struct{})
|
||||
releaseHook := make(chan struct{})
|
||||
var once sync.Once
|
||||
sessionLeaseAcquireHookForTest = func() {
|
||||
once.Do(func() {
|
||||
close(stalled)
|
||||
<-releaseHook
|
||||
})
|
||||
}
|
||||
t.Cleanup(func() { sessionLeaseAcquireHookForTest = nil })
|
||||
t.Cleanup(func() {
|
||||
select {
|
||||
case <-releaseHook:
|
||||
default:
|
||||
close(releaseHook)
|
||||
}
|
||||
})
|
||||
|
||||
// startTabControllerBuild only backgrounds the build when a Wails context
|
||||
// exists; expand its goroutine branch by hand so a.ctx can stay nil (the
|
||||
// nil-ctx emit guards are what every other build test relies on too).
|
||||
buildCtx, cancel := context.WithCancel(context.Background())
|
||||
app.mu.Lock()
|
||||
tab.buildGeneration++
|
||||
generation := tab.buildGeneration
|
||||
tab.buildCancel = cancel
|
||||
app.mu.Unlock()
|
||||
buildDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(buildDone)
|
||||
app.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
|
||||
}()
|
||||
select {
|
||||
case <-stalled:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("async build did not reach the lease bind")
|
||||
}
|
||||
|
||||
loaded, err := agent.LoadSession(newPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadSession(new): %v", err)
|
||||
}
|
||||
rebindErr := make(chan error, 1)
|
||||
go func() {
|
||||
rebindErr <- app.rebindTabToLoadedSessionPath(tab, newPath, loaded)
|
||||
}()
|
||||
|
||||
// Wait until the rebind's validation section has invalidated the async
|
||||
// build (startTabControllerBuild set generation 1; the rebind bumps to 2)
|
||||
// while the async build is still stalled pre-swap.
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for {
|
||||
app.mu.RLock()
|
||||
generation := tab.buildGeneration
|
||||
app.mu.RUnlock()
|
||||
if generation >= 2 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("rebind did not bump the build generation")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
|
||||
close(releaseHook)
|
||||
select {
|
||||
case err := <-rebindErr:
|
||||
if err != nil {
|
||||
t.Fatalf("rebindTabToLoadedSessionPath: %v", err)
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("rebind did not finish after releasing the stalled build")
|
||||
}
|
||||
select {
|
||||
case <-buildDone:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("stale async build did not finish its superseded cleanup")
|
||||
}
|
||||
|
||||
app.mu.RLock()
|
||||
builtCtrl := tab.Ctrl
|
||||
sessionPath := tab.SessionPath
|
||||
app.mu.RUnlock()
|
||||
if builtCtrl == nil {
|
||||
t.Fatal("rebind left the tab without a controller")
|
||||
}
|
||||
if got := builtCtrl.SessionPath(); got != newPath {
|
||||
t.Fatalf("stale async build overwrote the rebound controller: session path = %q, want %q", got, newPath)
|
||||
}
|
||||
if sessionPath != newPath {
|
||||
t.Fatalf("tab.SessionPath = %q, want %q", sessionPath, newPath)
|
||||
}
|
||||
if got := tab.sessionLeaseRuntimeKey(); got != newKey {
|
||||
t.Fatalf("tab lease key = %q, want the rebound session %q", got, newKey)
|
||||
}
|
||||
// The stale build's lease must be gone (released by its superseded
|
||||
// cleanup or replaced by the rebound build's adopt) — the old session
|
||||
// must be acquirable again.
|
||||
lease, err := agent.TryAcquireSessionLease(oldKey)
|
||||
if err != nil {
|
||||
t.Fatalf("stale build leaked its session lease: %v", err)
|
||||
}
|
||||
lease.Release()
|
||||
// Exactly one shared-host reference may remain: the rebound build's. The
|
||||
// stale build must have released its own.
|
||||
app.sharedHostsMu.Lock()
|
||||
refs := 0
|
||||
for _, entry := range app.sharedHosts {
|
||||
refs += entry.refs
|
||||
}
|
||||
app.sharedHostsMu.Unlock()
|
||||
if refs != 1 {
|
||||
t.Fatalf("shared host refs = %d after stale-build cleanup, want 1 (the rebound build's)", refs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetaForTabConcurrentWithBuildSwap polls MetaForTab (the frontend's boot
|
||||
// probe) while a fake build goroutine flips Ready/Label/StartupErr/model under
|
||||
// a.mu — the write pattern of buildTabControllerWithContext. Run with -race.
|
||||
func TestMetaForTabConcurrentWithBuildSwap(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
tab := &WorkspaceTab{ID: "tab", Scope: "project", WorkspaceRoot: t.TempDir()}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{"tab": tab},
|
||||
tabOrder: []string{"tab"},
|
||||
activeTabID: "tab",
|
||||
}
|
||||
|
||||
const iterations = 100
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for i := 0; i < iterations; i++ {
|
||||
app.mu.Lock()
|
||||
tab.Ready = !tab.Ready
|
||||
tab.Label = fmt.Sprintf("model-%d", i)
|
||||
tab.StartupErr = ""
|
||||
tab.model = fmt.Sprintf("provider/m%d", i)
|
||||
tab.goal = fmt.Sprintf("goal-%d", i)
|
||||
tab.tokenMode = "full"
|
||||
app.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
for i := 0; i < iterations; i++ {
|
||||
meta := app.MetaForTab("tab")
|
||||
if meta.EventChannel == "" {
|
||||
t.Fatal("MetaForTab returned zero meta for a live tab")
|
||||
}
|
||||
}
|
||||
<-done
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
// recoveryGCInterval bounds how often the background sweep repeats after the
|
||||
// startup run. Recovery branches accumulate slowly (only on a save conflict),
|
||||
// so a low frequency is plenty and keeps the disk scan off the hot path.
|
||||
const recoveryGCInterval = 6 * time.Hour
|
||||
|
||||
// startRecoveryGC waits for tab restore to complete, runs one sweep, then
|
||||
// repeats on an interval until the app context is cancelled. The wait is
|
||||
// load-bearing: restoreOrBuildTabs populates a.tabs asynchronously, and a
|
||||
// sweep against the pre-restore empty tab map would see every saved tab's
|
||||
// session as closed — and DeleteSession's tab-list persistence would then
|
||||
// overwrite desktop-tabs.json with that empty snapshot.
|
||||
func (a *App) startRecoveryGC() {
|
||||
a.goSafe("recoveryGC", func() {
|
||||
select {
|
||||
case <-a.tabsRestoredSignal():
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
}
|
||||
a.sweepReclaimableRecoveryBranches()
|
||||
ticker := time.NewTicker(recoveryGCInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-a.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
a.sweepReclaimableRecoveryBranches()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// sweepReclaimableRecoveryBranches trashes conflict-recovery branches that
|
||||
// preserve nothing unique (their content is covered by a still-present parent
|
||||
// session), were never continued on, sat idle past the grace period, and are
|
||||
// not held by any runtime. Trashing — never hard deletion — keeps every swept
|
||||
// branch recoverable from the session trash. Returns how many were reclaimed.
|
||||
func (a *App) sweepReclaimableRecoveryBranches() int {
|
||||
return a.reclaimRecoveryBranchesIn(recoveryGCDirs(), time.Now())
|
||||
}
|
||||
|
||||
func (a *App) reclaimRecoveryBranchesIn(dirs []string, now time.Time) int {
|
||||
reclaimed := 0
|
||||
for _, dir := range dirs {
|
||||
reclaimable, err := agent.ReclaimableRecoveryBranches(dir, now, agent.RecoveryGCGracePeriod)
|
||||
if err != nil {
|
||||
slog.Warn("desktop: scan reclaimable recovery branches", "dir", dir, "err", err)
|
||||
continue
|
||||
}
|
||||
for _, path := range reclaimable {
|
||||
// Re-check liveness right before disposal: the scan is a snapshot,
|
||||
// and the user may have opened the branch since. DeleteSession then
|
||||
// runs the full removal path (removal guard, runtime unbinding,
|
||||
// trash move), so even a miss here lands in the recoverable trash.
|
||||
if agent.SessionLeaseHeld(path) || a.sessionOpenInAnyTab(path) {
|
||||
continue
|
||||
}
|
||||
if err := a.DeleteSession(path); err != nil {
|
||||
slog.Warn("desktop: trash reclaimed recovery branch", "path", path, "err", err)
|
||||
continue
|
||||
}
|
||||
reclaimed++
|
||||
}
|
||||
}
|
||||
if reclaimed > 0 {
|
||||
slog.Info("desktop: moved redundant recovery branches to the session trash", "count", reclaimed)
|
||||
a.emitProjectTreeChanged()
|
||||
}
|
||||
return reclaimed
|
||||
}
|
||||
|
||||
// recoveryGCDirs returns every session directory the desktop lists sessions
|
||||
// from: the global desktop and legacy shared dirs plus each saved project's
|
||||
// session dirs, deduplicated.
|
||||
func recoveryGCDirs() []string {
|
||||
seen := map[string]bool{}
|
||||
var dirs []string
|
||||
add := func(dir string) {
|
||||
key := projectRootKey(dir)
|
||||
if dir == "" || seen[key] {
|
||||
return
|
||||
}
|
||||
seen[key] = true
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
add(desktopSessionDir(globalWorkspaceRoot()))
|
||||
add(config.SessionDir())
|
||||
for _, project := range loadProjectsFile().Projects {
|
||||
if root := normalizeProjectRoot(project.Root); root != "" {
|
||||
add(desktopSessionDir(root))
|
||||
add(config.ProjectSessionDir(root))
|
||||
}
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// sessionOpenInAnyTab reports whether any tab's current session is path.
|
||||
// Lease checks cover live runtimes; this additionally covers tabs that hold a
|
||||
// session without a lease (read-only channel views).
|
||||
func (a *App) sessionOpenInAnyTab(path string) bool {
|
||||
key := sessionRuntimeKey(path)
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
for _, tab := range a.tabs {
|
||||
if tab == nil {
|
||||
continue
|
||||
}
|
||||
if sessionRuntimeKey(tab.currentSessionPath()) == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// forkCoveredRecoveryBranch builds the reclaimable shape in dir: a conflict
|
||||
// fork whose parent went on to contain everything the fork preserved.
|
||||
func forkCoveredRecoveryBranch(t *testing.T, dir, name string) (parentPath, branchPath string) {
|
||||
t.Helper()
|
||||
parentPath = filepath.Join(dir, name+".jsonl")
|
||||
disk := agent.NewSession("sys")
|
||||
disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk " + name})
|
||||
if err := disk.Save(parentPath); err != nil {
|
||||
t.Fatalf("Save parent: %v", err)
|
||||
}
|
||||
stale := agent.NewSession("sys")
|
||||
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
stale.Add(provider.Message{Role: provider.RoleUser, Content: "local " + name})
|
||||
info, err := stale.SaveRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: parentPath})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRecoveryBranch: %v", err)
|
||||
}
|
||||
covering := agent.NewSession("")
|
||||
covering.Messages = append([]provider.Message(nil), stale.Snapshot()...)
|
||||
covering.Add(provider.Message{Role: provider.RoleAssistant, Content: "answered after recovery"})
|
||||
if err := covering.Save(parentPath); err != nil {
|
||||
t.Fatalf("Save covering parent: %v", err)
|
||||
}
|
||||
return parentPath, info.Path
|
||||
}
|
||||
|
||||
func TestRecoveryGCTrashesCoveredForkAndKeepsParent(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
parentPath, branchPath := forkCoveredRecoveryBranch(t, dir, "session")
|
||||
|
||||
app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
|
||||
if got := app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour)); got != 1 {
|
||||
t.Fatalf("reclaimed = %d, want 1", got)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(branchPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("reclaimed branch still present at %s (err=%v)", branchPath, err)
|
||||
}
|
||||
key := filepath.Base(branchPath)
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, key, key)
|
||||
if _, err := os.Stat(trashPath); err != nil {
|
||||
t.Fatalf("reclaimed branch should be in trash: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(parentPath); err != nil {
|
||||
t.Fatalf("parent session must be untouched: %v", err)
|
||||
}
|
||||
|
||||
// A second sweep is a no-op: nothing left to reclaim.
|
||||
if got := app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour)); got != 0 {
|
||||
t.Fatalf("second sweep reclaimed = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryGCSkipsBranchOpenInTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
_, branchPath := forkCoveredRecoveryBranch(t, dir, "open")
|
||||
|
||||
tab := &WorkspaceTab{ID: "tab", Scope: "global", SessionPath: branchPath, Ready: true}
|
||||
app := &App{tabs: map[string]*WorkspaceTab{"tab": tab}}
|
||||
if got := app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour)); got != 0 {
|
||||
t.Fatalf("reclaimed = %d, want 0 while the branch is open in a tab", got)
|
||||
}
|
||||
if _, err := os.Stat(branchPath); err != nil {
|
||||
t.Fatalf("open branch must be untouched: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecoveryGCFirstSweepWaitsForTabRestore forces the startup race the
|
||||
// review caught: a saved recovery tab exists in desktop-tabs.json but a.tabs
|
||||
// has not been populated yet. The GC's first sweep must wait for the restore
|
||||
// gate — sweeping early would judge the branch "not open in any tab" and
|
||||
// DeleteSession would persist the pre-restore (empty) tab list over the
|
||||
// user's saved one.
|
||||
func TestRecoveryGCFirstSweepWaitsForTabRestore(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
_, branchPath := forkCoveredRecoveryBranch(t, dir, "startup")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
app := &App{
|
||||
ctx: ctx,
|
||||
tabs: map[string]*WorkspaceTab{},
|
||||
tabsRestored: make(chan struct{}),
|
||||
}
|
||||
|
||||
swept := make(chan int, 1)
|
||||
go func() {
|
||||
select {
|
||||
case <-app.tabsRestoredSignal():
|
||||
case <-ctx.Done():
|
||||
swept <- -1
|
||||
return
|
||||
}
|
||||
swept <- app.reclaimRecoveryBranchesIn([]string{dir}, time.Now().Add(48*time.Hour))
|
||||
}()
|
||||
|
||||
// Gate still closed: the sweep must not have run — the branch is intact.
|
||||
select {
|
||||
case n := <-swept:
|
||||
t.Fatalf("sweep ran before tab restore completed (reclaimed=%d)", n)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
if _, err := os.Stat(branchPath); err != nil {
|
||||
t.Fatalf("branch touched before restore completed: %v", err)
|
||||
}
|
||||
|
||||
// Restore lands the saved tab holding the branch, then opens the gate:
|
||||
// the sweep runs and must skip the now-open branch.
|
||||
tab := &WorkspaceTab{ID: "tab", Scope: "global", SessionPath: branchPath, Ready: true}
|
||||
app.mu.Lock()
|
||||
app.tabs["tab"] = tab
|
||||
app.mu.Unlock()
|
||||
app.markTabsRestored()
|
||||
|
||||
if n := <-swept; n != 0 {
|
||||
t.Fatalf("post-restore sweep reclaimed = %d, want 0 (branch is open in a restored tab)", n)
|
||||
}
|
||||
if _, err := os.Stat(branchPath); err != nil {
|
||||
t.Fatalf("restored tab's branch must be untouched: %v", err)
|
||||
}
|
||||
|
||||
// markTabsRestored is idempotent (restore + recover paths may both fire).
|
||||
app.markTabsRestored()
|
||||
}
|
||||
@@ -34,6 +34,10 @@ func (t desktopCountingTool) Execute(context.Context, json.RawMessage) (string,
|
||||
}
|
||||
|
||||
func TestDesktopE2EBlocksRepeatedSuccessfulBashFileWrite(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping desktop E2E repeat-guard test in short mode")
|
||||
}
|
||||
|
||||
var calls int32
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(desktopCountingTool{name: "bash", calls: &calls})
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// TestRuntimeRebuildsEmitRuntimeRebuiltForTab pins the frontend contract the
|
||||
// prompt-chime dedupe depends on: every in-place controller replacement must
|
||||
// announce itself. Model, effort, and token-mode switches rebuild the tab's
|
||||
// controller WITHOUT an agent:ready — the rebuilt controller restarts its
|
||||
// approval/ask id counter at "1", so without runtime:rebuilt the frontend's
|
||||
// id-keyed chime dedupe mutes the first prompt after a switch.
|
||||
func TestRuntimeRebuildsEmitRuntimeRebuiltForTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
|
||||
setDesktopTestCredential(t, "NEW_MODEL_KEY", "sk-test")
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.DefaultModel = "old/old-model"
|
||||
cfg.Desktop.ProviderAccess = []string{"old", "new"}
|
||||
cfg.Providers = []config.ProviderEntry{
|
||||
{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_MODEL_KEY"},
|
||||
{Name: "new", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "deepseek-v4-pro", APIKeyEnv: "NEW_MODEL_KEY"},
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir session dir: %v", err)
|
||||
}
|
||||
sess := agent.NewSession("sys")
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "hello"})
|
||||
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
|
||||
path := filepath.Join(dir, "rebuild-events.jsonl")
|
||||
ctrl := control.New(control.Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "old", Sink: event.Discard})
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
// emitReady calls the Wails runtime directly; the ready hook keeps the
|
||||
// workspace-reconcile path (which SetEffortForTab can take) off the real
|
||||
// event bridge, which log.Fatals on a plain Background context.
|
||||
app.readyHook = func() {}
|
||||
|
||||
var mu sync.Mutex
|
||||
var rebuilt []string
|
||||
// The App-level queue must stay silent: ordering against the tab's agent
|
||||
// events only holds when the notice rides the tab sink's own queue, so a
|
||||
// notice showing up here means the routing regressed to the fallback.
|
||||
app.runtimeEvents.emit = func(_ context.Context, name string, _ ...interface{}) {
|
||||
if name == "runtime:rebuilt" {
|
||||
mu.Lock()
|
||||
rebuilt = append(rebuilt, "VIA-APP-QUEUE")
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
sinkEmit := func(_ context.Context, name string, payload ...interface{}) {
|
||||
if name != "runtime:rebuilt" {
|
||||
return
|
||||
}
|
||||
tabID := ""
|
||||
if len(payload) > 0 {
|
||||
tabID, _ = payload[0].(string)
|
||||
}
|
||||
mu.Lock()
|
||||
rebuilt = append(rebuilt, tabID)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab_rebuild_events",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: globalTabWorkspaceRoot(),
|
||||
Ready: true,
|
||||
model: "old/old-model",
|
||||
Ctrl: ctrl,
|
||||
sink: &tabEventSink{tabID: "tab_rebuild_events", app: app, ctx: context.Background()},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink.runtimeEvents.emit = sinkEmit
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
t.Cleanup(func() {
|
||||
if tab.Ctrl != nil {
|
||||
tab.Ctrl.Close()
|
||||
}
|
||||
})
|
||||
|
||||
waitCount := func(want int, step string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
mu.Lock()
|
||||
n := len(rebuilt)
|
||||
mu.Unlock()
|
||||
if n >= want {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
t.Fatalf("after %s: runtime:rebuilt events = %v, want %d", step, rebuilt, want)
|
||||
}
|
||||
|
||||
if err := app.SetModelForTab(tab.ID, "new/deepseek-v4-pro"); err != nil {
|
||||
t.Fatalf("SetModelForTab: %v", err)
|
||||
}
|
||||
waitCount(1, "model switch")
|
||||
|
||||
if err := app.SetEffortForTab(tab.ID, "high"); err != nil {
|
||||
t.Fatalf("SetEffortForTab: %v", err)
|
||||
}
|
||||
waitCount(2, "effort switch")
|
||||
|
||||
if err := app.SetTokenModeForTab(tab.ID, "economy"); err != nil {
|
||||
t.Fatalf("SetTokenModeForTab: %v", err)
|
||||
}
|
||||
waitCount(3, "token-mode switch")
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
for i, id := range rebuilt {
|
||||
if id == "VIA-APP-QUEUE" {
|
||||
t.Fatalf("event %d took the App-level fallback queue; it must ride the tab sink queue so it orders before the rebuilt controller's agent events (full: %v)", i, rebuilt)
|
||||
}
|
||||
if id != tab.ID {
|
||||
t.Fatalf("event %d carried tab id %q, want %q (full: %v)", i, id, tab.ID, rebuilt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Sanitized errors surfaced when a destructive or restore session operation
|
||||
// hits a real filesystem blocker. Like errSessionBusyElsewhere, they
|
||||
// intentionally carry no path, so raw OS error text never reaches the UI.
|
||||
var (
|
||||
errSessionFileLocked = errors.New("a session file is temporarily locked by another program (often antivirus or sync tools) — wait a moment and retry")
|
||||
errSessionFileAccessDenied = errors.New("access to a session file was denied — close programs that may be using it or check folder permissions, then retry")
|
||||
errSessionDiskFull = errors.New("not enough disk space to finish the operation — free some space and retry")
|
||||
)
|
||||
|
||||
// friendlySessionFileError rewrites raw OS-level filesystem errors from the
|
||||
// session trash/restore/purge flows (e.g. a Windows sharing violation while a
|
||||
// scanner holds a transcript) into the actionable, path-free errors above.
|
||||
// The original error is logged so diagnostics keep the path and errno.
|
||||
// Unrecognized errors — including already-sanitized ones like
|
||||
// errSessionBusyElsewhere — pass through unchanged.
|
||||
func friendlySessionFileError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var errno syscall.Errno
|
||||
if !errors.As(err, &errno) {
|
||||
return err
|
||||
}
|
||||
var friendly error
|
||||
switch {
|
||||
case isFileInUseErrno(errno):
|
||||
friendly = errSessionFileLocked
|
||||
case isAccessDeniedErrno(errno):
|
||||
friendly = errSessionFileAccessDenied
|
||||
case isDiskFullErrno(errno):
|
||||
friendly = errSessionDiskFull
|
||||
default:
|
||||
return err
|
||||
}
|
||||
slog.Warn("desktop: session file operation blocked", "err", err)
|
||||
return friendly
|
||||
}
|
||||
Loaded 100 of 416 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user