mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f24b095fcb | ||
|
|
0c86e03a67 | ||
|
|
786dc6d33e | ||
|
|
7c95b1c9dd | ||
|
|
1415df287e | ||
|
|
90082f993d | ||
|
|
6d8ec2220a | ||
|
|
17a5244606 | ||
|
|
36d697587c | ||
|
|
0f5a900c45 | ||
|
|
35e6cffead | ||
|
|
61fee7e38e | ||
|
|
a5aa171f23 | ||
|
|
26765dcdf6 | ||
|
|
80228bf4a7 | ||
|
|
9271090815 | ||
|
|
99cf4ae5f3 | ||
|
|
4bc7f43478 | ||
|
|
c303e077eb | ||
|
|
72be016952 | ||
|
|
ada5bdf8fd | ||
|
|
7aabfaeb87 | ||
|
|
4cb097d619 | ||
|
|
e37e035bd5 | ||
|
|
238d9a673b | ||
|
|
b4e4168947 | ||
|
|
288447c8f2 | ||
|
|
19b78f7ead | ||
|
|
ad763bce81 | ||
|
|
209cde2eff | ||
|
|
9b3a452f1f | ||
|
|
d4a416d47d | ||
|
|
fd04316dfa | ||
|
|
b42f0f6cd6 | ||
|
|
b47a757460 | ||
|
|
bf14218723 | ||
|
|
71533a6a4a | ||
|
|
2c0e6c9437 | ||
|
|
9fdca5e163 | ||
|
|
aae762db12 | ||
|
|
beb2946d4d | ||
|
|
01658c03d0 | ||
|
|
ec2cd41f58 | ||
|
|
5635adf7b7 | ||
|
|
4ce7029526 | ||
|
|
fce1223d19 | ||
|
|
7e68366c9e | ||
|
|
c993eaad24 | ||
|
|
671808a9cb | ||
|
|
0ac5bb9bb6 | ||
|
|
33902e96f3 | ||
|
|
6acd67f254 | ||
|
|
29bea9ff06 | ||
|
|
3ff2573dc3 | ||
|
|
0c44effe8d | ||
|
|
510abd2b69 | ||
|
|
41cfc15fe2 | ||
|
|
19d5707fce | ||
|
|
80ae90e856 | ||
|
|
c72b0c54dc | ||
|
|
82518d1bcc | ||
|
|
39eea11f70 | ||
|
|
7fc5c669eb | ||
|
|
8c8f9410e5 | ||
|
|
0d50826eef | ||
|
|
f6d6f79ad7 | ||
|
|
a5f500fa15 | ||
|
|
5ff3021663 | ||
|
|
4dca2b0b24 | ||
|
|
2c127bed35 | ||
|
|
10e1719096 | ||
|
|
5c89fea841 | ||
|
|
a953a57731 | ||
|
|
67264bc66a | ||
|
|
1d42053b08 | ||
|
|
34cfca95d0 | ||
|
|
d21b67abc4 | ||
|
|
0c542ceb7c | ||
|
|
260dc77c1e | ||
|
|
e73a78d685 | ||
|
|
71fba1d551 | ||
|
|
48a78a09f2 | ||
|
|
a9c7947a8f | ||
|
|
e67b2aa824 | ||
|
|
7592c2391e | ||
|
|
51f75e2418 | ||
|
|
2512cf7e17 | ||
|
|
c060a2e060 | ||
|
|
6ffea769e8 | ||
|
|
5d4df5da70 | ||
|
|
fe5a8f12b8 | ||
|
|
0dab33c3d0 | ||
|
|
0bad556717 | ||
|
|
fc53cef919 | ||
|
|
ac91369c31 | ||
|
|
cf9ee2823f | ||
|
|
34af76be31 | ||
|
|
48d5519d70 | ||
|
|
ee6c8e6c29 | ||
|
|
bf0be67b7c | ||
|
|
5928fe8344 | ||
|
|
59e6f7e4bf | ||
|
|
d9b17274ba | ||
|
|
4d0eaf082c | ||
|
|
416eae24ad | ||
|
|
d25f7833c3 | ||
|
|
2f5d44956b | ||
|
|
7bbacfde21 | ||
|
|
8f5eef019c | ||
|
|
bc7a8b68b4 | ||
|
|
ef870e951a | ||
|
|
a5ddf58c12 | ||
|
|
79e4d1a7a8 | ||
|
|
faa634b769 | ||
|
|
aae3851a5c | ||
|
|
ee278d86db | ||
|
|
aeb28447ff | ||
|
|
4721c321aa | ||
|
|
d231c86934 | ||
|
|
0fdcc8a63a | ||
|
|
ee41d1b88d | ||
|
|
3de328c1bf | ||
|
|
f24249f4f1 | ||
|
|
6a2a84e039 | ||
|
|
58de476bc8 | ||
|
|
9ec81f7099 | ||
|
|
2b5b709b91 | ||
|
|
778a443e19 | ||
|
|
2f16450e39 | ||
|
|
3c86a352c5 | ||
|
|
0c5e4fdb65 | ||
|
|
12fdaa830b | ||
|
|
1c3c93752d | ||
|
|
68d72e6e5f | ||
|
|
e235647a39 | ||
|
|
a3358ef847 | ||
|
|
ad972af1f9 | ||
|
|
35db95a66e | ||
|
|
61f6e2cad0 | ||
|
|
7d9e0d35af | ||
|
|
952e4ac350 | ||
|
|
8b9674f460 | ||
|
|
89e8c3d543 | ||
|
|
953b98c23a | ||
|
|
6b31b35671 | ||
|
|
8ddaad8b9a | ||
|
|
865adc6f1d | ||
|
|
674be729bb | ||
|
|
0793472e30 | ||
|
|
0d41d616b3 | ||
|
|
129caed9e8 | ||
|
|
5a5b50a985 | ||
|
|
3062ab652f | ||
|
|
f7c693788c | ||
|
|
11d5462705 | ||
|
|
7547d38c59 | ||
|
|
5e3712f60e | ||
|
|
4bd353eb31 | ||
|
|
e125de6f07 | ||
|
|
0e59991860 | ||
|
|
1f9e09c736 | ||
|
|
f3490cd370 | ||
|
|
ac493a7a52 | ||
|
|
1d8c7a4223 | ||
|
|
1e5c0674ea | ||
|
|
1985115723 | ||
|
|
51b388d4cb | ||
|
|
91bba93103 | ||
|
|
9549af0817 | ||
|
|
32c5bff6f2 | ||
|
|
5cb1a78394 | ||
|
|
1861115cd2 | ||
|
|
35cdd64dad | ||
|
|
e36b6aa803 | ||
|
|
39c6820689 | ||
|
|
691624d8da | ||
|
|
a04460e738 | ||
|
|
33c3145665 | ||
|
|
3bdacd8d70 | ||
|
|
b0d74fdf09 | ||
|
|
4c1b0aeb50 | ||
|
|
c36ca274c3 | ||
|
|
449c98f82e | ||
|
|
e6fa7e8314 | ||
|
|
f8ea772bfa | ||
|
|
f5cfa5ca64 | ||
|
|
f76aad817d | ||
|
|
9847087ec5 | ||
|
|
d3b10d04b4 | ||
|
|
9d403e91a7 | ||
|
|
af71a90ff7 | ||
|
|
90a8362bb4 | ||
|
|
fb9af7d3df | ||
|
|
db3b4ccbcb | ||
|
|
b36a7e3d54 | ||
|
|
4727e045d8 | ||
|
|
10a6d8c23a | ||
|
|
b74121b60f | ||
|
|
fb8a8afec1 | ||
|
|
0e2b986f11 | ||
|
|
be830422cd | ||
|
|
78f955923a | ||
|
|
2b16d4242b | ||
|
|
d22d871238 | ||
|
|
d4fffb5cdf | ||
|
|
c6ddaab47a | ||
|
|
2fec84022f | ||
|
|
5e18ab64ba | ||
|
|
d7addef4d8 | ||
|
|
f63bee1444 | ||
|
|
dfa11ead16 | ||
|
|
c5c5557cf8 | ||
|
|
15ed6bc8db | ||
|
|
4cfe58884d | ||
|
|
b0f57300a1 | ||
|
|
f18c137ff5 | ||
|
|
689351a497 | ||
|
|
8fd5c1f6e2 | ||
|
|
23aadfb6e0 | ||
|
|
26e004f4ba | ||
|
|
b41fc3cb72 | ||
|
|
642f937ab4 | ||
|
|
3d35be9099 | ||
|
|
a77ba2e509 | ||
|
|
1b0dbe093e | ||
|
|
9c74e799c3 | ||
|
|
bd27491c11 | ||
|
|
42147e9ac0 | ||
|
|
5cbf8f60f6 | ||
|
|
75e84414ac | ||
|
|
1bdf903ee8 | ||
|
|
41e0c542ca | ||
|
|
a14b28d1f7 | ||
|
|
8675031b19 | ||
|
|
56c7f4ad74 | ||
|
|
5482b42666 | ||
|
|
46feeb71af | ||
|
|
c9da184528 | ||
|
|
4eeeb0a8eb | ||
|
|
4ea9ceee10 | ||
|
|
0d5b471f8c | ||
|
|
df05ddd1bb | ||
|
|
ec8e8efba3 | ||
|
|
a2073c6553 | ||
|
|
9116a3b304 | ||
|
|
fe319846a1 | ||
|
|
9fc382af24 | ||
|
|
a91c6c2eef | ||
|
|
fcb12ed886 | ||
|
|
b528d5f416 | ||
|
|
88dbccb552 | ||
|
|
4f2fb94998 | ||
|
|
82341f8bf5 | ||
|
|
88feabab21 | ||
|
|
c7fc96cda2 | ||
|
|
87633b8a17 | ||
|
|
8266d6c8d1 | ||
|
|
3ba9825b60 | ||
|
|
c50f51b29b | ||
|
|
3f972b9b8d | ||
|
|
66991e1983 | ||
|
|
6fde1e899b | ||
|
|
f2aaa68ccd | ||
|
|
42e8dfdc48 | ||
|
|
5f236a5def | ||
|
|
7d114dc2fe | ||
|
|
a1eca07990 | ||
|
|
794c6be461 | ||
|
|
11e4c54104 | ||
|
|
a3e2e6f62f | ||
|
|
4a05f32d85 | ||
|
|
2a2d0e674a | ||
|
|
38a226ab6c | ||
|
|
5dfbbe00ef | ||
|
|
b297e9616c | ||
|
|
69d0944e63 | ||
|
|
56b5fca57c | ||
|
|
12c4dbb624 | ||
|
|
e4bbe48890 | ||
|
|
88b9fa0869 | ||
|
|
22e18cafc0 | ||
|
|
b75f2f0dbb | ||
|
|
1eb59c3cd5 | ||
|
|
15d335b86b | ||
|
|
9e9f24a77c | ||
|
|
427fba0f27 | ||
|
|
ae309c967a | ||
|
|
df04b11b06 | ||
|
|
ab24371d98 | ||
|
|
7bb2054edc | ||
|
|
d9b90207be | ||
|
|
eee6334921 | ||
|
|
72a2804b6a | ||
|
|
6bd942a090 | ||
|
|
97b42efe4a | ||
|
|
94f627c535 | ||
|
|
087aaa3a9b | ||
|
|
93b47a036a | ||
|
|
08e8e79bcf | ||
|
|
8847d5b9b8 | ||
|
|
99cf86bdf1 | ||
|
|
da051f9448 | ||
|
|
42a21500f9 | ||
|
|
3a06203b1c | ||
|
|
75c4308810 | ||
|
|
37360241df | ||
|
|
dfc9f19563 | ||
|
|
cea216a57a | ||
|
|
876c3dff56 | ||
|
|
c46e3af1c2 | ||
|
|
c2f4976815 | ||
|
|
2a6064c75b | ||
|
|
9e9c818324 | ||
|
|
d49358fe8a | ||
|
|
f72874d49b | ||
|
|
7bcea588d6 | ||
|
|
6849308cef | ||
|
|
6076a35731 | ||
|
|
a7acd8a3f4 | ||
|
|
2afbe59efc | ||
|
|
564c5a710b | ||
|
|
dc2fdebe97 | ||
|
|
d7229fe211 | ||
|
|
ede7ceef00 | ||
|
|
d8a4288862 | ||
|
|
9546b1f314 | ||
|
|
cc38074026 | ||
|
|
5664dc8ba7 | ||
|
|
d8c319d144 | ||
|
|
cf17cad099 | ||
|
|
51a7b83e28 | ||
|
|
9fede8b281 | ||
|
|
8d7764c61a | ||
|
|
79449ef801 | ||
|
|
0e78ef425e | ||
|
|
0d42ffbbdc | ||
|
|
70097ea087 | ||
|
|
1c62489dac | ||
|
|
cb74987bd9 | ||
|
|
3add2995d8 | ||
|
|
1fd5d7bb99 | ||
|
|
85de763b42 | ||
|
|
2e9680edd0 | ||
|
|
4a989f4d6a | ||
|
|
1f3a67b57e | ||
|
|
be1d9c359c | ||
|
|
e94e549799 | ||
|
|
1522bac58d | ||
|
|
558fb5d474 | ||
|
|
969f7cd436 | ||
|
|
5c16cc1408 | ||
|
|
3ad7439f92 | ||
|
|
86a4958057 | ||
|
|
3c5e51a214 | ||
|
|
10c79d6e8b | ||
|
|
49fad9d6f8 | ||
|
|
bf8490df5b | ||
|
|
9df2ec56d9 | ||
|
|
b0d7805b02 | ||
|
|
484263db3f | ||
|
|
fa8b24caa5 | ||
|
|
6afdb6805b | ||
|
|
643916d687 | ||
|
|
165c43fc5b | ||
|
|
1d0305478a | ||
|
|
5447d1c5a6 |
No files matched your search
@@ -7,10 +7,7 @@
|
||||
/.signpath/ @SivanCola @esengine
|
||||
/.github/workflows/release-stable.yml @SivanCola @esengine
|
||||
/.github/workflows/release-stable-trigger.yml @SivanCola @esengine
|
||||
/.github/workflows/release-preview.yml @SivanCola @esengine
|
||||
/.github/workflows/release-cli-trigger.yml @SivanCola @esengine
|
||||
/.github/workflows/release-desktop.yml @SivanCola @esengine
|
||||
/.github/workflows/release-desktop-trigger.yml @SivanCola @esengine
|
||||
/scripts/complete-signpath-request.ps1 @SivanCola @esengine
|
||||
/scripts/package-windows-desktop.sh @SivanCola @esengine
|
||||
/scripts/verify-windows-authenticode.ps1 @SivanCola @esengine
|
||||
|
||||
@@ -15,6 +15,16 @@ release workflow then asks that reporter to verify once the fix ships.
|
||||
|
||||
-
|
||||
|
||||
## Documentation impact
|
||||
|
||||
Documentation-impact: TODO
|
||||
|
||||
For changes to user-visible CLI, Desktop, configuration, provider, permission,
|
||||
or tool behavior, use one of:
|
||||
|
||||
- `Documentation-impact: updated - <what changed>` and update `docs/*.md`.
|
||||
- `Documentation-impact: none - <why the embedded documentation remains correct>`.
|
||||
|
||||
## Cache impact
|
||||
|
||||
Cache-impact: TODO
|
||||
|
||||
@@ -129,15 +129,16 @@ jobs:
|
||||
run: go test ./...
|
||||
|
||||
# Pull requests run a Windows smoke suite: the packages that actually
|
||||
# carry *_windows.go code, plus cmd/. The full ./... sweep stays on
|
||||
# pushes to main-v2; the Unix legs always run the full suite.
|
||||
# carry *_windows.go code, the startup/checkpoint packages with portable
|
||||
# filesystem contracts, plus cmd/. The full ./... sweep stays on pushes
|
||||
# to main-v2; the Unix legs always run the full suite.
|
||||
- name: test (Windows smoke)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -p 4 -timeout=3m ./internal/agent/... ./internal/cli/... ./internal/control/... ./internal/filelock/... ./internal/fileutil/... ./internal/hook/... ./internal/mcplaunch/... ./internal/notify/... ./internal/proc/... ./internal/remote/... ./internal/repair/... ./internal/sandbox/... ./internal/sysproxy/... ./internal/workspacelease/... ./cmd/...
|
||||
run: go test -p 4 -timeout=3m ./internal/agent/... ./internal/boot/... ./internal/checkpoint/... ./internal/cli/... ./internal/control/... ./internal/desktoplauncher/... ./internal/filelock/... ./internal/fileutil/... ./internal/hook/... ./internal/instruction/... ./internal/mcplaunch/... ./internal/notify/... ./internal/proc/... ./internal/remote/... ./internal/repair/... ./internal/sandbox/... ./internal/sysproxy/... ./internal/workspacelease/... ./cmd/...
|
||||
|
||||
- name: test (full)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
|
||||
@@ -152,6 +153,12 @@ jobs:
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -p 4 -timeout=3m ./...
|
||||
|
||||
- name: test (Scoop desktop launch)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows'
|
||||
timeout-minutes: 10
|
||||
shell: powershell
|
||||
run: .\scripts\test-scoop-desktop-launch.ps1
|
||||
|
||||
race:
|
||||
needs: changes
|
||||
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.code != 'false')
|
||||
@@ -259,6 +266,9 @@ jobs:
|
||||
- name: Test integrated terminal frontend
|
||||
run: pnpm --dir frontend test:terminal
|
||||
|
||||
- name: Test usage statistics frontend
|
||||
run: pnpm --dir frontend test:usage-stats
|
||||
|
||||
- name: vet
|
||||
run: go vet ./...
|
||||
|
||||
@@ -398,12 +408,15 @@ jobs:
|
||||
go run github.com/rhysd/actionlint/cmd/[email protected] \
|
||||
-ignore 'label "windows-11-arm" is unknown' \
|
||||
.github/workflows/release-stable.yml \
|
||||
.github/workflows/release-preview.yml \
|
||||
.github/workflows/release-cli-trigger.yml \
|
||||
.github/workflows/prepare-release-notes.yml \
|
||||
.github/workflows/release-stable-trigger.yml \
|
||||
.github/workflows/release.yml \
|
||||
.github/workflows/release-npm.yml \
|
||||
.github/workflows/release-desktop.yml
|
||||
.github/workflows/release-desktop.yml \
|
||||
.github/workflows/release-verify-issues.yml \
|
||||
.github/workflows/docs-impact.yml
|
||||
bash scripts/release-workflows.test.sh
|
||||
node scripts/check-single-release-public-contract.mjs
|
||||
|
||||
# The site/ auth client has security-sensitive redirect-validation logic
|
||||
# (safeNext) covered by node:test unit tests. Those tests use only Node
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Documentation impact guard
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
branches: [main-v2]
|
||||
types: [opened, synchronize, reopened, edited, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: docs-impact-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
docs-impact:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Execute only the trusted base-branch checker; never run contributor code
|
||||
# from a pull_request_target workflow.
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
- name: Collect PR changed files
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
gh api --paginate "repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" \
|
||||
--jq '.[].filename' > "$RUNNER_TEMP/docs-impact-files.txt"
|
||||
|
||||
- name: Check documentation impact declaration
|
||||
env:
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
DOCS_IMPACT_CHANGED_FILES_FILE: ${{ runner.temp }}/docs-impact-files.txt
|
||||
run: bash scripts/check-docs-impact.sh
|
||||
@@ -55,6 +55,18 @@ jobs:
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install and verify Linux sandbox backend
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y bubblewrap
|
||||
if sysctl -n kernel.unprivileged_userns_clone >/dev/null 2>&1; then
|
||||
sudo sysctl -w kernel.unprivileged_userns_clone=1
|
||||
fi
|
||||
if sysctl -n kernel.apparmor_restrict_unprivileged_userns >/dev/null 2>&1; then
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
fi
|
||||
bwrap --ro-bind / / --dev /dev --proc /proc -- true
|
||||
|
||||
- name: Build harness + fallback agent from the default branch
|
||||
# Harness (e2ebench) and suite always come from main-v2 so a PR can't weaken
|
||||
# its own grader or tests. The agent is rebuilt from the PR head below; this
|
||||
@@ -93,10 +105,12 @@ jobs:
|
||||
echo "agent under test: $src"
|
||||
|
||||
- name: Write provider config
|
||||
env:
|
||||
REASONIX_HOME: ${{ runner.temp }}/reasonix-e2e-home
|
||||
# User config covers suite tasks (they run in temp dirs); the repo-root copy
|
||||
# covers diff mode (the agent runs in the repo root, where project config wins).
|
||||
run: |
|
||||
mkdir -p ~/.config/reasonix
|
||||
mkdir -p "$REASONIX_HOME"
|
||||
cat > /tmp/reasonix-e2e.toml <<EOF
|
||||
default_model = "e2e"
|
||||
|
||||
@@ -120,19 +134,25 @@ jobs:
|
||||
[codegraph]
|
||||
enabled = false
|
||||
EOF
|
||||
cp /tmp/reasonix-e2e.toml ~/.config/reasonix/config.toml
|
||||
cp /tmp/reasonix-e2e.toml "$REASONIX_HOME/config.toml"
|
||||
cp /tmp/reasonix-e2e.toml ./reasonix.toml
|
||||
|
||||
- name: Run e2e
|
||||
env:
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
REASONIX_HOME: ${{ runner.temp }}/reasonix-e2e-home
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
if [ -z "$DEEPSEEK_API_KEY" ]; then
|
||||
printf 'missing DEEPSEEK_API_KEY secret\n\nAdd a repository secret named DEEPSEEK_API_KEY to enable the e2e bot.\n' > report.md
|
||||
exit 0
|
||||
exit 1
|
||||
fi
|
||||
# Provider credentials are deliberately loaded only from Reasonix's
|
||||
# global credential file, not inherited process variables. Keep this
|
||||
# ephemeral runner copy private and outside the checked-out project.
|
||||
umask 077
|
||||
printf 'DEEPSEEK_API_KEY=%s\n' "$DEEPSEEK_API_KEY" > "$REASONIX_HOME/.env"
|
||||
if printf '%s' "$COMMENT_BODY" | grep -q '/e2e[[:space:]]\+diff'; then
|
||||
ATTEMPTS=$(printf '%s' "$COMMENT_BODY" | sed -nE 's@.*/e2e[[:space:]]+diff[[:space:]]+x([0-9]+).*@\1@p' | head -1)
|
||||
[ -z "$ATTEMPTS" ] && ATTEMPTS=1
|
||||
@@ -142,10 +162,21 @@ jobs:
|
||||
BASE=$(git merge-base "origin/$BASE_REF" HEAD)
|
||||
"$RUNNER_TEMP/e2ebench" -mode diff -bin "${{ steps.agent.outputs.bin }}" -repo . -base "$BASE" -model e2e -attempts "$ATTEMPTS" -out report.md
|
||||
else
|
||||
"$RUNNER_TEMP/e2ebench" -bin "${{ steps.agent.outputs.bin }}" -suite "$RUNNER_TEMP/suite" -model e2e -out report.md -json report.json -budget 400000
|
||||
# The current five-task baseline can exceed 400k after only three
|
||||
# successful tasks. Keep bounded headroom so every scenario is graded.
|
||||
"$RUNNER_TEMP/e2ebench" -bin "${{ steps.agent.outputs.bin }}" -suite "$RUNNER_TEMP/suite" -model e2e -out report.md -json report.json -budget 800000
|
||||
node -e '
|
||||
const results = require("./report.json");
|
||||
const unsuccessful = results.filter((result) => !result.Passed || result.Skipped);
|
||||
if (results.length === 0 || unsuccessful.length > 0) {
|
||||
console.error(`e2e suite incomplete: ${unsuccessful.length}/${results.length} unsuccessful`);
|
||||
process.exit(1);
|
||||
}
|
||||
'
|
||||
fi
|
||||
|
||||
- name: Post report
|
||||
if: always() && hashFiles('report.md') != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TRIGGER_USER: ${{ github.event.comment.user.login }}
|
||||
|
||||
@@ -22,14 +22,29 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const body = context.payload.issue.body || '';
|
||||
// Pull the "Version line" section out of the rendered issue form.
|
||||
const section = body.split(/^###\s+/m).find(s => /^Version line/i.test(s)) || '';
|
||||
const m = section.match(/\bv([12])\b/i);
|
||||
const sectionOf = (name) =>
|
||||
body.split(/^###\s+/m).find((s) => new RegExp(`^${name}`, 'i').test(s)) || '';
|
||||
|
||||
const m = sectionOf('Version line').match(/\bv([12])\b/i);
|
||||
if (!m) {
|
||||
core.info('No version line found; nothing to label.');
|
||||
return;
|
||||
}
|
||||
const choice = 'v' + m[1];
|
||||
let choice = 'v' + m[1];
|
||||
|
||||
// The dropdown is a choice, and v2 sits first, so it gets picked by
|
||||
// reporters who are actually on 0.x. `Exact version` is pasted from
|
||||
// `reasonix --version`, so when it parses it is the better evidence:
|
||||
// 0.x is the TypeScript line, 1.x the Go rewrite. Anything that does
|
||||
// not parse (a commit sha, "main-v2") leaves the dropdown to decide.
|
||||
const exact = sectionOf('Exact version').match(/\b(\d+)\.(\d+)\.(\d+)/);
|
||||
if (exact) {
|
||||
const fromVersion = Number(exact[1]) === 0 ? 'v1' : 'v2';
|
||||
if (fromVersion !== choice) {
|
||||
core.info(`Version line says ${choice} but ${exact[0]} says ${fromVersion}; trusting the version.`);
|
||||
choice = fromVersion;
|
||||
}
|
||||
}
|
||||
const other = choice === 'v2' ? 'v1' : 'v2';
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
name: Prepare release notes
|
||||
name: Prepare release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Exact release version without a tag prefix (for example 1.18.0 or 1.19.0-preview.3)"
|
||||
description: "Official version without a tag prefix (for example 1.20.0)"
|
||||
required: true
|
||||
type: string
|
||||
from_tag:
|
||||
description: "Previous release tag (defaults to the newest catalog entry)"
|
||||
required: false
|
||||
type: string
|
||||
target_pr:
|
||||
description: "Optional open same-repository PR into main-v2 to reuse instead of opening a release-only PR"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -36,51 +28,24 @@ jobs:
|
||||
- name: Select the review branch
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
TARGET_PR: ${{ inputs.target_pr }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git fetch origin main-v2
|
||||
|
||||
if [ -n "$TARGET_PR" ]; then
|
||||
if [[ ! "$TARGET_PR" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "::error::target_pr must be a positive pull request number"
|
||||
exit 1
|
||||
fi
|
||||
mapfile -t pr_state < <(
|
||||
gh pr view "$TARGET_PR" --repo "$REPOSITORY" \
|
||||
--json state,baseRefName,headRefName,isCrossRepository \
|
||||
--jq '[.state, .baseRefName, .headRefName, (.isCrossRepository | tostring)][]'
|
||||
)
|
||||
if [ "${pr_state[0]}" != "OPEN" ] || [ "${pr_state[1]}" != "main-v2" ]; then
|
||||
echo "::error::target_pr must be an open pull request into main-v2"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${pr_state[3]}" != "false" ]; then
|
||||
echo "::error::target_pr comes from a fork; the repository token cannot safely update that branch"
|
||||
exit 1
|
||||
fi
|
||||
branch="${pr_state[2]}"
|
||||
if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
||||
echo "::error::version must be MAJOR.MINOR.PATCH"
|
||||
exit 1
|
||||
fi
|
||||
branch="release-notes/v${VERSION}"
|
||||
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
|
||||
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
|
||||
git checkout -b "$branch" --track "origin/$branch"
|
||||
if ! git merge-base --is-ancestor origin/main-v2 HEAD; then
|
||||
echo "::error::target_pr must include the latest main-v2 before release notes are generated"
|
||||
exit 1
|
||||
fi
|
||||
echo "RELEASE_NOTES_PR=$TARGET_PR" >> "$GITHUB_ENV"
|
||||
git merge --no-edit origin/main-v2
|
||||
else
|
||||
branch="release-notes/v${VERSION#v}"
|
||||
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
|
||||
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
|
||||
git checkout -b "$branch" --track "origin/$branch"
|
||||
git merge --no-edit origin/main-v2
|
||||
else
|
||||
git checkout -b "$branch"
|
||||
fi
|
||||
echo "RELEASE_NOTES_PR=" >> "$GITHUB_ENV"
|
||||
git checkout -b "$branch"
|
||||
fi
|
||||
echo "RELEASE_NOTES_BRANCH=$branch" >> "$GITHUB_ENV"
|
||||
|
||||
@@ -90,12 +55,9 @@ jobs:
|
||||
DEEPSEEK_MODEL: deepseek-v4-pro
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
FROM_TAG: ${{ inputs.from_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=(--version "$VERSION")
|
||||
if [ -n "$FROM_TAG" ]; then args+=(--from "$FROM_TAG"); fi
|
||||
node scripts/generate-release-notes.mjs "${args[@]}"
|
||||
node scripts/generate-release-notes.mjs --version "$VERSION"
|
||||
node scripts/release-notes.mjs validate
|
||||
node --test scripts/release-notes.test.mjs
|
||||
node scripts/release-notes.mjs render --version "$VERSION" --output /tmp/release-notes.md
|
||||
@@ -121,12 +83,19 @@ jobs:
|
||||
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing."
|
||||
fi
|
||||
git push -u origin "$RELEASE_NOTES_BRANCH"
|
||||
if [ -n "$RELEASE_NOTES_PR" ]; then
|
||||
echo "Updated existing release-bound PR #$RELEASE_NOTES_PR"
|
||||
elif ! gh pr list --head "$RELEASE_NOTES_BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then
|
||||
gh pr create \
|
||||
if ! gh pr list --head "$RELEASE_NOTES_BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then
|
||||
if ! gh pr create \
|
||||
--base main-v2 \
|
||||
--head "$RELEASE_NOTES_BRANCH" \
|
||||
--title "docs(release): 准备 v${VERSION#v} 中英更新日志" \
|
||||
--body-file /tmp/release-notes.md
|
||||
--body-file /tmp/release-notes.md; then
|
||||
echo "::warning::GitHub Actions could not open the PR; the reviewed branch is preserved."
|
||||
{
|
||||
echo "## Manual PR handoff"
|
||||
echo
|
||||
echo '```sh'
|
||||
echo "gh pr create --repo ${{ github.repository }} --base main-v2 --head $RELEASE_NOTES_BRANCH --fill"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
fi
|
||||
@@ -1,39 +0,0 @@
|
||||
name: Relay CLI prerelease
|
||||
run-name: Relay CLI prerelease ${{ github.ref_name }}
|
||||
|
||||
# Keep native CLI publication on protected main-v2. The tag-triggered workflow
|
||||
# carries only the immutable candidate tag and its classified channel into the
|
||||
# protected release control plane.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*-*"]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
relay:
|
||||
name: dispatch protected CLI control plane
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dispatch release.yml on main-v2
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
CONTROL_PLANE_REF: ${{ github.event.repository.default_branch }}
|
||||
with:
|
||||
script: |
|
||||
if (process.env.CONTROL_PLANE_REF !== 'main-v2') {
|
||||
core.setFailed(`Unexpected default branch: ${process.env.CONTROL_PLANE_REF}`);
|
||||
return;
|
||||
}
|
||||
const tag = process.env.RELEASE_TAG;
|
||||
const preview = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-preview\.(?:0|[1-9]\d*)$/.test(tag);
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: preview ? 'release-preview.yml' : 'release.yml',
|
||||
ref: process.env.CONTROL_PLANE_REF,
|
||||
inputs: preview ? { tag } : { channel: 'stable', tag },
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
name: Relay desktop prerelease
|
||||
run-name: Relay desktop prerelease ${{ github.ref_name }}
|
||||
|
||||
# Keep the production signing build on protected main-v2. The tag-triggered
|
||||
# relay carries only the immutable candidate tag into the protected workflow.
|
||||
on:
|
||||
push:
|
||||
tags: ["desktop-v*-*"]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
relay:
|
||||
name: dispatch protected desktop control plane
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dispatch release-desktop.yml on main-v2
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
CONTROL_PLANE_REF: ${{ github.event.repository.default_branch }}
|
||||
with:
|
||||
script: |
|
||||
if (process.env.CONTROL_PLANE_REF !== 'main-v2') {
|
||||
core.setFailed(`Unexpected default branch: ${process.env.CONTROL_PLANE_REF}`);
|
||||
return;
|
||||
}
|
||||
if (/^desktop-v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-preview\.(?:0|[1-9]\d*)$/.test(process.env.RELEASE_TAG)) {
|
||||
core.setFailed(`Desktop Preview must be dispatched without a Git tag: ${process.env.RELEASE_TAG}`);
|
||||
return;
|
||||
}
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'release-desktop.yml',
|
||||
ref: process.env.CONTROL_PLANE_REF,
|
||||
inputs: {
|
||||
channel: 'stable',
|
||||
tag: process.env.RELEASE_TAG,
|
||||
},
|
||||
});
|
||||
@@ -1,10 +1,8 @@
|
||||
name: Release desktop
|
||||
|
||||
# Desktop (Wails) release line. Stable releases are called by
|
||||
# release-stable.yml after its single GitHub environment approval. Desktop
|
||||
# prerelease tags are relayed by release-desktop-trigger.yml to this workflow on
|
||||
# protected main-v2. Production SignPath requests therefore always have the
|
||||
# exact main-v2 origin; both public channels use the production policy.
|
||||
# Desktop (Wails) release line. Official releases are called by
|
||||
# release-stable.yml after its single GitHub environment approval. Production
|
||||
# SignPath requests therefore have the exact protected main-v2 origin.
|
||||
#
|
||||
# Wails cannot cross-compile a CGO/WebKit binary, so build/ fans out to one native
|
||||
# runner per platform. Artifacts are minisign-signed (MINISIGN_* secrets), a
|
||||
@@ -12,27 +10,21 @@ name: Release desktop
|
||||
# release and mirrored to R2 (the updater reads R2 first, then the crash worker
|
||||
# release gateway; stable desktop releases own GitHub's repository-wide "latest").
|
||||
#
|
||||
# The same build/sign/manifest pipeline serves two channels. Approved
|
||||
# orchestrators publish Stable or Preview, while manual dispatch supports
|
||||
# Stable/RC recovery and non-publishing Preview signing checks. Preview advances
|
||||
# only R2's preview/ pointer and never appears on the GitHub releases page. The
|
||||
# legacy canary/ pointer is mirrored for clients released before the rename.
|
||||
# Historical workflow-call inputs still understand Preview artifacts so old
|
||||
# runs remain diagnosable. They are not exposed by manual dispatch and cannot
|
||||
# create a new public Preview release.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: "Release channel"
|
||||
description: "Recovery channel"
|
||||
type: choice
|
||||
options: [stable, preview]
|
||||
options: [stable]
|
||||
default: stable
|
||||
tag:
|
||||
description: "stable: tag to publish (e.g. desktop-v1.1.0)"
|
||||
required: false
|
||||
type: string
|
||||
base_version:
|
||||
description: "Preview signing checks: intended next stable version, e.g. 1.5.0"
|
||||
required: false
|
||||
type: string
|
||||
production_signing_smoke:
|
||||
description: "Wait for external SignPath approval, verify trust, and do not publish"
|
||||
required: false
|
||||
@@ -73,12 +65,12 @@ on:
|
||||
default: false
|
||||
type: boolean
|
||||
orchestrator:
|
||||
description: "Trusted release orchestrator: stable or preview"
|
||||
description: "Trusted release orchestrator (legacy Preview calls remain readable)"
|
||||
required: false
|
||||
default: stable
|
||||
type: string
|
||||
preview_number:
|
||||
description: "Canonical Preview ordinal supplied by release-preview.yml"
|
||||
description: "Legacy Preview ordinal for old workflow-call compatibility"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
@@ -269,6 +261,17 @@ jobs:
|
||||
- name: Cache hit guard
|
||||
run: ./scripts/cache-guard.sh
|
||||
|
||||
- name: Verify embedded documentation identity
|
||||
env:
|
||||
DOCS_BUILD_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
DOCS_SOURCE_REVISION: ${{ needs.resolve.outputs.sha }}
|
||||
run: |
|
||||
if [ ! -f scripts/verify-embedded-docs.sh ]; then
|
||||
echo "Legacy candidate predates the embedded docs contract; skipping."
|
||||
exit 0
|
||||
fi
|
||||
bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION" "$DOCS_SOURCE_REVISION"
|
||||
|
||||
build:
|
||||
name: build (${{ matrix.name }})
|
||||
needs: [resolve, cache-guard]
|
||||
@@ -501,12 +504,26 @@ jobs:
|
||||
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
|
||||
run: cp signed-installer/*installer*.exe dist/
|
||||
|
||||
# Recovery builds intentionally check out the immutable product SHA,
|
||||
# which can predate a verifier repair. Fetch only the verifier from the
|
||||
# protected workflow commit so recovery can validate old immutable
|
||||
# artifacts without rebuilding them from a moving product branch.
|
||||
- name: Checkout protected release verifier
|
||||
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: release-control
|
||||
sparse-checkout: scripts/verify-windows-authenticode.ps1
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify Windows Authenticode release contract
|
||||
if: runner.os == 'Windows' && env.HAS_SIGNPATH == 'true'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$arch = if ("${{ matrix.platform }}" -eq "windows/arm64") { "arm64" } else { "amd64" }
|
||||
./scripts/verify-windows-authenticode.ps1 `
|
||||
./release-control/scripts/verify-windows-authenticode.ps1 `
|
||||
-PayloadDirectory signed-payload `
|
||||
-InstallerPath "dist/Reasonix-windows-$arch-installer.exe" `
|
||||
-PortableArchivePath "dist/Reasonix-windows-$arch.zip" `
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
name: Release npm
|
||||
|
||||
# npm line. Stable releases are called by release-stable.yml after its single
|
||||
# GitHub environment approval. Direct tag pushes are retained only for
|
||||
# prereleases so npm `next` can still ship independently without touching the
|
||||
# stable Homebrew cask. Bump a prerelease with:
|
||||
# git tag npm-vX.Y.Z-rc.N # prerelease -> publishes under `next`
|
||||
# git push origin <tag>
|
||||
#
|
||||
# Manual workflow_dispatch recovers only an approved Stable version from an
|
||||
# existing tag. Public canary publication is owned by release-preview.yml so its
|
||||
# version remains aligned with CLI, Desktop, and the immutable release marker.
|
||||
# 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 publisher verifies every immutable package and
|
||||
# advances each public dist-tag only when the candidate is newer.
|
||||
# GitHub environment approval. Direct prerelease tag publication is disabled;
|
||||
# manual workflow_dispatch recovers only an approved Stable version from an
|
||||
# existing tag. Historical canary and next package identities remain readable,
|
||||
# but normal publication advances only the official release.
|
||||
on:
|
||||
push:
|
||||
tags: ['npm-v*-*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
@@ -64,12 +52,12 @@ on:
|
||||
default: false
|
||||
type: boolean
|
||||
orchestrator:
|
||||
description: "Trusted release orchestrator: stable or preview"
|
||||
description: "Trusted release orchestrator (legacy Preview calls remain readable)"
|
||||
required: false
|
||||
default: stable
|
||||
type: string
|
||||
preview_number:
|
||||
description: "Canonical Preview ordinal supplied by release-preview.yml"
|
||||
description: "Legacy Preview ordinal for old workflow-call compatibility"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
@@ -78,8 +66,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
# Serialize every publisher for one npm dist-tag. In particular, canary
|
||||
# remains ordered even if another approved orchestrator is added later.
|
||||
# Serialize every publisher for one npm dist-tag. Historical canary calls
|
||||
# remain ordered even though normal publication now uses Stable only.
|
||||
group: release-npm-${{ inputs.channel || 'next' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
@@ -137,6 +125,18 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- run: ./scripts/cache-guard.sh
|
||||
- name: Verify embedded documentation identity
|
||||
env:
|
||||
DOCS_BUILD_VERSION: v${{ inputs.base_version }}
|
||||
DOCS_SOURCE_REVISION: ${{ inputs.approved_sha }}
|
||||
run: |
|
||||
if [ ! -f scripts/verify-embedded-docs.sh ]; then
|
||||
echo "Legacy candidate predates the embedded docs contract; skipping."
|
||||
exit 0
|
||||
fi
|
||||
revision="$DOCS_SOURCE_REVISION"
|
||||
if [ -z "$revision" ]; then revision="$(git rev-parse HEAD)"; fi
|
||||
bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION" "$revision"
|
||||
|
||||
npm:
|
||||
name: publish npm packages
|
||||
@@ -157,9 +157,9 @@ jobs:
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
# Tag push uses the npm-v* tag. The Preview orchestrator synthesizes a
|
||||
# <base>-canary.<preview_number> version; a manual Stable dispatch publishes
|
||||
# the requested semver exactly. build.mjs strips the leading `npm-`/`v`.
|
||||
# Stable publication uses the exact npm-v* tag. build.mjs strips the
|
||||
# leading `npm-`/`v`; historical Preview inputs remain only for old
|
||||
# workflow-call compatibility and are not reachable from a public entry.
|
||||
- name: Resolve version
|
||||
id: ver
|
||||
env:
|
||||
@@ -183,3 +183,14 @@ jobs:
|
||||
VERSION_ARG: ${{ steps.ver.outputs.arg }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: node npm/build.mjs "$VERSION_ARG" --publish
|
||||
- name: Align legacy aliases with the official release
|
||||
if: ${{ inputs.channel == 'stable' }}
|
||||
env:
|
||||
VERSION: ${{ inputs.base_version }}
|
||||
APPROVED_SHA: ${{ inputs.approved_sha }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
candidate="$APPROVED_SHA"
|
||||
if [ -z "$candidate" ]; then candidate="$(git rev-parse HEAD)"; fi
|
||||
EXPECTED_SHA="$candidate" node scripts/finalize-npm-official-release.mjs "$VERSION"
|
||||
@@ -1,219 +0,0 @@
|
||||
name: Release preview
|
||||
run-name: Release preview ${{ inputs.tag }}
|
||||
|
||||
# One protected Preview event owns the public CLI, Desktop, npm, and changelog
|
||||
# identities. The CLI tag supplies the canonical preview ordinal; child
|
||||
# publishers retain their native version syntax but use that same ordinal.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Existing protected CLI Preview tag, e.g. v1.19.0-preview.3"
|
||||
required: true
|
||||
type: string
|
||||
recovery:
|
||||
description: "Recover an existing partially published Preview from main-v2 history"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
# Every Preview version advances the same public CLI, Desktop, and npm
|
||||
# channels. Keep the whole all-surface event serialized across versions.
|
||||
group: preview-release
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
name: validate Preview release event
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
base_version: ${{ steps.release.outputs.base_version }}
|
||||
preview_number: ${{ steps.release.outputs.preview_number }}
|
||||
cli_tag: ${{ steps.release.outputs.cli_tag }}
|
||||
sha: ${{ steps.release.outputs.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.sha }}
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Resolve Preview release
|
||||
id: release
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
ALLOW_PREVIEW_RECOVERY: ${{ inputs.recovery }}
|
||||
run: bash scripts/resolve-preview-release.sh
|
||||
- name: Validate exact reviewed Preview notes
|
||||
env:
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
RELEASE_SHA: ${{ steps.release.outputs.sha }}
|
||||
run: |
|
||||
node scripts/release-notes.mjs render --version "$VERSION" --output /tmp/release-notes.md
|
||||
node scripts/release-event.mjs generate \
|
||||
--version "$VERSION" \
|
||||
--sha "$RELEASE_SHA" \
|
||||
--published-at 2000-01-01T00:00:00.000Z \
|
||||
--output /tmp/release-event.json
|
||||
- name: Upload reviewed release notes
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: orchestrator-reviewed-release-notes
|
||||
path: /tmp/release-notes.md
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
authorize:
|
||||
name: approve Preview release
|
||||
needs: preflight
|
||||
runs-on: ubuntu-latest
|
||||
environment: canary
|
||||
outputs:
|
||||
version: ${{ steps.approved.outputs.version }}
|
||||
base_version: ${{ steps.approved.outputs.base_version }}
|
||||
preview_number: ${{ steps.approved.outputs.preview_number }}
|
||||
cli_tag: ${{ steps.approved.outputs.cli_tag }}
|
||||
sha: ${{ steps.approved.outputs.sha }}
|
||||
steps:
|
||||
- name: Record approved Preview event
|
||||
id: approved
|
||||
env:
|
||||
VERSION: ${{ needs.preflight.outputs.version }}
|
||||
BASE_VERSION: ${{ needs.preflight.outputs.base_version }}
|
||||
PREVIEW_NUMBER: ${{ needs.preflight.outputs.preview_number }}
|
||||
CLI_TAG: ${{ needs.preflight.outputs.cli_tag }}
|
||||
RELEASE_SHA: ${{ needs.preflight.outputs.sha }}
|
||||
run: |
|
||||
{
|
||||
echo "version=$VERSION"
|
||||
echo "base_version=$BASE_VERSION"
|
||||
echo "preview_number=$PREVIEW_NUMBER"
|
||||
echo "cli_tag=$CLI_TAG"
|
||||
echo "sha=$RELEASE_SHA"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "Approved Preview release $VERSION at $RELEASE_SHA"
|
||||
|
||||
signpath-preflight:
|
||||
name: verify Preview SignPath control plane
|
||||
needs: authorize
|
||||
uses: ./.github/workflows/release-desktop.yml
|
||||
with:
|
||||
channel: preview
|
||||
base_version: ${{ needs.authorize.outputs.base_version }}
|
||||
preview_number: ${{ needs.authorize.outputs.preview_number }}
|
||||
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
|
||||
approved_sha: ${{ needs.authorize.outputs.sha }}
|
||||
orchestrated: true
|
||||
orchestrator: preview
|
||||
signing_preflight: true
|
||||
secrets: inherit
|
||||
|
||||
cli:
|
||||
name: publish CLI Preview
|
||||
needs: [authorize, signpath-preflight]
|
||||
if: ${{ needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' }}
|
||||
uses: ./.github/workflows/release.yml
|
||||
with:
|
||||
channel: preview
|
||||
tag: ${{ needs.authorize.outputs.cli_tag }}
|
||||
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
|
||||
approved_sha: ${{ needs.authorize.outputs.sha }}
|
||||
orchestrated: true
|
||||
orchestrator: preview
|
||||
allow_preview_recovery: ${{ inputs.recovery }}
|
||||
secrets: inherit
|
||||
|
||||
npm:
|
||||
name: publish npm Canary
|
||||
needs: [authorize, signpath-preflight]
|
||||
if: ${{ needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' }}
|
||||
uses: ./.github/workflows/release-npm.yml
|
||||
with:
|
||||
channel: canary
|
||||
base_version: ${{ needs.authorize.outputs.base_version }}
|
||||
preview_number: ${{ needs.authorize.outputs.preview_number }}
|
||||
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
|
||||
approved_sha: ${{ needs.authorize.outputs.sha }}
|
||||
orchestrated: true
|
||||
orchestrator: preview
|
||||
secrets: inherit
|
||||
|
||||
desktop:
|
||||
name: publish Desktop Preview
|
||||
needs: [authorize, signpath-preflight]
|
||||
if: ${{ needs.authorize.result == 'success' && needs.signpath-preflight.result == 'success' }}
|
||||
uses: ./.github/workflows/release-desktop.yml
|
||||
with:
|
||||
channel: preview
|
||||
base_version: ${{ needs.authorize.outputs.base_version }}
|
||||
preview_number: ${{ needs.authorize.outputs.preview_number }}
|
||||
approved_cli_tag: ${{ needs.authorize.outputs.cli_tag }}
|
||||
approved_sha: ${{ needs.authorize.outputs.sha }}
|
||||
orchestrated: true
|
||||
orchestrator: preview
|
||||
signing_preflight_verified: true
|
||||
secrets: inherit
|
||||
|
||||
postflight:
|
||||
name: publish Preview release record
|
||||
needs: [authorize, cli, npm, desktop]
|
||||
if: ${{ always() && !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Require every Preview publisher to succeed
|
||||
env:
|
||||
CLI_RESULT: ${{ needs.cli.result }}
|
||||
NPM_RESULT: ${{ needs.npm.result }}
|
||||
DESKTOP_RESULT: ${{ needs.desktop.result }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for surface in CLI NPM DESKTOP; do
|
||||
result_var="${surface}_RESULT"
|
||||
if [ "${!result_var}" != "success" ]; then
|
||||
echo "::error::$surface Preview publisher result is ${!result_var}, expected success"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Upload immutable public release marker
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ needs.authorize.outputs.version }}
|
||||
CLI_TAG: ${{ needs.authorize.outputs.cli_tag }}
|
||||
RELEASE_SHA: ${{ needs.authorize.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/release-event.mjs generate \
|
||||
--version "$VERSION" --sha "$RELEASE_SHA" \
|
||||
--published-at "$(gh api "repos/${{ github.repository }}/releases/tags/$CLI_TAG" --jq .published_at)" \
|
||||
--output /tmp/release-event.json
|
||||
existing="$(mktemp -d)"
|
||||
if gh release download "$CLI_TAG" --pattern release-event.json --dir "$existing" 2>/dev/null; then
|
||||
cmp -s /tmp/release-event.json "$existing/release-event.json" || {
|
||||
echo "::error::published release-event.json differs from the approved Preview event"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
gh release upload "$CLI_TAG" /tmp/release-event.json
|
||||
fi
|
||||
- name: Refresh public changelog
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh workflow run pages.yml --ref main-v2
|
||||
@@ -4,9 +4,9 @@ run-name: Release stable ${{ inputs.tag || github.ref_name }}
|
||||
# Stable publication has exactly one GitHub environment gate. Push the CLI,
|
||||
# npm, and desktop tags atomically; release-stable-trigger.yml relays the vX.Y.Z
|
||||
# tag to this workflow on protected main-v2. Preflight verifies that all three
|
||||
# tags point to the current main-v2 commit, and one GitHub approval releases
|
||||
# every channel. After approval, a zero-publication Windows preflight verifies
|
||||
# both architectures and signing stages before any public channel starts.
|
||||
# tags point to the reviewed Notes candidate on main-v2 history, and one GitHub
|
||||
# approval releases every surface. After approval, a zero-publication Windows
|
||||
# preflight verifies both architectures and signing stages before publication.
|
||||
# Keeping the control-plane ref on main-v2 lets SignPath restrict
|
||||
# production signing to that one protected origin instead of trusting wildcard
|
||||
# tag-like branch names. Manual recovery uses the same fixed control plane while
|
||||
@@ -54,6 +54,7 @@ jobs:
|
||||
name: validate stable release set
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
outputs:
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
@@ -81,6 +82,17 @@ jobs:
|
||||
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
|
||||
ALLOW_STABLE_RECOVERY: ${{ inputs.allow_recovery }}
|
||||
run: bash scripts/resolve-stable-release.sh
|
||||
- name: Revalidate normal release candidate and exact push CI
|
||||
if: ${{ !inputs.allow_recovery }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_CI_WAIT_SECONDS: 60
|
||||
RELEASE_VERSION: ${{ steps.release.outputs.version }}
|
||||
RELEASE_SHA: ${{ steps.release.outputs.sha }}
|
||||
run: |
|
||||
bash scripts/validate-stable-candidate.sh "$RELEASE_VERSION" "$RELEASE_SHA"
|
||||
bash scripts/verify-release-push-ci.sh "$RELEASE_SHA"
|
||||
- name: Validate reviewed release notes
|
||||
run: node scripts/release-notes.mjs render --version "${{ steps.release.outputs.cli_tag }}" --output /tmp/release-notes.md
|
||||
# Recovery builds deliberately check out the immutable tagged candidate,
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
name: Release
|
||||
|
||||
# Native CLI binary line. Stable and Preview releases are called by their
|
||||
# approved all-surface orchestrators. Manual dispatch remains available only for
|
||||
# Stable/RC recovery; standalone prereleases remain internal stable-channel
|
||||
# checkpoints behind the `release` gate.
|
||||
# Native CLI binary line. Official releases are called by the protected Stable
|
||||
# orchestrator. Manual dispatch remains available only for official recovery;
|
||||
# historical Preview inputs below are workflow-call compatibility, not a public
|
||||
# publication entrypoint.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: "Standalone CLI recovery channel"
|
||||
required: true
|
||||
# Public Preview recovery reruns release-preview.yml so every surface and
|
||||
# its immutable release-event marker stay aligned.
|
||||
default: stable
|
||||
type: choice
|
||||
options: [stable]
|
||||
tag:
|
||||
description: "Existing protected Stable/RC CLI tag (for example v1.18.0 or v1.18.0-rc.1)"
|
||||
description: "Existing official CLI tag (for example v1.18.0)"
|
||||
required: true
|
||||
type: string
|
||||
workflow_call:
|
||||
@@ -44,12 +42,12 @@ on:
|
||||
default: false
|
||||
type: boolean
|
||||
orchestrator:
|
||||
description: "Trusted release orchestrator: stable or preview"
|
||||
description: "Trusted release orchestrator (legacy Preview calls remain readable)"
|
||||
required: false
|
||||
default: stable
|
||||
type: string
|
||||
allow_preview_recovery:
|
||||
description: "Allow an approved Preview tag from main-v2 history"
|
||||
description: "Legacy compatibility for already-created Preview runs"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
@@ -181,15 +179,25 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- run: ./scripts/cache-guard.sh
|
||||
- name: Verify embedded documentation identity
|
||||
env:
|
||||
DOCS_BUILD_VERSION: ${{ needs.resolve.outputs.tag }}
|
||||
DOCS_SOURCE_REVISION: ${{ needs.resolve.outputs.sha }}
|
||||
run: |
|
||||
if [ ! -f scripts/verify-embedded-docs.sh ]; then
|
||||
echo "Legacy candidate predates the embedded docs contract; skipping."
|
||||
exit 0
|
||||
fi
|
||||
bash scripts/verify-embedded-docs.sh "$DOCS_BUILD_VERSION" "$DOCS_SOURCE_REVISION"
|
||||
|
||||
goreleaser:
|
||||
name: archives + checksums + homebrew tap
|
||||
needs: [resolve, cache-guard]
|
||||
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
# The stable caller has already passed the single GitHub release approval.
|
||||
# Standalone prereleases pass release-gate above. This job therefore must not
|
||||
# add a second GitHub environment approval.
|
||||
# The Stable caller has already passed the single GitHub release approval.
|
||||
# Official standalone recovery passes release-gate above. This job therefore
|
||||
# must not add a second GitHub environment approval.
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ builds:
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
- -s -w -X main.version={{ .Tag }}
|
||||
- -s -w -X main.version={{ .Tag }} -X reasonix/internal/productdocs.linkedVersion={{ .Tag }} -X reasonix/internal/productdocs.linkedRevision={{ .Commit }}
|
||||
goos: [darwin, linux, windows]
|
||||
goarch: [amd64, arm64]
|
||||
|
||||
|
||||
@@ -6,15 +6,11 @@ allowed_branch_names:
|
||||
- main-v2
|
||||
allowed_build_definitions:
|
||||
- .github/workflows/release-stable.yml
|
||||
- .github/workflows/release-preview.yml
|
||||
- .github/workflows/release-desktop.yml
|
||||
fingerprint_files:
|
||||
- .github/workflows/release-stable.yml
|
||||
- .github/workflows/release-stable-trigger.yml
|
||||
- .github/workflows/release-preview.yml
|
||||
- .github/workflows/release-cli-trigger.yml
|
||||
- .github/workflows/release-desktop.yml
|
||||
- .github/workflows/release-desktop-trigger.yml
|
||||
- .signpath/artifact-configurations/windows-payload.xml
|
||||
- .signpath/artifact-configurations/windows-installer-v2.xml
|
||||
- scripts/complete-signpath-request.ps1
|
||||
|
||||
@@ -30,6 +30,11 @@
|
||||
<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://trendshift.io/repositories/27020?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-27020" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/27020/monthly?language=Go" alt="esengine/DeepSeek-Reasonix | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/27020?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-27020" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/27020" alt="esengine/DeepSeek-Reasonix | Trendshift" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<h3 align="center">A DeepSeek-native AI coding agent for your terminal.</h3>
|
||||
@@ -141,7 +146,7 @@ For advanced CLI usage and configuration, see the **[CLI reference](./docs/CLI.m
|
||||
- **Features & troubleshooting:** [Subagent profiles](./docs/SUBAGENT_PROFILES.md) ·
|
||||
[Context Engine v2](./docs/SESSION_MEMORY_RETRIEVAL.md) ·
|
||||
[Capability diagnostics](./docs/CAPABILITY_DIAGNOSTICS.md) ·
|
||||
[Recovery and Safe Mode](./docs/RECOVERY.md) · [Bot guide](./docs/BOT_GUIDE.md) ·
|
||||
[Recovery and updates](./docs/RECOVERY.md) · [Bot guide](./docs/BOT_GUIDE.md) ·
|
||||
[Checkpoints & rewind](./docs/CHECKPOINTS.md)
|
||||
- **Engineering & migration:** [Spec](./docs/SPEC.md) ·
|
||||
[Task contracts & pause policy](./docs/TASK_CONTRACT.md) ·
|
||||
|
||||
@@ -30,6 +30,11 @@
|
||||
<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://trendshift.io/repositories/27020?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-27020" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/27020/monthly?language=Go" alt="esengine/DeepSeek-Reasonix | Trendshift" width="250" height="55"/></a>
|
||||
<a href="https://trendshift.io/repositories/27020?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-27020" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/27020" alt="esengine/DeepSeek-Reasonix | Trendshift" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
<h3 align="center">面向终端的 DeepSeek 原生 AI coding agent。</h3>
|
||||
|
||||
+18
-10
@@ -64,6 +64,8 @@ type result struct {
|
||||
Note string
|
||||
}
|
||||
|
||||
const defaultSuiteTokenBudget = 800_000
|
||||
|
||||
func main() {
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(flag.CommandLine.Output(), "e2ebench — Reasonix end-to-end benchmark.\n\n")
|
||||
@@ -85,7 +87,7 @@ func main() {
|
||||
profileFlag := flag.String("profile", benchmarkProfileBaseline, "prompt profile: baseline | delivery")
|
||||
outMD := flag.String("out", "", "write the markdown report here (default: stdout)")
|
||||
outJSON := flag.String("json", "", "write the JSON report here (optional)")
|
||||
budget := flag.Int("budget", 400_000, "abort once total tokens cross this (0 = no cap)")
|
||||
budget := flag.Int("budget", defaultSuiteTokenBudget, "abort once total tokens cross this (0 = no cap)")
|
||||
// diff-mode flags
|
||||
repo := flag.String("repo", ".", "repo root (diff mode)")
|
||||
base := flag.String("base", "", "base ref to diff the PR head against (diff mode)")
|
||||
@@ -224,15 +226,7 @@ func runTask(bin, model, profile string, t task) result {
|
||||
defer cancel()
|
||||
|
||||
metricsPath := filepath.Join(work, ".run-metrics.json")
|
||||
args := []string{"run", "--metrics", metricsPath}
|
||||
if model != "" {
|
||||
args = append(args, "--model", model)
|
||||
}
|
||||
if t.MaxSteps > 0 {
|
||||
args = append(args, "--max-steps", fmt.Sprint(t.MaxSteps))
|
||||
}
|
||||
args = appendBenchmarkProfileArgs(args, profile)
|
||||
args = append(args, t.Prompt)
|
||||
args := buildRunTaskArgs(metricsPath, model, profile, t.MaxSteps, t.Prompt)
|
||||
|
||||
cmd := exec.CommandContext(ctx, bin, args...)
|
||||
cmd.Dir = work
|
||||
@@ -253,6 +247,20 @@ func runTask(bin, model, profile string, t task) result {
|
||||
return r
|
||||
}
|
||||
|
||||
func buildRunTaskArgs(metricsPath, model, profile string, maxSteps int, prompt string) []string {
|
||||
// Benchmarks are unattended and their fixtures require ordinary workspace
|
||||
// writes. Auto still honors explicit ask/deny rules and the sandbox boundary.
|
||||
args := []string{"run", "--auto", "--metrics", metricsPath}
|
||||
if model != "" {
|
||||
args = append(args, "--model", model)
|
||||
}
|
||||
if maxSteps > 0 {
|
||||
args = append(args, "--max-steps", fmt.Sprint(maxSteps))
|
||||
}
|
||||
args = appendBenchmarkProfileArgs(args, profile)
|
||||
return append(args, prompt)
|
||||
}
|
||||
|
||||
func grade(work, taskDir string) bool {
|
||||
verify := filepath.Join(taskDir, "verify.sh")
|
||||
if !fileExists(verify) {
|
||||
|
||||
@@ -21,6 +21,27 @@ func TestAppendBenchmarkProfileArgsDeliveryUsesRealRuntimeProfile(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRunTaskArgsEnablesUnattendedWorkspaceWrites(t *testing.T) {
|
||||
got := buildRunTaskArgs("metrics.json", "e2e", benchmarkProfileDelivery, 12, "fix it")
|
||||
want := []string{
|
||||
"run", "--auto", "--metrics", "metrics.json",
|
||||
"--model", "e2e", "--max-steps", "12",
|
||||
"--profile", "delivery", "fix it",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("run task args = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultSuiteBudgetCoversCurrentFiveTaskBaseline(t *testing.T) {
|
||||
// The real-provider baseline exceeded 400k after only three successful
|
||||
// tasks. Keep enough headroom to grade all five instead of silently skipping
|
||||
// the final scenarios as normal model and cache usage varies.
|
||||
if defaultSuiteTokenBudget < 800_000 {
|
||||
t.Fatalf("default suite token budget = %d, want at least 800000", defaultSuiteTokenBudget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBenchmarkProfile(t *testing.T) {
|
||||
for _, input := range []string{"", "baseline", " BASELINE "} {
|
||||
if got, err := normalizeBenchmarkProfile(input); err != nil || got != benchmarkProfileBaseline {
|
||||
|
||||
@@ -1,381 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/netclient"
|
||||
"reasonix/internal/provider"
|
||||
_ "reasonix/internal/provider/anthropic"
|
||||
_ "reasonix/internal/provider/openai"
|
||||
_ "reasonix/internal/provider/responses"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
func runAssist(args []string) int {
|
||||
fs := flag.NewFlagSet("reasonix-guard assist", flag.ContinueOnError)
|
||||
root := fs.String("root", ".", "project root to diagnose")
|
||||
model := fs.String("model", "", "provider or provider/model used for the one-shot plan")
|
||||
network := fs.Bool("network", true, "include provider connectivity diagnostics")
|
||||
apply := fs.Bool("apply", false, "apply the validated plan after preview and confirmation")
|
||||
yes := fs.Bool("yes", false, "confirm plan application non-interactively")
|
||||
allowProject := fs.Bool("allow-project", false, "allow a plan to quarantine project reasonix.toml")
|
||||
jsonOut := fs.Bool("json", false, "print the plan and preview as JSON")
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
|
||||
return 2
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
report, err := repair.Diagnose(ctx, repair.DiagnoseOptions{Root: *root, Network: *network})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
plan, err := requestRepairPlan(ctx, *root, *model, report)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
opts := repair.ApplyPlanOptions{Root: *root, AllowProject: *allowProject}
|
||||
preview, err := repair.PreviewRepairPlan(plan, opts)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if *jsonOut {
|
||||
if code := printJSON(struct {
|
||||
Plan repair.RepairPlan `json:"plan"`
|
||||
PlanID string `json:"planId"`
|
||||
Preview []repair.RepairPlanPreview `json:"preview"`
|
||||
PreviewID string `json:"previewId"`
|
||||
}{plan, repair.RepairPlanID(plan), preview, repair.RepairPlanPreviewID(plan, preview)}); code != 0 {
|
||||
return code
|
||||
}
|
||||
} else {
|
||||
printPlanPreview(plan, preview)
|
||||
}
|
||||
if !*apply {
|
||||
return 0
|
||||
}
|
||||
if !*yes && !confirmPlan() {
|
||||
fmt.Println("repair plan not applied")
|
||||
return 0
|
||||
}
|
||||
opts.ExpectedPreviewID = repair.RepairPlanPreviewID(plan, preview)
|
||||
result, err := repair.ApplyRepairPlan(plan, opts)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if *jsonOut {
|
||||
return printJSON(result)
|
||||
}
|
||||
for _, action := range result.Applied {
|
||||
fmt.Println("applied:", action)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runApplyPlan(args []string) int {
|
||||
fs := flag.NewFlagSet("reasonix-guard apply-plan", flag.ContinueOnError)
|
||||
root := fs.String("root", ".", "project root")
|
||||
file := fs.String("file", "", "RepairPlan JSON file")
|
||||
yes := fs.Bool("yes", false, "confirm plan application non-interactively")
|
||||
allowProject := fs.Bool("allow-project", false, "allow project reasonix.toml repair")
|
||||
previewID := fs.String("preview-id", "", "expected preview ID from a prior dry-run")
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*file) == "" {
|
||||
return 2
|
||||
}
|
||||
b, err := os.ReadFile(*file)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
plan, err := repair.DecodeRepairPlan(b)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
opts := repair.ApplyPlanOptions{Root: *root, AllowProject: *allowProject}
|
||||
preview, err := repair.PreviewRepairPlan(plan, opts)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if !*jsonOut {
|
||||
printPlanPreview(plan, preview)
|
||||
}
|
||||
actualPreviewID := repair.RepairPlanPreviewID(plan, preview)
|
||||
expectedPreviewID := strings.TrimSpace(*previewID)
|
||||
if *yes && expectedPreviewID == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: --preview-id is required with --yes; preview the plan and confirm its current previewId first")
|
||||
return 2
|
||||
}
|
||||
if expectedPreviewID != "" && expectedPreviewID != actualPreviewID {
|
||||
fmt.Fprintf(os.Stderr, "error: repair plan preview changed since confirmation; re-preview and re-confirm (expected %s, got %s)\n", expectedPreviewID, actualPreviewID)
|
||||
return 1
|
||||
}
|
||||
if !*yes && !confirmPlan() {
|
||||
fmt.Println("repair plan not applied")
|
||||
return 0
|
||||
}
|
||||
opts.ExpectedPreviewID = actualPreviewID
|
||||
result, err := repair.ApplyRepairPlan(plan, opts)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if *jsonOut {
|
||||
return printJSON(result)
|
||||
}
|
||||
for _, action := range result.Applied {
|
||||
fmt.Println("applied:", action)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func requestRepairPlan(ctx context.Context, root, modelRef string, report repair.DiagnosticReport) (repair.RepairPlan, error) {
|
||||
cfg, err := loadAIProviderConfig(root)
|
||||
if err != nil {
|
||||
return repair.RepairPlan{}, err
|
||||
}
|
||||
if strings.TrimSpace(modelRef) == "" {
|
||||
modelRef = cfg.DefaultModel
|
||||
}
|
||||
entry, ok := cfg.ResolveModel(modelRef)
|
||||
if !ok {
|
||||
return repair.RepairPlan{}, fmt.Errorf("AI assistance model %q is not configured", modelRef)
|
||||
}
|
||||
if entry.RequiresAPIKey() && entry.APIKey() == "" {
|
||||
return repair.RepairPlan{}, fmt.Errorf("AI assistance provider %q has no configured API key", entry.Name)
|
||||
}
|
||||
p, err := provider.New(entry.Kind, repairPlanProviderConfig(entry, cfg.NetworkProxySpec()))
|
||||
if err != nil {
|
||||
return repair.RepairPlan{}, err
|
||||
}
|
||||
safeReport, snapshotAliases := providerSafeReportFrom(report)
|
||||
payload, err := json.Marshal(safeReport)
|
||||
if err != nil {
|
||||
return repair.RepairPlan{}, err
|
||||
}
|
||||
stream, err := p.Stream(ctx, provider.Request{
|
||||
Messages: []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: repairPlanSystemPrompt},
|
||||
{Role: provider.RoleUser, Content: string(payload)},
|
||||
},
|
||||
Temperature: provider.TemperaturePtr(0),
|
||||
MaxTokens: 2000,
|
||||
})
|
||||
if err != nil {
|
||||
return repair.RepairPlan{}, err
|
||||
}
|
||||
var out strings.Builder
|
||||
for chunk := range stream {
|
||||
switch chunk.Type {
|
||||
case provider.ChunkText:
|
||||
out.WriteString(chunk.Text)
|
||||
case provider.ChunkError:
|
||||
if chunk.Err != nil {
|
||||
return repair.RepairPlan{}, chunk.Err
|
||||
}
|
||||
}
|
||||
}
|
||||
plan, err := repair.DecodeRepairPlan([]byte(out.String()))
|
||||
if err != nil {
|
||||
return repair.RepairPlan{}, err
|
||||
}
|
||||
return resolveProviderSnapshotAliases(plan, snapshotAliases)
|
||||
}
|
||||
|
||||
func repairPlanProviderConfig(entry *config.ProviderEntry, proxy netclient.ProxySpec) provider.Config {
|
||||
return provider.Config{
|
||||
Name: entry.Name, BaseURL: entry.BaseURL, Model: entry.Model, APIKey: entry.APIKey(),
|
||||
Extra: map[string]any{
|
||||
"api_key_env": entry.APIKeyEnv, "api_key_source": entry.APIKeySourceLabel(),
|
||||
"thinking": entry.Thinking, "effort": config.EffectiveEffort(entry), "supported_efforts": entry.SupportedEfforts,
|
||||
"reasoning_protocol": config.ReasoningProtocolForEntry(entry),
|
||||
"chat_url": entry.ChatURL, "headers": entry.Headers, "extra_body": entry.ExtraBody,
|
||||
"auth_header": entry.AuthHeader, "proxy_spec": proxy,
|
||||
"vision": config.EffectiveVision(entry), "vision_detail": entry.VisionDetail,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func loadAIProviderConfig(root string) (*config.Config, error) {
|
||||
// Recovery tooling must never rewrite user configuration, so skip the
|
||||
// on-disk legacy MCP tier migration that LoadForRoot performs.
|
||||
if cfg, err := config.LoadForRootReadOnly(root); err == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
if snapshots, err := repair.ListConfigSnapshots(); err == nil {
|
||||
for _, snapshot := range snapshots {
|
||||
cfg, loadErr := config.LoadForEditReadOnlyStrict(snapshot.Path)
|
||||
if loadErr == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// Built-in recovery defaults resolve the global credential file without
|
||||
// reading, migrating, or rewriting malformed user/project TOML.
|
||||
return config.LoadRecoveryDefaultsForRoot(root), nil
|
||||
}
|
||||
|
||||
const repairPlanSystemPrompt = `You are a Reasonix recovery planner. Return JSON only, matching exactly:
|
||||
{"schemaVersion":1,"summary":"...","actions":[{"type":"...","scope":"","snapshotId":"","target":"","reason":"..."}]}
|
||||
The user message is a diagnostic report whose findings carry only a severity, a machine code (e.g. config.invalid_toml, derived.invalid_json, update related codes), and a generic scope; pick actions from those codes.
|
||||
Snapshot IDs are host-issued aliases ordered newest first; pendingUpdate=true means rollback_update is available.
|
||||
Allowed actions only (return an empty actions array when no safe action applies):
|
||||
- repair_config with scope global or project
|
||||
- restore_snapshot with snapshotId
|
||||
- rebuild_derived_state with target tabs, projects, window, zoom, or all
|
||||
- rollback_update with no parameters
|
||||
Never request shell commands, credential changes, session-content edits, arbitrary paths, plugin execution, or source-code changes. Prefer the smallest reversible plan supported by the diagnostics. Do not invent snapshot IDs.`
|
||||
|
||||
// providerSafeReport is the strict allowlist DTO sent to the AI provider.
|
||||
// It deliberately carries no free-form diagnostic strings or state metadata:
|
||||
// finding messages can quote config lines, URLs, commands, and permission
|
||||
// rules, while snapshot and update metadata can be edited on disk. Only fixed
|
||||
// diagnostic vocabulary, booleans, and host-issued snapshot aliases leave the
|
||||
// machine.
|
||||
type providerSafeReport struct {
|
||||
Root string `json:"root"`
|
||||
Network bool `json:"network"`
|
||||
Snapshots []providerSafeSnapshot `json:"snapshots"`
|
||||
PendingUpdate bool `json:"pendingUpdate"`
|
||||
Findings []providerSafeFinding `json:"findings"`
|
||||
}
|
||||
|
||||
type providerSafeSnapshot struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type providerSafeFinding struct {
|
||||
Severity string `json:"severity"`
|
||||
Code string `json:"code"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
func providerSafeReportFrom(report repair.DiagnosticReport) (providerSafeReport, map[string]string) {
|
||||
safe := providerSafeReport{
|
||||
Root: "<project>",
|
||||
Network: report.Network,
|
||||
Snapshots: make([]providerSafeSnapshot, 0, len(report.Snapshots)),
|
||||
PendingUpdate: report.PendingUpdate != nil,
|
||||
Findings: make([]providerSafeFinding, 0, len(report.Findings)),
|
||||
}
|
||||
snapshotAliases := make(map[string]string, len(report.Snapshots))
|
||||
for _, snapshot := range report.Snapshots {
|
||||
if strings.TrimSpace(snapshot.ID) != "" {
|
||||
alias := fmt.Sprintf("snapshot-%d", len(safe.Snapshots)+1)
|
||||
safe.Snapshots = append(safe.Snapshots, providerSafeSnapshot{ID: alias})
|
||||
snapshotAliases[alias] = snapshot.ID
|
||||
}
|
||||
}
|
||||
for _, finding := range report.Findings {
|
||||
safe.Findings = append(safe.Findings, providerSafeFinding{
|
||||
Severity: providerSafeSeverity(finding.Severity),
|
||||
Code: providerSafeDiagnosticCode(finding.Code),
|
||||
Scope: providerSafeScope(finding.Scope),
|
||||
})
|
||||
}
|
||||
return safe, snapshotAliases
|
||||
}
|
||||
|
||||
func resolveProviderSnapshotAliases(plan repair.RepairPlan, aliases map[string]string) (repair.RepairPlan, error) {
|
||||
resolved := plan
|
||||
resolved.Actions = append([]repair.RepairPlanAction(nil), plan.Actions...)
|
||||
for i := range resolved.Actions {
|
||||
action := &resolved.Actions[i]
|
||||
if action.Type != "restore_snapshot" {
|
||||
continue
|
||||
}
|
||||
id, ok := aliases[action.SnapshotID]
|
||||
if !ok {
|
||||
return repair.RepairPlan{}, fmt.Errorf("AI repair plan referenced unknown snapshot alias %q", action.SnapshotID)
|
||||
}
|
||||
action.SnapshotID = id
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func providerSafeSeverity(severity string) string {
|
||||
switch severity {
|
||||
case "error", "warning", "info":
|
||||
return severity
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func providerSafeDiagnosticCode(code string) string {
|
||||
switch code {
|
||||
case "config.invalid_toml", "config.load_failed",
|
||||
"provider.missing_name", "provider.duplicate_name", "provider.unsupported_kind",
|
||||
"provider.no_models", "provider.invalid_url", "provider.invalid_models_url",
|
||||
"provider.invalid_key_name", "provider.missing_key",
|
||||
"model.no_default", "model.invalid_default",
|
||||
"plugin.missing_name", "plugin.duplicate_name", "plugin.missing_command",
|
||||
"plugin.command_missing", "plugin.invalid_url", "plugin.invalid_type",
|
||||
"permissions.conflict", "file.permissions",
|
||||
"directory.unavailable", "directory.unreadable", "directory.not_directory", "directory.not_writable",
|
||||
"derived.invalid_json",
|
||||
"network.invalid_proxy", "network.client_failed", "network.unreachable",
|
||||
"network.authentication_failed", "network.ok", "network.unexpected_status":
|
||||
return code
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// providerSafeScope generalizes a finding scope to a closed vocabulary.
|
||||
// derived:* names survive because rebuild_derived_state needs the target and
|
||||
// the suffixes are a fixed enum; provider:*/plugin:* carry user-chosen names
|
||||
// and collapse to their category; anything unrecognized becomes "other".
|
||||
func providerSafeScope(scope string) string {
|
||||
scope = strings.TrimSpace(scope)
|
||||
switch {
|
||||
case scope == "":
|
||||
return ""
|
||||
case strings.HasPrefix(scope, "derived:"):
|
||||
switch scope[len("derived:"):] {
|
||||
case "tabs", "projects", "window", "zoom":
|
||||
return scope
|
||||
}
|
||||
return "derived:other"
|
||||
case strings.HasPrefix(scope, "provider:"):
|
||||
return "provider"
|
||||
case strings.HasPrefix(scope, "plugin:"):
|
||||
return "plugin"
|
||||
}
|
||||
switch scope {
|
||||
case "global", "project", "runtime", "network", "model", "provider", "plugin", "permissions",
|
||||
"global config", "credential file", "Reasonix home", "Reasonix state directory", "project root":
|
||||
return scope
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
func printPlanPreview(plan repair.RepairPlan, previews []repair.RepairPlanPreview) {
|
||||
fmt.Println("AI repair plan:", plan.Summary)
|
||||
for _, preview := range previews {
|
||||
fmt.Printf(" %d. %s\n", preview.Index, preview.Description)
|
||||
if preview.Diff != "" {
|
||||
fmt.Println(preview.Diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func confirmPlan() bool {
|
||||
fmt.Print("Apply this repair plan? [y/N] ")
|
||||
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
line = strings.ToLower(strings.TrimSpace(line))
|
||||
return line == "y" || line == "yes"
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/netclient"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
func TestRepairPlanProviderConfigPreservesKimiK3Efforts(t *testing.T) {
|
||||
entry := &config.ProviderEntry{
|
||||
Name: "opencode-go",
|
||||
Kind: "openai",
|
||||
BaseURL: "https://opencode.ai/zen/go/v1",
|
||||
Model: "kimi-k3",
|
||||
ReasoningProtocol: config.ReasoningProtocolOpenAI,
|
||||
SupportedEfforts: []string{"high", "max"},
|
||||
DefaultEffort: "max",
|
||||
}
|
||||
got := repairPlanProviderConfig(entry, netclient.ProxySpec{})
|
||||
if effort := got.Extra["effort"]; effort != "max" {
|
||||
t.Fatalf("effort = %#v, want max", effort)
|
||||
}
|
||||
if efforts, ok := got.Extra["supported_efforts"].([]string); !ok || !reflect.DeepEqual(efforts, []string{"high", "max"}) {
|
||||
t.Fatalf("supported_efforts = %#v, want [high max]", got.Extra["supported_efforts"])
|
||||
}
|
||||
if protocol := got.Extra["reasoning_protocol"]; protocol != config.ReasoningProtocolOpenAI {
|
||||
t.Fatalf("reasoning_protocol = %#v, want openai", protocol)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProviderSafeReportDropsUserControlledContent pins the outbound-privacy
|
||||
// contract: the payload sent to the AI provider must carry only allowlisted
|
||||
// codes, severities, and generic scopes — never diagnostic prose, which can
|
||||
// quote config lines, MCP command lines, permission rules, URLs with
|
||||
// credentials, emails, tokens, or absolute paths.
|
||||
func TestProviderSafeReportDropsUserControlledContent(t *testing.T) {
|
||||
const metadataSecret = "snapshot-metadata-secret"
|
||||
const snapshotID = "20260715T000000.000000000Z-abcdef123456"
|
||||
secrets := []string{
|
||||
"/Users/someone/dotfiles",
|
||||
"[email protected]",
|
||||
"sk-live-Abc123Secret",
|
||||
"npx --api-key=sk-live-Abc123Secret mcp-server",
|
||||
"bash(rm -rf *)",
|
||||
"corp-internal-llm",
|
||||
"https://user:[email protected]:8080",
|
||||
metadataSecret,
|
||||
"secret.finding.code",
|
||||
"secret-severity",
|
||||
snapshotID,
|
||||
}
|
||||
report := repair.DiagnosticReport{
|
||||
GeneratedAt: metadataSecret,
|
||||
Root: "/Users/someone/dotfiles",
|
||||
Network: true,
|
||||
Snapshots: []repair.DiagnosticSnapshot{
|
||||
{ID: snapshotID, RecordedAt: metadataSecret, Version: metadataSecret},
|
||||
{ID: metadataSecret, RecordedAt: metadataSecret, Version: metadataSecret},
|
||||
},
|
||||
PendingUpdate: &repair.DiagnosticUpdate{FromVersion: metadataSecret, ToVersion: metadataSecret},
|
||||
Findings: []repair.DiagnosticFinding{
|
||||
{Severity: "error", Code: "config.invalid_toml", Scope: "global",
|
||||
Message: "Configuration cannot be parsed: line 3: api_key = \"sk-live-Abc123Secret\" contact [email protected]"},
|
||||
{Severity: "warning", Code: "plugin.command_missing", Scope: "plugin:corp-internal-llm",
|
||||
Message: `MCP command "npx --api-key=sk-live-Abc123Secret mcp-server" was not found.`},
|
||||
{Severity: "warning", Code: "permissions.conflict", Scope: "permissions",
|
||||
Message: `Permission rule "bash(rm -rf *)" appears in allow, deny; deny takes precedence.`},
|
||||
{Severity: "error", Code: "network.invalid_proxy", Scope: "network",
|
||||
Message: "Proxy configuration is invalid: parse \"https://user:[email protected]:8080\": bad"},
|
||||
{Severity: "warning", Code: "provider.missing_key", Scope: "provider:corp-internal-llm",
|
||||
Message: "The configured API key is missing."},
|
||||
{Severity: "warning", Code: "derived.invalid_json", Scope: "derived:tabs",
|
||||
Message: "Derived desktop state desktop-tabs.json is malformed."},
|
||||
{Severity: "secret-severity", Code: "secret.finding.code", Scope: metadataSecret,
|
||||
Message: metadataSecret, Remediation: metadataSecret},
|
||||
},
|
||||
}
|
||||
|
||||
safeReport, aliases := providerSafeReportFrom(report)
|
||||
payload, err := json.Marshal(safeReport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(payload)
|
||||
for _, secret := range secrets {
|
||||
if strings.Contains(body, secret) {
|
||||
t.Fatalf("outbound payload leaked %q:\n%s", secret, body)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
`"root":"\u003cproject\u003e"`, // json.Marshal HTML-escapes <project>
|
||||
`"code":"config.invalid_toml"`,
|
||||
`"scope":"provider"`,
|
||||
`"scope":"plugin"`,
|
||||
`"scope":"derived:tabs"`,
|
||||
`"id":"snapshot-1"`,
|
||||
`"id":"snapshot-2"`,
|
||||
`"pendingUpdate":true`,
|
||||
`"severity":"unknown"`,
|
||||
`"code":"unknown"`,
|
||||
`"scope":"other"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("outbound payload missing %s:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if aliases["snapshot-1"] != snapshotID || aliases["snapshot-2"] != metadataSecret {
|
||||
t.Fatalf("snapshot alias map = %v", aliases)
|
||||
}
|
||||
for _, forbiddenField := range []string{"generatedAt", "recordedAt", "version", "fromVersion", "toVersion"} {
|
||||
if strings.Contains(body, `"`+forbiddenField+`"`) {
|
||||
t.Fatalf("outbound payload retained free-form field %q:\n%s", forbiddenField, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveProviderSnapshotAliases(t *testing.T) {
|
||||
const snapshotID = "20260715T000000.000000000Z-abcdef123456"
|
||||
plan, err := repair.DecodeRepairPlan([]byte(`{
|
||||
"schemaVersion": 1,
|
||||
"summary": "restore newest snapshot",
|
||||
"actions": [
|
||||
{"type":"repair_config","scope":"global","reason":"invalid config"},
|
||||
{"type":"restore_snapshot","snapshotId":"snapshot-1","reason":"known-good config"}
|
||||
]
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := resolveProviderSnapshotAliases(plan, map[string]string{"snapshot-1": snapshotID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := resolved.Actions[1].SnapshotID; got != snapshotID {
|
||||
t.Fatalf("resolved snapshot ID = %q, want %q", got, snapshotID)
|
||||
}
|
||||
if got := plan.Actions[1].SnapshotID; got != "snapshot-1" {
|
||||
t.Fatalf("alias resolution mutated source plan: %q", got)
|
||||
}
|
||||
if _, err := resolveProviderSnapshotAliases(plan, nil); err == nil {
|
||||
t.Fatal("unknown snapshot alias was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func writeDerivedStatePlan(t *testing.T) (string, repair.RepairPlan) {
|
||||
t.Helper()
|
||||
file := filepath.Join(t.TempDir(), "plan.json")
|
||||
raw := []byte(`{"schemaVersion":1,"summary":"rebuild tabs","actions":[{"type":"rebuild_derived_state","target":"tabs","reason":"malformed"}]}`)
|
||||
if err := os.WriteFile(file, raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, err := repair.DecodeRepairPlan(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return file, plan
|
||||
}
|
||||
|
||||
func TestApplyPlanYesRequiresConfirmedPreviewID(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
tabs := filepath.Join(home, "desktop-tabs.json")
|
||||
if err := os.WriteFile(tabs, []byte("bad-tabs"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, _ := writeDerivedStatePlan(t)
|
||||
if got := runApplyPlan([]string{"--file", file, "--yes"}); got != 2 {
|
||||
t.Fatalf("exit code = %d, want missing preview ID usage refusal", got)
|
||||
}
|
||||
if got, err := os.ReadFile(tabs); err != nil || string(got) != "bad-tabs" {
|
||||
t.Fatalf("unbound --yes touched derived state: %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlanRejectsStateChangedAfterPreview(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
tabs := filepath.Join(home, "desktop-tabs.json")
|
||||
if err := os.WriteFile(tabs, []byte("first-state"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, plan := writeDerivedStatePlan(t)
|
||||
preview, err := repair.PreviewRepairPlan(plan, repair.ApplyPlanOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previewID := repair.RepairPlanPreviewID(plan, preview)
|
||||
if err := os.WriteFile(tabs, []byte("changed-after-preview"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := runApplyPlan([]string{"--file", file, "--preview-id", previewID, "--yes"}); got != 1 {
|
||||
t.Fatalf("exit code = %d, want stale preview refusal", got)
|
||||
}
|
||||
if got, err := os.ReadFile(tabs); err != nil || string(got) != "changed-after-preview" {
|
||||
t.Fatalf("stale preview touched derived state: %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPlanAcceptsCurrentConfirmedPreviewID(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
tabs := filepath.Join(home, "desktop-tabs.json")
|
||||
if err := os.WriteFile(tabs, []byte("bad-tabs"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, plan := writeDerivedStatePlan(t)
|
||||
preview, err := repair.PreviewRepairPlan(plan, repair.ApplyPlanOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previewID := repair.RepairPlanPreviewID(plan, preview)
|
||||
if got := runApplyPlan([]string{"--file", file, "--preview-id", previewID, "--yes"}); got != 0 {
|
||||
t.Fatalf("exit code = %d, want confirmed preview application", got)
|
||||
}
|
||||
if _, err := os.Stat(tabs); !os.IsNotExist(err) {
|
||||
t.Fatalf("confirmed repair did not quarantine derived state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderSafeScopeClosedVocabulary(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
"global": "global",
|
||||
"project": "project",
|
||||
"derived:tabs": "derived:tabs",
|
||||
"derived:zoom": "derived:zoom",
|
||||
"derived:/etc/passwd": "derived:other",
|
||||
"provider:corp-llm": "provider",
|
||||
"plugin:internal-mcp": "plugin",
|
||||
"credential file": "credential file",
|
||||
"[email protected]": "other",
|
||||
"/Users/someone/project": "other",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := providerSafeScope(in); got != want {
|
||||
t.Errorf("providerSafeScope(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,415 +0,0 @@
|
||||
// Command reasonix-guard diagnoses and repairs Reasonix without loading the
|
||||
// desktop shell. It is packaged beside the desktop application so recovery
|
||||
// remains available when Wails, WebView, or user configuration cannot start.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() { os.Exit(run(os.Args[1:])) }
|
||||
|
||||
func run(args []string) int {
|
||||
if len(args) == 0 {
|
||||
return runLaunch(nil)
|
||||
}
|
||||
switch args[0] {
|
||||
case "check":
|
||||
return runCheck(args[1:], false)
|
||||
case "repair":
|
||||
return runCheck(args[1:], true)
|
||||
case "launch":
|
||||
return runLaunch(args[1:])
|
||||
case "recover":
|
||||
return runRecover(args[1:])
|
||||
case "snapshots":
|
||||
return runSnapshots(args[1:])
|
||||
case "restore":
|
||||
return runRestore(args[1:])
|
||||
case "undo":
|
||||
return runUndo(args[1:])
|
||||
case "diagnose":
|
||||
return runDiagnose(args[1:])
|
||||
case "rebuild":
|
||||
return runRebuild(args[1:])
|
||||
case "assist":
|
||||
return runAssist(args[1:])
|
||||
case "apply-plan":
|
||||
return runApplyPlan(args[1:])
|
||||
case "version", "--version", "-v":
|
||||
fmt.Println("reasonix-guard", version)
|
||||
return 0
|
||||
case "help", "--help", "-h":
|
||||
usage()
|
||||
return 0
|
||||
default:
|
||||
if isMacBundleLauncher() {
|
||||
return runLaunch(append([]string{"--"}, args...))
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "unknown command:", args[0])
|
||||
usage()
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func runDiagnose(args []string) int {
|
||||
fs := flag.NewFlagSet("reasonix-guard diagnose", flag.ContinueOnError)
|
||||
root := fs.String("root", ".", "project root to inspect")
|
||||
network := fs.Bool("network", false, "probe provider endpoints and credentials")
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
|
||||
return 2
|
||||
}
|
||||
report, err := repair.Diagnose(context.Background(), repair.DiagnoseOptions{Root: *root, Network: *network})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if *jsonOut {
|
||||
if code := printJSON(report); code != 0 {
|
||||
return code
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Reasonix Guard diagnostics")
|
||||
if len(report.Findings) == 0 {
|
||||
fmt.Println(" ok: no issues found")
|
||||
}
|
||||
for _, finding := range report.Findings {
|
||||
fmt.Printf(" %-7s %-32s %s\n", finding.Severity, finding.Code, finding.Message)
|
||||
}
|
||||
}
|
||||
if report.HasErrors() {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runRebuild(args []string) int {
|
||||
fs := flag.NewFlagSet("reasonix-guard rebuild", flag.ContinueOnError)
|
||||
target := fs.String("target", "", "tabs|projects|window|zoom|all")
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*target) == "" {
|
||||
return 2
|
||||
}
|
||||
applied, err := repair.RebuildDerivedState(*target)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if *jsonOut {
|
||||
return printJSON(applied)
|
||||
}
|
||||
for _, path := range applied {
|
||||
fmt.Println("quarantined:", path)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runSnapshots(args []string) int {
|
||||
fs := flag.NewFlagSet("reasonix-guard snapshots", flag.ContinueOnError)
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
|
||||
return 2
|
||||
}
|
||||
snapshots, err := repair.ListConfigSnapshots()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if *jsonOut {
|
||||
return printJSON(snapshots)
|
||||
}
|
||||
for _, snap := range snapshots {
|
||||
fmt.Printf("%s %s %s\n", snap.ID, snap.Version, snap.RecordedAt)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runRestore(args []string) int {
|
||||
fs := flag.NewFlagSet("reasonix-guard restore", flag.ContinueOnError)
|
||||
snapshot := fs.String("snapshot", "", "snapshot id to restore")
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*snapshot) == "" {
|
||||
return 2
|
||||
}
|
||||
tx, err := repair.RestoreConfigSnapshot(*snapshot)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println("restored config snapshot; undo transaction:", tx.ID)
|
||||
return 0
|
||||
}
|
||||
|
||||
func runUndo(args []string) int {
|
||||
fs := flag.NewFlagSet("reasonix-guard undo", flag.ContinueOnError)
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
|
||||
return 2
|
||||
}
|
||||
tx, err := repair.UndoLastRepair()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if *jsonOut {
|
||||
return printJSON(tx)
|
||||
}
|
||||
fmt.Println("undid repair:", tx.ID)
|
||||
return 0
|
||||
}
|
||||
|
||||
func printJSON(v any) int {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(v); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func runCheck(args []string, apply bool) int {
|
||||
name := "reasonix-guard check"
|
||||
if apply {
|
||||
name = "reasonix-guard repair"
|
||||
}
|
||||
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
||||
root := fs.String("root", ".", "project root to inspect")
|
||||
includeProject := fs.Bool("project", false, "allow repair to quarantine project reasonix.toml")
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
|
||||
return 2
|
||||
}
|
||||
report, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: *root, Apply: apply, IncludeProject: *includeProject})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if *jsonOut {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(report); err != nil {
|
||||
return 1
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Reasonix Guard")
|
||||
for _, check := range report.Checks {
|
||||
status := "ok"
|
||||
if !check.Exists {
|
||||
status = "missing"
|
||||
} else if !check.Valid {
|
||||
status = "invalid"
|
||||
}
|
||||
fmt.Printf(" %-8s %-8s %s\n", check.Scope, status, check.Path)
|
||||
if check.Error != "" {
|
||||
fmt.Println(" ", check.Error)
|
||||
}
|
||||
}
|
||||
for _, action := range report.Applied {
|
||||
fmt.Println(" applied:", action)
|
||||
}
|
||||
}
|
||||
for _, check := range report.Checks {
|
||||
if check.Exists && !check.Valid {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// failedInstallBlocksLaunch decides whether startup must stop after a failed
|
||||
// RecoverFailedInstall. The failure marker means the installer may have left
|
||||
// the release unit partially replaced, so the binaries cannot be presumed
|
||||
// coherent until a rollback completes: any error with the restore unfinished
|
||||
// fails closed. MixedInstall alone is not the signal — a rollback that failed
|
||||
// before touching anything (e.g. while staging) reports MixedInstall=false yet
|
||||
// leaves the installer's half-written unit in place. Only a completed restore
|
||||
// (RolledBack, with just the marker cleanup failing) is safe to launch.
|
||||
func failedInstallBlocksLaunch(result repair.UpdateRollbackResult, err error) bool {
|
||||
return err != nil && !result.RolledBack
|
||||
}
|
||||
|
||||
func runLaunch(args []string) int {
|
||||
fs := flag.NewFlagSet("reasonix-guard launch", flag.ContinueOnError)
|
||||
app := fs.String("app", "", "desktop executable path")
|
||||
safeMode := fs.Bool("safe-mode", false, "force Safe Mode")
|
||||
detach := fs.Bool("detach", packagedDetachedLauncher(), "start the desktop and exit the launcher")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
// An update helper that watched the installer fail records a marker (it
|
||||
// cannot roll back itself from outside the install directory). Restore the
|
||||
// previous release unit immediately instead of waiting for a crash loop.
|
||||
if result, failure, err := repair.RecoverFailedInstall(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "update rollback after failed install failed:", err)
|
||||
if failedInstallBlocksLaunch(result, err) {
|
||||
fmt.Fprintln(os.Stderr, "error: the failed installer may have left a mix of two releases and the rollback did not complete; refusing to start. Re-run reasonix-guard to retry the rollback, or reinstall Reasonix.")
|
||||
return 1
|
||||
}
|
||||
} else if failure != nil && result.RolledBack {
|
||||
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s after the %s installer failed.\n", result.ToVersion, result.FromVersion)
|
||||
}
|
||||
forceSafeMode := false
|
||||
if result, err := repair.ReconcilePendingUpdate(version); err != nil {
|
||||
if !errors.Is(err, repair.ErrPendingUpdateAwaitingHealth) {
|
||||
fmt.Fprintln(os.Stderr, "previous update recovery failed:", err)
|
||||
if result.MixedInstall {
|
||||
fmt.Fprintln(os.Stderr, "error: the installation may mix two releases; refusing to start. Re-run reasonix-guard to retry recovery, or reinstall Reasonix.")
|
||||
return 1
|
||||
}
|
||||
forceSafeMode = true
|
||||
}
|
||||
} else if result.RolledBack {
|
||||
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s before launch after an unfinished %s update.\n", result.FromVersion, result.ToVersion)
|
||||
} else if result.Cleared {
|
||||
fmt.Fprintf(os.Stderr, "Reasonix Guard cleared an abandoned %s update before launch.\n", result.ToVersion)
|
||||
}
|
||||
|
||||
tracker := repair.NewStartupTracker("")
|
||||
useSafeMode := *safeMode || forceSafeMode
|
||||
if !useSafeMode && tracker.SafeModeRecommended() {
|
||||
if result, err := repair.RollbackPendingUpdate(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "update rollback failed:", err)
|
||||
if result.MixedInstall {
|
||||
fmt.Fprintln(os.Stderr, "error: the installation now mixes two releases; refusing to start. Re-run reasonix-guard to retry the rollback, or reinstall Reasonix.")
|
||||
return 1
|
||||
}
|
||||
// The compensated install is still a coherent (new-version) release
|
||||
// unit, so a Safe Mode boot is a sound fallback while the pending
|
||||
// transaction stays on disk for the next rollback attempt.
|
||||
useSafeMode = true
|
||||
} else if result.RolledBack {
|
||||
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s after %s failed to start.\n", result.ToVersion, result.FromVersion)
|
||||
_ = tracker.MarkClean()
|
||||
} else {
|
||||
switch nativeRecoveryChoice() {
|
||||
case recoveryRepair:
|
||||
if _, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: ".", Apply: true}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "repair failed:", err)
|
||||
}
|
||||
useSafeMode = true
|
||||
case recoverySafeMode:
|
||||
useSafeMode = true
|
||||
case recoveryQuit:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
path := strings.TrimSpace(*app)
|
||||
if path == "" {
|
||||
path = siblingDesktopExecutable()
|
||||
}
|
||||
if path == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: cannot locate reasonix-desktop; pass --app PATH")
|
||||
return 1
|
||||
}
|
||||
childArgs := append([]string(nil), fs.Args()...)
|
||||
if useSafeMode {
|
||||
childArgs = append([]string{"--safe-mode"}, childArgs...)
|
||||
}
|
||||
cmd := exec.Command(path, childArgs...)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
if *detach {
|
||||
if err := cmd.Start(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if err := cmd.Run(); err != nil {
|
||||
if exit, ok := err.(*exec.ExitError); ok {
|
||||
return exit.ExitCode()
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func packagedDetachedLauncher() bool {
|
||||
if runtime.GOOS != "windows" {
|
||||
return false
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
name := strings.ToLower(filepath.Base(exe))
|
||||
return name == "reasonix-launcher.exe" || name == "reasonix.exe"
|
||||
}
|
||||
|
||||
func runRecover(args []string) int {
|
||||
fs := flag.NewFlagSet("reasonix-guard recover", flag.ContinueOnError)
|
||||
root := fs.String("root", ".", "project root to inspect")
|
||||
includeProject := fs.Bool("project", false, "allow repair to quarantine project reasonix.toml")
|
||||
app := fs.String("app", "", "desktop executable path")
|
||||
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
|
||||
return 2
|
||||
}
|
||||
switch nativeRecoveryChoice() {
|
||||
case recoveryRepair:
|
||||
if _, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: *root, Apply: true, IncludeProject: *includeProject}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "repair failed:", err)
|
||||
return 1
|
||||
}
|
||||
launch := []string{"--safe-mode"}
|
||||
if strings.TrimSpace(*app) != "" {
|
||||
launch = append(launch, "--app", *app)
|
||||
}
|
||||
return runLaunch(launch)
|
||||
case recoverySafeMode:
|
||||
launch := []string{"--safe-mode"}
|
||||
if strings.TrimSpace(*app) != "" {
|
||||
launch = append(launch, "--app", *app)
|
||||
}
|
||||
return runLaunch(launch)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func siblingDesktopExecutable() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
name := "reasonix-desktop"
|
||||
if runtime.GOOS == "windows" {
|
||||
name += ".exe"
|
||||
}
|
||||
candidates := []string{filepath.Join(filepath.Dir(exe), name)}
|
||||
if runtime.GOOS == "darwin" {
|
||||
candidates = append(candidates, filepath.Clean(filepath.Join(filepath.Dir(exe), "..", "MacOS", name)))
|
||||
}
|
||||
for _, path := range candidates {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isMacBundleLauncher() bool {
|
||||
if runtime.GOOS != "darwin" {
|
||||
return false
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
return err == nil && strings.Contains(filepath.ToSlash(exe), ".app/Contents/MacOS/")
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Println("usage: reasonix-guard <check|repair|diagnose|rebuild|assist|apply-plan|launch|recover|snapshots|restore|undo> [options]")
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
func TestCheckReportsInvalidProjectConfig(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[broken\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := run([]string{"check", "--root", root, "--json"}); code != 1 {
|
||||
t.Fatalf("exit code = %d, want 1", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedInstallBlocksLaunch(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
result repair.UpdateRollbackResult
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{name: "no error never blocks", result: repair.UpdateRollbackResult{}, err: nil, want: false},
|
||||
{name: "incomplete rollback fails closed", result: repair.UpdateRollbackResult{}, err: errors.New("stage failed"), want: true},
|
||||
{name: "uncompensated rollback fails closed", result: repair.UpdateRollbackResult{MixedInstall: true}, err: errors.New("restore failed"), want: true},
|
||||
{name: "completed restore with marker cleanup error launches", result: repair.UpdateRollbackResult{RolledBack: true}, err: errors.New("remove marker: permission denied"), want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := failedInstallBlocksLaunch(tc.result, tc.err); got != tc.want {
|
||||
t.Errorf("%s: failedInstallBlocksLaunch = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package main
|
||||
|
||||
type recoveryChoice int
|
||||
|
||||
const (
|
||||
recoveryQuit recoveryChoice = iota
|
||||
recoverySafeMode
|
||||
recoveryRepair
|
||||
)
|
||||
@@ -1,24 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func nativeRecoveryChoice() recoveryChoice {
|
||||
script := `button returned of (display dialog "Reasonix failed to start repeatedly. You can repair damaged configuration or start with external integrations disabled." with title "Reasonix Recovery" buttons {"Quit", "Safe Mode", "Repair and Start"} default button "Repair and Start" with icon caution)`
|
||||
out, err := exec.Command("osascript", "-e", script).Output()
|
||||
if err != nil {
|
||||
return recoverySafeMode
|
||||
}
|
||||
switch strings.TrimSpace(string(out)) {
|
||||
case "Repair and Start":
|
||||
return recoveryRepair
|
||||
case "Safe Mode":
|
||||
return recoverySafeMode
|
||||
default:
|
||||
return recoveryQuit
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func nativeRecoveryChoice() recoveryChoice {
|
||||
if path, err := exec.LookPath("zenity"); err == nil {
|
||||
cmd := exec.Command(path, "--question", "--title=Reasonix Recovery",
|
||||
"--text=Reasonix failed to start repeatedly.",
|
||||
"--ok-label=Repair and Start", "--cancel-label=Safe Mode", "--extra-button=Quit")
|
||||
out, err := cmd.Output()
|
||||
if strings.TrimSpace(string(out)) == "Quit" {
|
||||
return recoveryQuit
|
||||
}
|
||||
if err == nil {
|
||||
return recoveryRepair
|
||||
}
|
||||
return recoverySafeMode
|
||||
}
|
||||
if path, err := exec.LookPath("kdialog"); err == nil {
|
||||
err := exec.Command(path, "--warningyesnocancel", "Reasonix failed to start repeatedly. Repair configuration and start?", "--title", "Reasonix Recovery").Run()
|
||||
if err == nil {
|
||||
return recoveryRepair
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.ExitCode() == 2 {
|
||||
return recoveryQuit
|
||||
}
|
||||
}
|
||||
return recoverySafeMode
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
//go:build !darwin && !windows && !linux
|
||||
|
||||
package main
|
||||
|
||||
func nativeRecoveryChoice() recoveryChoice { return recoverySafeMode }
|
||||
@@ -1,27 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func nativeRecoveryChoice() recoveryChoice {
|
||||
script := `Add-Type -AssemblyName PresentationFramework; $r=[System.Windows.MessageBox]::Show('Reasonix failed to start repeatedly. Yes: repair configuration and start. No: start in Safe Mode. Cancel: quit.','Reasonix Recovery','YesNoCancel','Warning'); Write-Output $r`
|
||||
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return recoverySafeMode
|
||||
}
|
||||
switch strings.TrimSpace(string(out)) {
|
||||
case "Yes":
|
||||
return recoveryRepair
|
||||
case "No":
|
||||
return recoverySafeMode
|
||||
default:
|
||||
return recoveryQuit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Command reasonix-launcher is the permanent thin desktop entry point for the
|
||||
// versioned install layout (v1.20+). It reads InstallRoot/current.json, starts
|
||||
// the active reasonix-desktop binary, and exits. It never counts crashes,
|
||||
// chooses previous versions, or enters a product safe mode.
|
||||
//
|
||||
// Migration compatibility: old shortcuts may still pass "launch", "--detach",
|
||||
// or "--safe-mode". Those tokens are stripped and ignored.
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"reasonix/internal/desktoplauncher"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:]))
|
||||
}
|
||||
|
||||
func run(args []string) int {
|
||||
return desktoplauncher.Run(args, version)
|
||||
}
|
||||
|
||||
func resolveDesktopPath(installRoot string) (string, error) {
|
||||
return desktoplauncher.ResolveDesktopPath(installRoot)
|
||||
}
|
||||
|
||||
func stripLegacyLaunchArgs(args []string) []string {
|
||||
return desktoplauncher.StripLegacyLaunchArgs(args)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/installlayout"
|
||||
)
|
||||
|
||||
func TestStripLegacyLaunchArgs(t *testing.T) {
|
||||
got := stripLegacyLaunchArgs([]string{
|
||||
"launch", "--detach", "--safe-mode", "--app", `C:\Old\reasonix-desktop.exe`, "--foo", "bar",
|
||||
})
|
||||
want := []string{"--foo", "bar"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("got %#v want %#v", got, want)
|
||||
}
|
||||
got = stripLegacyLaunchArgs([]string{"--app=ignored", "keep"})
|
||||
if len(got) != 1 || got[0] != "keep" {
|
||||
t.Fatalf("got %#v", got)
|
||||
}
|
||||
got = stripLegacyLaunchArgs([]string{"--", "--safe-mode", "x"})
|
||||
if len(got) != 3 || got[0] != "--" {
|
||||
t.Fatalf("separator not preserved: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDesktopPathUsesCurrentJSON(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
src := t.TempDir()
|
||||
version := "v1.20.0"
|
||||
members := make([]installlayout.Member, 0, 3)
|
||||
for _, name := range installlayout.AllowedVersionMembers() {
|
||||
path := filepath.Join(src, name)
|
||||
if err := os.WriteFile(path, []byte("bin-"+name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
members = append(members, installlayout.Member{Name: name, Path: path})
|
||||
}
|
||||
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
|
||||
InstallRoot: root,
|
||||
Version: version,
|
||||
RequestID: "launcher-test",
|
||||
Members: members,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path, err := resolveDesktopPath(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(root, "versions", version, installlayout.DesktopBinaryName())
|
||||
if path != want {
|
||||
t.Fatalf("path=%s want=%s", path, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDesktopPathFallsBackToFlatSibling(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
flat := filepath.Join(root, installlayout.DesktopBinaryName())
|
||||
if err := os.WriteFile(flat, []byte("flat"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path, err := resolveDesktopPath(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path != flat {
|
||||
t.Fatalf("path=%s want=%s", path, flat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDesktopPathRejectsCorruptPointer(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Write a current.json that fails schema validation.
|
||||
if err := os.WriteFile(filepath.Join(root, installlayout.CurrentFileName), []byte(`{"schemaVersion":99}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Even with a flat sibling, a corrupt pointer must fail closed. Falling back
|
||||
// would silently run a stale executable after a partially observed update.
|
||||
if err := os.WriteFile(filepath.Join(root, installlayout.DesktopBinaryName()), []byte("x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path, err := resolveDesktopPath(root); err == nil {
|
||||
t.Fatalf("corrupt current.json resolved stale flat desktop %q", path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
// Command reasonix-legacy-migrator is the one-shot flat→versioned install
|
||||
// migrator used by v1.20+ packaging. In compatibility payloads it is still
|
||||
// named reasonix-guard(.exe) so 1.18–1.19.1 updaters can hand off; the source
|
||||
// and behavior are intentionally separate from the old Guard recovery product.
|
||||
//
|
||||
// It only: acquires a migration lock, validates the flat release unit, creates
|
||||
// versions/<version>, writes current.json, rewrites entry points, starts the
|
||||
// thin launcher, and self-deletes when possible. It never chooses safe mode,
|
||||
// counts crashes, or auto-rolls back.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/desktoplauncher"
|
||||
"reasonix/internal/fileutil"
|
||||
"reasonix/internal/installlayout"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
const migrationLockName = ".reasonix-layout-migrate.lock"
|
||||
|
||||
func main() {
|
||||
if runningAsLauncher() {
|
||||
os.Exit(desktoplauncher.Run(os.Args[1:], version))
|
||||
}
|
||||
os.Exit(run(os.Args[1:]))
|
||||
}
|
||||
|
||||
func run(args []string) int {
|
||||
if len(args) == 1 {
|
||||
switch args[0] {
|
||||
case "version", "--version", "-v":
|
||||
fmt.Println("reasonix-legacy-migrator", version)
|
||||
return 0
|
||||
case "help", "--help", "-h":
|
||||
fmt.Println("usage: reasonix-legacy-migrator [--install-root PATH] [--version VERSION] [--activate-staging PATH]")
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
installRoot := ""
|
||||
activeVersion := strings.TrimSpace(version)
|
||||
activateStaging := ""
|
||||
relaunch := true
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--install-root":
|
||||
if i+1 >= len(args) {
|
||||
fmt.Fprintln(os.Stderr, "error: --install-root requires a path")
|
||||
return 2
|
||||
}
|
||||
i++
|
||||
installRoot = args[i]
|
||||
case "--version":
|
||||
if i+1 >= len(args) {
|
||||
fmt.Fprintln(os.Stderr, "error: --version requires a value")
|
||||
return 2
|
||||
}
|
||||
i++
|
||||
activeVersion = args[i]
|
||||
case "--activate-staging":
|
||||
if i+1 >= len(args) {
|
||||
fmt.Fprintln(os.Stderr, "error: --activate-staging requires a path")
|
||||
return 2
|
||||
}
|
||||
i++
|
||||
activateStaging = args[i]
|
||||
case "launch", "--detach", "--safe-mode":
|
||||
// Accept legacy argv from old shortcuts; no product behavior.
|
||||
continue
|
||||
case "--no-relaunch":
|
||||
relaunch = false
|
||||
continue
|
||||
case "--app":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if installRoot == "" {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
installRoot = filepath.Dir(exe)
|
||||
}
|
||||
installRoot = filepath.Clean(installRoot)
|
||||
if activateStaging != "" {
|
||||
activeVersion, err := normalizeActiveVersion(activeVersion)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if err := activateInstallerStaging(installRoot, activeVersion, activateStaging); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
if relaunch {
|
||||
_ = startLauncher(installRoot)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
if err := migrateWithRelaunch(installRoot, activeVersion, relaunch); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func migrate(installRoot, activeVersion string) error {
|
||||
return migrateWithRelaunch(installRoot, activeVersion, true)
|
||||
}
|
||||
|
||||
func migrateWithRelaunch(installRoot, activeVersion string, relaunch bool) error {
|
||||
var err error
|
||||
activeVersion, err = normalizeActiveVersion(activeVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
unlock, err := acquireMigrationLock(installRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
// Pointer already committed: only cleanup, never overwrite the active version.
|
||||
// A present but corrupt pointer is fatal; treating it like an absent pointer
|
||||
// could overwrite evidence of a partial activation and migrate stale files.
|
||||
currentPath := filepath.Join(installRoot, installlayout.CurrentFileName)
|
||||
if _, statErr := os.Lstat(currentPath); statErr == nil {
|
||||
ptr, err := installlayout.ReadCurrent(installRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := finalizeLegacyPendingUpdate(installRoot, ptr.ActiveVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = cleanupLegacyFlatFiles(installRoot)
|
||||
_ = archiveInstallRootLegacyMarkers(installRoot)
|
||||
if relaunch {
|
||||
_ = startLauncher(installRoot) // best-effort; layout already committed
|
||||
selfDelete()
|
||||
}
|
||||
return nil
|
||||
} else if !os.IsNotExist(statErr) {
|
||||
return fmt.Errorf("migrate: inspect current.json: %w", statErr)
|
||||
}
|
||||
|
||||
desktopName := installlayout.DesktopBinaryName()
|
||||
cliName := installlayout.CLIBinaryName()
|
||||
helperName := installlayout.UpdateHelperBinaryName()
|
||||
flatDesktop := filepath.Join(installRoot, desktopName)
|
||||
if _, err := os.Lstat(flatDesktop); err != nil {
|
||||
return fmt.Errorf("migrate: flat desktop binary missing: %w", err)
|
||||
}
|
||||
|
||||
members := []installlayout.Member{
|
||||
{Name: desktopName, Path: flatDesktop},
|
||||
}
|
||||
if p := optionalRegular(filepath.Join(installRoot, cliName)); p != "" {
|
||||
members = append(members, installlayout.Member{Name: cliName, Path: p})
|
||||
} else {
|
||||
// CLI may be absent on some portable trees; synthesize from desktop only
|
||||
// is not allowed — require the whitelist. Prefer copying desktop as a
|
||||
// last-resort placeholder is forbidden; fail closed.
|
||||
return fmt.Errorf("migrate: flat CLI binary %s is required", cliName)
|
||||
}
|
||||
requiredNames := []string{desktopName, cliName}
|
||||
if runtime.GOOS == "windows" {
|
||||
requiredNames = append(requiredNames, helperName)
|
||||
if p := optionalRegular(filepath.Join(installRoot, helperName)); p != "" {
|
||||
members = append(members, installlayout.Member{Name: helperName, Path: p})
|
||||
} else {
|
||||
return fmt.Errorf("migrate: flat update helper %s is required", helperName)
|
||||
}
|
||||
}
|
||||
|
||||
// Old Linux updaters only publish desktop, CLI, and the compatibility
|
||||
// migrator. On Unix the migrator is also a thin-launcher multicall binary,
|
||||
// so it can create the permanent entry point without another payload member.
|
||||
if err := ensureLauncherEntry(installRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
// v1.18-v1.19 helpers leave an installed transaction awaiting Guard health.
|
||||
// The flat release unit is still intact here, so commit that exact verified
|
||||
// transaction before moving its files into the version directory.
|
||||
if err := finalizeLegacyPendingUpdate(installRoot, activeVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
|
||||
InstallRoot: installRoot,
|
||||
Version: activeVersion,
|
||||
RequestID: "legacy-migrate",
|
||||
Members: members,
|
||||
RequiredNames: requiredNames,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("migrate: activate versioned layout: %w", err)
|
||||
}
|
||||
|
||||
_ = cleanupLegacyFlatFiles(installRoot)
|
||||
_ = archiveInstallRootLegacyMarkers(installRoot)
|
||||
if relaunch {
|
||||
// Launcher start is best-effort: layout activation is the commit point.
|
||||
if err := startLauncher(installRoot); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "warning: start launcher after migrate:", err)
|
||||
}
|
||||
// Unix can unlink the running migrator. On Windows the parent thin launcher
|
||||
// removes it after this process exits.
|
||||
selfDelete()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeActiveVersion(activeVersion string) (string, error) {
|
||||
if err := installlayout.ValidateVersionName(activeVersion); err != nil {
|
||||
// Accept bare "dev" builds in development only.
|
||||
if activeVersion == "" || activeVersion == "dev" {
|
||||
activeVersion = "v0.0.0-dev"
|
||||
if err := installlayout.ValidateVersionName(activeVersion); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else if !strings.HasPrefix(activeVersion, "v") {
|
||||
activeVersion = "v" + activeVersion
|
||||
if err := installlayout.ValidateVersionName(activeVersion); err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return activeVersion, nil
|
||||
}
|
||||
|
||||
func activateInstallerStaging(installRoot, activeVersion, stagingRoot string) error {
|
||||
installRoot = filepath.Clean(strings.TrimSpace(installRoot))
|
||||
stagingRoot = filepath.Clean(strings.TrimSpace(stagingRoot))
|
||||
if !pathWithinInstallRoot(installRoot, stagingRoot) || stagingRoot == installRoot {
|
||||
return fmt.Errorf("installer staging must be inside the install root")
|
||||
}
|
||||
info, err := os.Lstat(stagingRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("inspect installer staging: %w", err)
|
||||
}
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("installer staging must be a real directory")
|
||||
}
|
||||
|
||||
desktopName := installlayout.DesktopBinaryName()
|
||||
cliName := installlayout.CLIBinaryName()
|
||||
requiredNames := []string{desktopName, cliName}
|
||||
if runtime.GOOS == "windows" {
|
||||
requiredNames = append(requiredNames, installlayout.UpdateHelperBinaryName())
|
||||
}
|
||||
members := make([]installlayout.Member, 0, len(requiredNames))
|
||||
for _, name := range requiredNames {
|
||||
members = append(members, installlayout.Member{Name: name, Path: filepath.Join(stagingRoot, name)})
|
||||
}
|
||||
|
||||
launcherName := installlayout.LauncherBinaryName()
|
||||
launcherSource := filepath.Join(stagingRoot, launcherName)
|
||||
rootMembers := []installlayout.Member{
|
||||
{Name: launcherName, Path: launcherSource},
|
||||
{Name: cliName, Path: filepath.Join(stagingRoot, cliName)},
|
||||
}
|
||||
requiredRootNames := []string{launcherName, cliName}
|
||||
if alias := installlayout.PortableAliasName(); alias != "" {
|
||||
rootMembers = append(rootMembers, installlayout.Member{Name: alias, Path: launcherSource})
|
||||
requiredRootNames = append(requiredRootNames, alias)
|
||||
}
|
||||
|
||||
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
|
||||
InstallRoot: installRoot,
|
||||
Version: activeVersion,
|
||||
RequestID: "signed-installer-" + activeVersion,
|
||||
Members: members,
|
||||
RequiredNames: requiredNames,
|
||||
RootMembers: rootMembers,
|
||||
RequiredRootNames: requiredRootNames,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("activate signed installer staging: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runningAsLauncher() bool {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(filepath.Base(exe), installlayout.LauncherBinaryName())
|
||||
}
|
||||
|
||||
func optionalRegular(path string) string {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return ""
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func acquireMigrationLock(installRoot string) (func(), error) {
|
||||
if err := os.MkdirAll(installRoot, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := filepath.Join(installRoot, migrationLockName)
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
if os.IsExist(err) {
|
||||
// Stale lock from a dead migrator: if older than 10 minutes, steal it.
|
||||
info, statErr := os.Stat(path)
|
||||
if statErr == nil && time.Since(info.ModTime()) > 10*time.Minute {
|
||||
_ = os.Remove(path)
|
||||
f, err = os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migrate: acquire lock: %w", err)
|
||||
}
|
||||
}
|
||||
_, _ = fmt.Fprintf(f, "pid=%d\nversion=%s\n", os.Getpid(), version)
|
||||
_ = f.Close()
|
||||
return func() { _ = os.Remove(path) }, nil
|
||||
}
|
||||
|
||||
func ensureLauncherEntry(installRoot string) error {
|
||||
launcher := installlayout.LauncherBinaryName()
|
||||
path := filepath.Join(installRoot, launcher)
|
||||
if info, err := os.Lstat(path); err == nil {
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("migrate: thin launcher %s is not a regular file", launcher)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// On Windows also accept Reasonix.exe as the alias.
|
||||
if runtime.GOOS == "windows" {
|
||||
if _, err := os.Lstat(filepath.Join(installRoot, "Reasonix.exe")); err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("migrate: thin launcher %s is missing; install a signed package", launcher)
|
||||
}
|
||||
|
||||
// Legacy Linux archives cannot deliver a fourth member through the old
|
||||
// updater. Copy this signed multicall migrator as the permanent thin launcher.
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate: resolve migrator executable: %w", err)
|
||||
}
|
||||
info, err := os.Lstat(exe)
|
||||
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("migrate: migrator executable is not a regular file")
|
||||
}
|
||||
body, err := os.ReadFile(exe)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate: read launcher source: %w", err)
|
||||
}
|
||||
if err := fileutil.AtomicWriteFile(path, body, 0o755); err != nil {
|
||||
return fmt.Errorf("migrate: install thin launcher: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func finalizeLegacyPendingUpdate(installRoot, activeVersion string) error {
|
||||
tx, err := repair.ReadPendingUpdate()
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate: read legacy pending update: %w", err)
|
||||
}
|
||||
if tx.TargetKind != "file" {
|
||||
return fmt.Errorf("migrate: legacy pending update target kind %q is not supported", tx.TargetKind)
|
||||
}
|
||||
if strings.TrimSpace(tx.ToVersion) != strings.TrimSpace(activeVersion) {
|
||||
return fmt.Errorf("migrate: pending update targets %s, not %s", tx.ToVersion, activeVersion)
|
||||
}
|
||||
for _, target := range append([]repair.UpdateTransactionFile{{TargetPath: tx.TargetPath}}, tx.Files...) {
|
||||
if !pathWithinInstallRoot(installRoot, target.TargetPath) {
|
||||
return fmt.Errorf("migrate: pending update target escapes install root")
|
||||
}
|
||||
}
|
||||
id := repair.UpdateTransactionID(tx)
|
||||
if err := repair.MarkUpdateHealthyExact(activeVersion, tx.CreatedAt, id); err != nil {
|
||||
return fmt.Errorf("migrate: commit legacy pending update: %w", err)
|
||||
}
|
||||
if current, err := repair.ReadPendingUpdate(); err == nil {
|
||||
if repair.UpdateTransactionID(current) == id {
|
||||
return fmt.Errorf("migrate: legacy pending update remained after commit")
|
||||
}
|
||||
return fmt.Errorf("migrate: pending update changed during commit")
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("migrate: verify legacy pending update commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pathWithinInstallRoot(installRoot, target string) bool {
|
||||
root := filepath.Clean(strings.TrimSpace(installRoot))
|
||||
target = filepath.Clean(strings.TrimSpace(target))
|
||||
if root == "" || target == "" || !filepath.IsAbs(root) || !filepath.IsAbs(target) {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(root, target)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func cleanupLegacyFlatFiles(installRoot string) error {
|
||||
// Remove flat desktop/CLI/helper/guard sidecars after the version tree is
|
||||
// active. Never delete the thin launcher or current.json.
|
||||
names := []string{
|
||||
installlayout.DesktopBinaryName(),
|
||||
installlayout.CLIBinaryName(),
|
||||
installlayout.UpdateHelperBinaryName(),
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
names = append(names, "reasonix-guard.exe")
|
||||
} else {
|
||||
names = append(names, "reasonix-guard")
|
||||
}
|
||||
for _, name := range names {
|
||||
path := filepath.Join(installRoot, name)
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func archiveInstallRootLegacyMarkers(installRoot string) error {
|
||||
// Very old portable builds wrote markers beside the binary. Current repair
|
||||
// state under Reasonix home is finalized through repair transaction APIs.
|
||||
markers := []string{
|
||||
"pending-update.json",
|
||||
"startup-state.json",
|
||||
}
|
||||
ts := time.Now().UTC().Format("20060102T150405Z")
|
||||
destRoot := filepath.Join(installRoot, "repair", "legacy-v1", ts)
|
||||
moved := false
|
||||
for _, name := range markers {
|
||||
src := filepath.Join(installRoot, name)
|
||||
if _, err := os.Lstat(src); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(destRoot, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
dest := filepath.Join(destRoot, name)
|
||||
if err := os.Rename(src, dest); err != nil {
|
||||
// Cross-device: copy+remove best effort.
|
||||
data, readErr := os.ReadFile(src)
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
if writeErr := fileutil.AtomicWriteFile(dest, data, 0o600); writeErr == nil {
|
||||
_ = os.Remove(src)
|
||||
}
|
||||
}
|
||||
moved = true
|
||||
}
|
||||
if moved {
|
||||
meta, _ := json.MarshalIndent(map[string]any{
|
||||
"migratedAt": time.Now().UTC().Format(time.RFC3339),
|
||||
"from": "flat-v1",
|
||||
"to": installlayout.InstallLayoutVersionedV1,
|
||||
}, "", " ")
|
||||
_ = fileutil.AtomicWriteFile(filepath.Join(destRoot, "migration.json"), append(meta, '\n'), 0o644)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func startLauncher(installRoot string) error {
|
||||
launcher := "reasonix-launcher"
|
||||
if runtime.GOOS == "windows" {
|
||||
launcher += ".exe"
|
||||
}
|
||||
path := filepath.Join(installRoot, launcher)
|
||||
if _, err := os.Lstat(path); err != nil {
|
||||
if runtime.GOOS == "windows" {
|
||||
path = filepath.Join(installRoot, "Reasonix.exe")
|
||||
}
|
||||
}
|
||||
if _, err := os.Lstat(path); err != nil {
|
||||
// Migration succeeded; user can start manually.
|
||||
return nil
|
||||
}
|
||||
cmd := exec.Command(path)
|
||||
cmd.Dir = installRoot
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
|
||||
if runtime.GOOS == "windows" {
|
||||
return cmd.Start()
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
func selfDelete() {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Do not delete if we are not named like a compatibility guard payload.
|
||||
base := strings.ToLower(filepath.Base(exe))
|
||||
if base != "reasonix-guard" && base != "reasonix-guard.exe" {
|
||||
return
|
||||
}
|
||||
_ = os.Remove(exe)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/installlayout"
|
||||
)
|
||||
|
||||
func installerVersionNames() []string {
|
||||
names := []string{installlayout.DesktopBinaryName(), installlayout.CLIBinaryName()}
|
||||
if runtime.GOOS == "windows" {
|
||||
names = append(names, installlayout.UpdateHelperBinaryName())
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func writeInstallerStaging(t *testing.T, root, label string, includeLauncher bool) string {
|
||||
t.Helper()
|
||||
staging := filepath.Join(root, "versions", ".installer-"+label)
|
||||
if err := os.MkdirAll(staging, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range installerVersionNames() {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(label+"-"+name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if includeLauncher {
|
||||
name := installlayout.LauncherBinaryName()
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(label+"-"+name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return staging
|
||||
}
|
||||
|
||||
func TestMigrateFlatInstallToVersioned(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
// Flat release unit.
|
||||
for _, name := range installlayout.AllowedVersionMembers() {
|
||||
if err := os.WriteFile(filepath.Join(root, name), []byte("flat-"+name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Thin launcher entry must already exist (packaging places it). Use a
|
||||
// non-executable marker so startLauncher fails closed without hanging.
|
||||
_ = os.WriteFile(filepath.Join(root, "reasonix-launcher"), []byte("launcher"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(root, "reasonix-launcher.exe"), []byte("launcher"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(root, "Reasonix.exe"), []byte("launcher"), 0o644)
|
||||
// Marker files that must be archived, not deleted silently without record.
|
||||
_ = os.WriteFile(filepath.Join(root, "pending-update.json"), []byte(`{"pending":true}`), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(root, "startup-state.json"), []byte(`{}`), 0o644)
|
||||
|
||||
if err := migrate(root, "v1.20.0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !installlayout.HasCurrent(root) {
|
||||
t.Fatal("current.json missing after migration")
|
||||
}
|
||||
ptr, err := installlayout.ReadCurrent(root)
|
||||
if err != nil || ptr.ActiveVersion != "v1.20.0" {
|
||||
t.Fatalf("pointer=%+v err=%v", ptr, err)
|
||||
}
|
||||
// Flat desktop must be cleaned up after successful activation.
|
||||
if _, err := os.Stat(filepath.Join(root, installlayout.DesktopBinaryName())); !os.IsNotExist(err) {
|
||||
t.Fatal("flat desktop should be removed after migration")
|
||||
}
|
||||
// Active desktop lives under versions/.
|
||||
if _, err := installlayout.ActiveDesktopPath(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Second run is idempotent cleanup-only.
|
||||
if err := migrate(root, "v1.20.0"); err != nil {
|
||||
t.Fatalf("idempotent re-run: %v", err)
|
||||
}
|
||||
ptr2, err := installlayout.ReadCurrent(root)
|
||||
if err != nil || ptr2.ActiveVersion != "v1.20.0" {
|
||||
t.Fatalf("re-run overwrote active version: %+v", ptr2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateRefusesWithoutFlatUnit(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := migrate(root, "v1.20.0"); err == nil {
|
||||
t.Fatal("expected failure without flat desktop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateRefusesCorruptCurrentPointerWithoutOverwritingIt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
corrupt := []byte(`{"schemaVersion":99}`)
|
||||
current := filepath.Join(root, installlayout.CurrentFileName)
|
||||
if err := os.WriteFile(current, corrupt, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range installlayout.AllowedVersionMembers() {
|
||||
if err := os.WriteFile(filepath.Join(root, name), []byte("stale-"+name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := migrate(root, "v1.20.0"); err == nil {
|
||||
t.Fatal("corrupt current.json was treated as an absent pointer")
|
||||
}
|
||||
got, err := os.ReadFile(current)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(corrupt) {
|
||||
t.Fatalf("corrupt pointer was overwritten: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateInstallerStagingPublishesVersionAndRootEntries(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
staging := writeInstallerStaging(t, root, "new", true)
|
||||
if err := activateInstallerStaging(root, "v1.20.0", staging); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ptr, err := installlayout.ReadCurrent(root)
|
||||
if err != nil || ptr.ActiveVersion != "v1.20.0" {
|
||||
t.Fatalf("pointer=%+v err=%v", ptr, err)
|
||||
}
|
||||
activeDesktop, err := installlayout.ActiveDesktopPath(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := os.ReadFile(activeDesktop); err != nil || string(got) != "new-"+installlayout.DesktopBinaryName() {
|
||||
t.Fatalf("active desktop=%q err=%v", got, err)
|
||||
}
|
||||
for _, name := range []string{installlayout.LauncherBinaryName(), installlayout.CLIBinaryName()} {
|
||||
if info, err := os.Lstat(filepath.Join(root, name)); err != nil || !info.Mode().IsRegular() {
|
||||
t.Fatalf("root entry %s missing or invalid: %v", name, err)
|
||||
}
|
||||
}
|
||||
if alias := installlayout.PortableAliasName(); alias != "" {
|
||||
if got, err := os.ReadFile(filepath.Join(root, alias)); err != nil || string(got) != "new-"+installlayout.LauncherBinaryName() {
|
||||
t.Fatalf("portable alias=%q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateInstallerStagingFailureKeepsPreviousPointer(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
oldStaging := writeInstallerStaging(t, root, "old", true)
|
||||
if err := activateInstallerStaging(root, "v1.19.1", oldStaging); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
broken := writeInstallerStaging(t, root, "broken", false)
|
||||
if err := activateInstallerStaging(root, "v1.20.0", broken); err == nil {
|
||||
t.Fatal("staging without a launcher was activated")
|
||||
}
|
||||
ptr, err := installlayout.ReadCurrent(root)
|
||||
if err != nil || ptr.ActiveVersion != "v1.19.1" {
|
||||
t.Fatalf("previous pointer changed after failed activation: %+v err=%v", ptr, err)
|
||||
}
|
||||
if err := activateInstallerStaging(root, "v1.20.0", t.TempDir()); err == nil {
|
||||
t.Fatal("staging outside the install root was accepted")
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,11 @@ func TestRunWithCrashCaptureRecordsAndReraises(t *testing.T) {
|
||||
if err != nil || len(reports) != 1 {
|
||||
t.Fatalf("captured reports=%d err=%v", len(reports), err)
|
||||
}
|
||||
if got := reports[0].Report.TopFrame; !strings.Contains(got, "main_test.go:") {
|
||||
t.Fatalf("top frame = %q, want panic call site in main_test.go", got)
|
||||
// Token-like function and file basenames may be redacted by the current
|
||||
// privacy filter. Keep asserting that the top frame is the test call site
|
||||
// without requiring its pre-redaction basename.
|
||||
if got := reports[0].Report.TopFrame; !strings.Contains(got, "_test.go:") {
|
||||
t.Fatalf("top frame = %q, want sanitized panic call site in a test file", got)
|
||||
}
|
||||
preview, err := crashreport.Preview(reports[0].Report)
|
||||
if err != nil {
|
||||
|
||||
@@ -28,7 +28,6 @@ func TestTopLevelSignPathWorkflowCallGraph(t *testing.T) {
|
||||
}
|
||||
want := []string{
|
||||
".github/workflows/release-desktop.yml",
|
||||
".github/workflows/release-preview.yml",
|
||||
".github/workflows/release-stable.yml",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
!/build/linux/nfpm.yaml
|
||||
!/build/linux/reasonix.desktop
|
||||
!/build/linux/io.reasonix.desktop.update.policy
|
||||
!/build/linux/postinstall.sh
|
||||
!/build/linux/icons
|
||||
!/build/linux/icons/**
|
||||
|
||||
|
||||
+659
-221
File diff suppressed because it is too large.
Load diff
+193
-9
@@ -24,6 +24,7 @@ import (
|
||||
"reasonix/internal/billing"
|
||||
"reasonix/internal/boot"
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/command"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
@@ -35,6 +36,7 @@ import (
|
||||
"reasonix/internal/plugin"
|
||||
"reasonix/internal/pluginpkg"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/remote/protocol"
|
||||
"reasonix/internal/sandbox"
|
||||
"reasonix/internal/skill"
|
||||
"reasonix/internal/store"
|
||||
@@ -372,9 +374,12 @@ func (r *desktopAskRuntimeRunner) Run(ctx context.Context, _ string) error {
|
||||
return r.ask(ctx)
|
||||
}
|
||||
|
||||
func TestCommandsIncludesEffortNotThinking(t *testing.T) {
|
||||
func TestCommandsIncludesDocsAndEffortNotThinking(t *testing.T) {
|
||||
app := NewApp()
|
||||
cmds := app.Commands()
|
||||
if !hasCommand(cmds, "docs") {
|
||||
t.Fatalf("Commands() should include docs: %+v", cmds)
|
||||
}
|
||||
if !hasCommand(cmds, "effort") {
|
||||
t.Fatalf("Commands() should include effort: %+v", cmds)
|
||||
}
|
||||
@@ -383,6 +388,153 @@ func TestCommandsIncludesEffortNotThinking(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandsDocsShowsOnlyRuntimeWinner(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
commands []command.Command
|
||||
skills []skill.Skill
|
||||
wantKind string
|
||||
}{
|
||||
{
|
||||
name: "custom command shadows builtin",
|
||||
commands: []command.Command{{Name: "docs", Description: "custom docs"}},
|
||||
wantKind: "custom",
|
||||
},
|
||||
{
|
||||
name: "skill shadows builtin",
|
||||
skills: []skill.Skill{{Name: "docs", Description: "docs skill"}},
|
||||
wantKind: "skill",
|
||||
},
|
||||
{
|
||||
name: "custom command shadows skill and builtin",
|
||||
commands: []command.Command{{Name: "docs", Description: "custom docs"}},
|
||||
skills: []skill.Skill{{Name: "docs", Description: "docs skill"}},
|
||||
wantKind: "custom",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctrl := control.New(control.Options{Commands: tt.commands, Skills: tt.skills})
|
||||
defer ctrl.Close()
|
||||
app := NewApp()
|
||||
app.setTestCtrl(ctrl, "")
|
||||
|
||||
var docs []CommandInfo
|
||||
for _, cmd := range app.Commands() {
|
||||
if cmd.Name == "docs" {
|
||||
docs = append(docs, cmd)
|
||||
}
|
||||
}
|
||||
if len(docs) != 1 || docs[0].Kind != tt.wantKind {
|
||||
t.Fatalf("docs commands = %+v, want one %s entry", docs, tt.wantKind)
|
||||
}
|
||||
if fallback, ok := commandInfoByName(app.Commands(), control.ReasonixDocsSlashName); !ok || fallback.Kind != "builtin" {
|
||||
t.Fatalf("qualified docs fallback = %+v, %v; want built-in", fallback, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandsDocsAccountsForHiddenCompatibilityAliases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
commands []command.Command
|
||||
skills []skill.Skill
|
||||
wantCanonical string
|
||||
}{
|
||||
{
|
||||
name: "hidden plugin command alias",
|
||||
commands: []command.Command{
|
||||
{Name: "docs", Plugin: "manuals", Hidden: true},
|
||||
{Name: "manuals:docs", Plugin: "manuals"},
|
||||
},
|
||||
wantCanonical: "manuals:docs",
|
||||
},
|
||||
{
|
||||
name: "compatible plugin skill alias",
|
||||
skills: []skill.Skill{{Name: "docs", Plugin: "manuals"}},
|
||||
wantCanonical: "manuals:docs",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctrl := control.New(control.Options{Commands: tt.commands, Skills: tt.skills})
|
||||
defer ctrl.Close()
|
||||
app := NewApp()
|
||||
app.setTestCtrl(ctrl, "")
|
||||
commands := app.Commands()
|
||||
if _, ok := commandInfoByName(commands, "docs"); ok {
|
||||
t.Fatalf("hidden runtime owner left a misleading docs entry: %+v", commands)
|
||||
}
|
||||
for _, want := range []string{control.ReasonixDocsSlashName, tt.wantCanonical} {
|
||||
if _, ok := commandInfoByName(commands, want); !ok {
|
||||
t.Fatalf("commands missing %q: %+v", want, commands)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandsDocsDoesNotDisplaceQualifiedCustomCommands(t *testing.T) {
|
||||
ctrl := control.New(control.Options{Commands: []command.Command{
|
||||
{Name: "docs", Description: "custom docs"},
|
||||
{Name: "reasonix:docs", Description: "qualified custom docs"},
|
||||
{Name: "reasonix:builtin:docs", Description: "second qualified custom docs"},
|
||||
}})
|
||||
defer ctrl.Close()
|
||||
app := NewApp()
|
||||
app.setTestCtrl(ctrl, "")
|
||||
commands := app.Commands()
|
||||
for _, want := range []struct {
|
||||
name string
|
||||
kind string
|
||||
}{
|
||||
{name: "docs", kind: "custom"},
|
||||
{name: "reasonix:docs", kind: "custom"},
|
||||
{name: "reasonix:builtin:docs", kind: "custom"},
|
||||
{name: "reasonix:builtin:docs:2", kind: "builtin"},
|
||||
} {
|
||||
if command, ok := commandInfoByName(commands, want.name); !ok || command.Kind != want.kind {
|
||||
t.Fatalf("command %q = %+v, %v; want kind %q", want.name, command, ok, want.kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendRemoteSessionCommandsUsesHostResolvedDocsName(t *testing.T) {
|
||||
base := []CommandInfo{{Name: "new", Kind: "builtin", Group: "actions"}}
|
||||
legacy := appendRemoteSessionCommands(base, protocol.SessionCatalogResult{})
|
||||
if _, ok := commandInfoByName(legacy, "docs"); ok {
|
||||
t.Fatalf("new desktop invented /docs for an older host: %+v", legacy)
|
||||
}
|
||||
catalog := protocol.SessionCatalogResult{
|
||||
BuiltinCommands: []protocol.BuiltinCommandCatalogItem{{
|
||||
Name: control.ReasonixDocsSlashName, Description: "host docs", Hint: "<question>", Group: "integrations",
|
||||
}},
|
||||
Commands: []protocol.CommandCatalogItem{{Name: "manuals:docs", Description: "plugin docs"}},
|
||||
Skills: []protocol.SkillCatalogItem{},
|
||||
}
|
||||
commands := appendRemoteSessionCommands(base, catalog)
|
||||
if _, ok := commandInfoByName(commands, "docs"); ok {
|
||||
t.Fatalf("remote commands invented an unavailable short docs route: %+v", commands)
|
||||
}
|
||||
if fallback, ok := commandInfoByName(commands, control.ReasonixDocsSlashName); !ok || fallback.Kind != "builtin" || fallback.Hint != "<question>" {
|
||||
t.Fatalf("remote qualified docs fallback = %+v, %v", fallback, ok)
|
||||
}
|
||||
if canonical, ok := commandInfoByName(commands, "manuals:docs"); !ok || canonical.Kind != "custom" {
|
||||
t.Fatalf("remote canonical plugin docs = %+v, %v", canonical, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func commandInfoByName(commands []CommandInfo, name string) (CommandInfo, bool) {
|
||||
for _, command := range commands {
|
||||
if command.Name == name {
|
||||
return command, true
|
||||
}
|
||||
}
|
||||
return CommandInfo{}, false
|
||||
}
|
||||
|
||||
func TestCommandsClassifiesSubagentSkills(t *testing.T) {
|
||||
ctrl := control.New(control.Options{Skills: []skill.Skill{
|
||||
{Name: "init", Description: "inline skill", RunAs: skill.RunInline},
|
||||
@@ -996,6 +1148,9 @@ status_bar_items = ["cost", "balance"]
|
||||
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
|
||||
t.Fatalf("set desktop appearance: %v", err)
|
||||
}
|
||||
if err := userCfg.SetDesktopTerminalTheme("light"); err != nil {
|
||||
t.Fatalf("set desktop terminal theme: %v", err)
|
||||
}
|
||||
if err := userCfg.SetDesktopCloseBehavior("background"); err != nil {
|
||||
t.Fatalf("set desktop close behavior: %v", err)
|
||||
}
|
||||
@@ -1016,7 +1171,7 @@ status_bar_items = ["cost", "balance"]
|
||||
}
|
||||
|
||||
got := NewApp().Settings()
|
||||
if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.CloseBehavior != "background" || got.StatusBarStyle != "text" {
|
||||
if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DesktopTerminalTheme != "light" || got.CloseBehavior != "background" || got.StatusBarStyle != "text" {
|
||||
t.Fatalf("desktop settings = lang:%q layout:%q theme:%q style:%q close:%q status:%q, want user-level desktop prefs", got.DesktopLanguage, got.DesktopLayoutStyle, got.DesktopTheme, got.DesktopThemeStyle, got.CloseBehavior, got.StatusBarStyle)
|
||||
}
|
||||
if want := []string{"model", "balance", "cache"}; !reflect.DeepEqual(got.StatusBarItems, want) {
|
||||
@@ -1037,6 +1192,9 @@ func TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayl
|
||||
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
|
||||
t.Fatalf("set desktop appearance: %v", err)
|
||||
}
|
||||
if err := userCfg.SetDesktopTerminalTheme("light"); err != nil {
|
||||
t.Fatalf("set desktop terminal theme: %v", err)
|
||||
}
|
||||
if err := userCfg.SetDesktopStatusBarStyle("icon"); err != nil {
|
||||
t.Fatalf("set desktop status bar style: %v", err)
|
||||
}
|
||||
@@ -1057,7 +1215,7 @@ func TestDesktopStartupSettingsUsesUserDesktopPreferencesWithoutFullSettingsPayl
|
||||
}
|
||||
|
||||
got := NewApp().DesktopStartupSettings()
|
||||
if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DisplayMode != "standard" || got.StatusBarStyle != "icon" || got.CheckUpdates || got.UpdateChannel != "preview" {
|
||||
if got.DesktopLanguage != "en" || got.DesktopLayoutStyle != "classic" || got.DesktopTheme != "dark" || got.DesktopThemeStyle != "graphite" || got.DesktopTerminalTheme != "light" || got.DisplayMode != "standard" || got.StatusBarStyle != "icon" || got.CheckUpdates || got.UpdateChannel != "stable" {
|
||||
t.Fatalf("DesktopStartupSettings desktop prefs = %+v, want user-level startup prefs", got)
|
||||
}
|
||||
if want := []string{"workspace", "git_branch", "model"}; !reflect.DeepEqual(got.StatusBarItems, want) {
|
||||
@@ -5622,6 +5780,17 @@ func TestSetTokenModeRejectsRunningTurn(t *testing.T) {
|
||||
|
||||
func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "OLD_MODEL_KEY", "sk-test")
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.DefaultModel = "old/old-model"
|
||||
cfg.Desktop.ProviderAccess = []string{"old"}
|
||||
cfg.Providers = []config.ProviderEntry{
|
||||
{Name: "old", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "old-model", APIKeyEnv: "OLD_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 {
|
||||
@@ -5630,12 +5799,17 @@ func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
|
||||
path := filepath.Join(dir, "jobs.jsonl")
|
||||
jm := jobs.NewManager(event.Discard)
|
||||
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: "test", Jobs: jm})
|
||||
defer ctrl.Close()
|
||||
app := NewApp()
|
||||
app.setTestCtrl(ctrl, "")
|
||||
app.ctx = context.Background()
|
||||
app.setTestCtrl(ctrl, "old/old-model")
|
||||
t.Cleanup(func() {
|
||||
if current := app.activeCtrl(); current != nil {
|
||||
current.Close()
|
||||
}
|
||||
})
|
||||
|
||||
release := make(chan struct{})
|
||||
jm.StartForSession(agent.BranchID(path), "bash", "long job", func(ctx context.Context, _ io.Writer) (string, error) {
|
||||
job := jm.StartForSession(agent.BranchID(path), "bash", "long job", func(ctx context.Context, _ io.Writer) (string, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
@@ -5643,11 +5817,21 @@ func TestSetTokenModeRejectsBackgroundJobs(t *testing.T) {
|
||||
return "", nil
|
||||
}
|
||||
})
|
||||
defer close(release)
|
||||
t.Cleanup(func() { close(release) })
|
||||
|
||||
err := app.SetTokenMode("economy")
|
||||
if err == nil || !strings.Contains(err.Error(), "stop background jobs") {
|
||||
t.Fatalf("SetTokenMode with background job error = %v, want background-job guard", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "background_jobs=1") {
|
||||
t.Fatalf("SetTokenMode with background job error = %v, want exact background-job guard", err)
|
||||
}
|
||||
cancelled, err := app.CancelJobForTab("", job.ID)
|
||||
if err != nil || !cancelled {
|
||||
t.Fatalf("CancelJobForTab = %v, %v, want true, nil", cancelled, err)
|
||||
}
|
||||
if result := jm.WaitForSession(context.Background(), agent.BranchID(path), []string{job.ID}, 5); len(result) != 1 || result[0].Status != jobs.Killed {
|
||||
t.Fatalf("stopped background job = %+v, want one killed result", result)
|
||||
}
|
||||
if err := app.SetTokenMode("economy"); err != nil {
|
||||
t.Fatalf("SetTokenMode after stopping background job: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/workspacelease"
|
||||
)
|
||||
|
||||
// ActiveWorkView is the structured Desktop contract for work that prevents a
|
||||
// controller rebuild or a destructive tab action. Jobs is always a JSON array.
|
||||
type ActiveWorkView struct {
|
||||
Running bool `json:"running"`
|
||||
PendingPrompt bool `json:"pendingPrompt"`
|
||||
Cancellable bool `json:"cancellable"`
|
||||
Jobs []JobView `json:"jobs"`
|
||||
}
|
||||
|
||||
// JobCancelBatchView reports which requested jobs accepted cancellation. Both
|
||||
// slices are initialized so Wails never sends null to React.
|
||||
type JobCancelBatchView struct {
|
||||
Cancelled []string `json:"cancelled"`
|
||||
NotRunning []string `json:"notRunning"`
|
||||
}
|
||||
|
||||
// BackgroundRuntimeView is one visible or detached runtime with active work.
|
||||
// TabID is an opaque process-local handle; paths and session writer ids are
|
||||
// deliberately omitted from this user-facing contract.
|
||||
type BackgroundRuntimeView struct {
|
||||
TabID string `json:"tabId"`
|
||||
Title string `json:"title"`
|
||||
Detached bool `json:"detached"`
|
||||
Running bool `json:"running"`
|
||||
PendingPrompt bool `json:"pendingPrompt"`
|
||||
Jobs []JobView `json:"jobs"`
|
||||
}
|
||||
|
||||
// WorkspaceConflictView describes a currently-waiting Delivery writer without
|
||||
// exposing the lock path, process id, or session path.
|
||||
type WorkspaceConflictView struct {
|
||||
State string `json:"state"`
|
||||
OwnerTabID string `json:"ownerTabId,omitempty"`
|
||||
OwnerTitle string `json:"ownerTitle,omitempty"`
|
||||
OwnerWork ActiveWorkView `json:"ownerWork"`
|
||||
CanReveal bool `json:"canReveal"`
|
||||
CanCreateWorktree bool `json:"canCreateWorktree"`
|
||||
}
|
||||
|
||||
func activeWorkForController(ctrl control.SessionAPI) ActiveWorkView {
|
||||
view := ActiveWorkView{Jobs: []JobView{}}
|
||||
if ctrl == nil {
|
||||
return view
|
||||
}
|
||||
status := ctrl.RuntimeStatus()
|
||||
view.Running = status.Running
|
||||
view.PendingPrompt = status.PendingPrompt
|
||||
view.Cancellable = status.Cancellable
|
||||
for _, job := range ctrl.Jobs() {
|
||||
view.Jobs = append(view.Jobs, JobView{
|
||||
ID: job.ID, Kind: job.Kind, Label: job.Label,
|
||||
Status: job.Status, StartedAt: job.StartedAt,
|
||||
})
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func (v ActiveWorkView) active() bool {
|
||||
return v.Running || v.PendingPrompt || len(v.Jobs) > 0
|
||||
}
|
||||
|
||||
// ActiveWorkForTab returns the precise blocker state for one tab. It is a
|
||||
// preflight aid only; rebuild paths still re-check active work atomically.
|
||||
func (a *App) ActiveWorkForTab(tabID string) ActiveWorkView {
|
||||
return activeWorkForController(a.ctrlForRuntimeTabID(tabID))
|
||||
}
|
||||
|
||||
// CancelJobsForTab requests cancellation for a stable tab id. The job manager
|
||||
// keeps cancelled-but-unwinding jobs visible until their done channels close.
|
||||
func (a *App) CancelJobsForTab(tabID string, jobIDs []string) (JobCancelBatchView, error) {
|
||||
result := JobCancelBatchView{Cancelled: []string{}, NotRunning: []string{}}
|
||||
seen := map[string]bool{}
|
||||
for _, raw := range jobIDs {
|
||||
id := strings.TrimSpace(raw)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
cancelled, err := a.CancelJobForTab(tabID, id)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if cancelled {
|
||||
result.Cancelled = append(result.Cancelled, id)
|
||||
} else {
|
||||
result.NotRunning = append(result.NotRunning, id)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type backgroundRuntimeSnapshot struct {
|
||||
id string
|
||||
ctrl control.SessionAPI
|
||||
detached bool
|
||||
title string
|
||||
}
|
||||
|
||||
// BackgroundRuntimes returns every process-local runtime that still needs a
|
||||
// visible recovery path, including runtimes detached by an explicit tab close.
|
||||
func (a *App) BackgroundRuntimes() []BackgroundRuntimeView {
|
||||
a.mu.RLock()
|
||||
snapshots := make([]backgroundRuntimeSnapshot, 0, len(a.tabs)+len(a.detachedSessions))
|
||||
seen := map[*WorkspaceTab]bool{}
|
||||
for _, tab := range a.tabs {
|
||||
if tab == nil || seen[tab] {
|
||||
continue
|
||||
}
|
||||
seen[tab] = true
|
||||
title := strings.TrimSpace(tab.TopicTitle)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(tab.Label)
|
||||
}
|
||||
snapshots = append(snapshots, backgroundRuntimeSnapshot{id: tab.ID, ctrl: tab.Ctrl, title: title})
|
||||
}
|
||||
for _, tab := range a.detachedSessions {
|
||||
if tab == nil || seen[tab] {
|
||||
continue
|
||||
}
|
||||
seen[tab] = true
|
||||
title := strings.TrimSpace(tab.TopicTitle)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(tab.Label)
|
||||
}
|
||||
snapshots = append(snapshots, backgroundRuntimeSnapshot{id: tab.ID, ctrl: tab.Ctrl, detached: true, title: title})
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
|
||||
out := make([]BackgroundRuntimeView, 0, len(snapshots))
|
||||
for _, snapshot := range snapshots {
|
||||
work := activeWorkForController(snapshot.ctrl)
|
||||
if !work.active() {
|
||||
continue
|
||||
}
|
||||
out = append(out, BackgroundRuntimeView{
|
||||
TabID: snapshot.id, Title: snapshot.title, Detached: snapshot.detached,
|
||||
Running: work.Running, PendingPrompt: work.PendingPrompt, Jobs: work.Jobs,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) ctrlForRuntimeTabID(tabID string) control.SessionAPI {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if strings.TrimSpace(tabID) == "" {
|
||||
return a.activeCtrlLocked()
|
||||
}
|
||||
tab := a.tabByEventSinkIDLocked(tabID)
|
||||
if tab == nil {
|
||||
return nil
|
||||
}
|
||||
return tab.Ctrl
|
||||
}
|
||||
|
||||
// RevealBackgroundRuntime activates a visible owner or reopens the exact
|
||||
// detached session. It never reattaches by workspace alone.
|
||||
func (a *App) RevealBackgroundRuntime(tabID string) (TabMeta, error) {
|
||||
a.mu.RLock()
|
||||
if tab := a.tabs[tabID]; tab != nil {
|
||||
a.mu.RUnlock()
|
||||
if err := a.SetActiveTab(tabID); err != nil {
|
||||
return TabMeta{}, err
|
||||
}
|
||||
a.mu.RLock()
|
||||
current := a.tabs[tabID]
|
||||
if current == nil {
|
||||
a.mu.RUnlock()
|
||||
return TabMeta{}, fmt.Errorf("background task is no longer available")
|
||||
}
|
||||
meta := a.tabMeta(current, true)
|
||||
a.mu.RUnlock()
|
||||
return enrichTabMeta(meta), nil
|
||||
}
|
||||
tab := a.tabByEventSinkIDLocked(tabID)
|
||||
if tab == nil || tab.Ctrl == nil {
|
||||
a.mu.RUnlock()
|
||||
return TabMeta{}, fmt.Errorf("background task is no longer available")
|
||||
}
|
||||
scope := tab.Scope
|
||||
workspaceRoot := tab.WorkspaceRoot
|
||||
topicID := tab.TopicID
|
||||
sessionPath := tab.currentSessionPath()
|
||||
a.mu.RUnlock()
|
||||
if strings.TrimSpace(sessionPath) == "" {
|
||||
return TabMeta{}, fmt.Errorf("background task session is unavailable")
|
||||
}
|
||||
return a.OpenTopicSession(scope, workspaceRoot, topicID, sessionPath)
|
||||
}
|
||||
|
||||
type workspaceLeaseReporter interface {
|
||||
WorkspaceLeaseState() workspacelease.State
|
||||
}
|
||||
|
||||
func controllerWorkspaceLeaseState(ctrl control.SessionAPI) workspacelease.State {
|
||||
if reporter, ok := ctrl.(workspaceLeaseReporter); ok {
|
||||
return reporter.WorkspaceLeaseState()
|
||||
}
|
||||
return workspacelease.State{}
|
||||
}
|
||||
|
||||
// WorkspaceConflictForTab classifies the owner that a Delivery writer is
|
||||
// currently waiting for. An acquired process-local owner is actionable; when no
|
||||
// local owner matches, the OS lock is treated as external.
|
||||
func (a *App) WorkspaceConflictForTab(tabID string) WorkspaceConflictView {
|
||||
empty := WorkspaceConflictView{State: "none", OwnerWork: ActiveWorkView{Jobs: []JobView{}}}
|
||||
a.mu.RLock()
|
||||
var target *WorkspaceTab
|
||||
if strings.TrimSpace(tabID) == "" {
|
||||
target = a.activeTabLocked()
|
||||
} else {
|
||||
target = a.tabByEventSinkIDLocked(tabID)
|
||||
}
|
||||
if target == nil {
|
||||
a.mu.RUnlock()
|
||||
return empty
|
||||
}
|
||||
targetCtrl := target.Ctrl
|
||||
targetWorkspaceRoot := target.WorkspaceRoot
|
||||
a.mu.RUnlock()
|
||||
if targetCtrl == nil {
|
||||
return empty
|
||||
}
|
||||
targetState := controllerWorkspaceLeaseState(targetCtrl)
|
||||
if !targetState.Waiting || targetState.Acquired {
|
||||
return empty
|
||||
}
|
||||
targetRoot, err := workspacelease.CanonicalWorkspace(targetWorkspaceRoot)
|
||||
if err != nil {
|
||||
return empty
|
||||
}
|
||||
availability := a.DeliveryWorktreeAvailability(targetWorkspaceRoot)
|
||||
|
||||
a.mu.RLock()
|
||||
type candidate struct {
|
||||
id string
|
||||
ctrl control.SessionAPI
|
||||
root string
|
||||
title string
|
||||
}
|
||||
candidates := make([]candidate, 0, len(a.tabs)+len(a.detachedSessions))
|
||||
seen := map[*WorkspaceTab]bool{}
|
||||
for _, tab := range a.runtimeTabsLocked() {
|
||||
if tab == nil || tab == target || seen[tab] || tab.Ctrl == nil {
|
||||
continue
|
||||
}
|
||||
seen[tab] = true
|
||||
title := strings.TrimSpace(tab.TopicTitle)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(tab.Label)
|
||||
}
|
||||
candidates = append(candidates, candidate{id: tab.ID, ctrl: tab.Ctrl, root: tab.WorkspaceRoot, title: title})
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
|
||||
for _, candidate := range candidates {
|
||||
root, err := workspacelease.CanonicalWorkspace(candidate.root)
|
||||
if err != nil || root != targetRoot || !controllerWorkspaceLeaseState(candidate.ctrl).Acquired {
|
||||
continue
|
||||
}
|
||||
return WorkspaceConflictView{
|
||||
State: "local", OwnerTabID: candidate.id, OwnerTitle: candidate.title,
|
||||
OwnerWork: activeWorkForController(candidate.ctrl), CanReveal: true,
|
||||
CanCreateWorktree: availability.Available,
|
||||
}
|
||||
}
|
||||
empty.State = "external"
|
||||
empty.CanCreateWorktree = availability.Available
|
||||
return empty
|
||||
}
|
||||
|
||||
// RevealWorkspaceWriterForTab opens the exact process-local runtime identified
|
||||
// by WorkspaceConflictForTab. External writers remain non-actionable.
|
||||
func (a *App) RevealWorkspaceWriterForTab(tabID string) (TabMeta, error) {
|
||||
conflict := a.WorkspaceConflictForTab(tabID)
|
||||
if conflict.State != "local" || conflict.OwnerTabID == "" {
|
||||
return TabMeta{}, fmt.Errorf("the workspace writer is not available in this Reasonix window")
|
||||
}
|
||||
return a.RevealBackgroundRuntime(conflict.OwnerTabID)
|
||||
}
|
||||
|
||||
const stopAndCloseGrace = 15 * time.Second
|
||||
|
||||
// CloseTabWithPolicy makes the old implicit detach behavior an explicit user
|
||||
// choice. stop_and_close never removes the tab until all owned work is idle.
|
||||
func (a *App) CloseTabWithPolicy(tabID, policy string) error {
|
||||
switch strings.TrimSpace(policy) {
|
||||
case "keep_running":
|
||||
return a.closeTab(tabID, true)
|
||||
case "stop_and_close":
|
||||
ctrl := a.ctrlForRuntimeTabID(tabID)
|
||||
if ctrl == nil {
|
||||
return a.closeTab(tabID, false)
|
||||
}
|
||||
ctrl.Cancel()
|
||||
jobs := ctrl.Jobs()
|
||||
ids := make([]string, 0, len(jobs))
|
||||
for _, job := range jobs {
|
||||
ids = append(ids, job.ID)
|
||||
}
|
||||
if _, err := a.CancelJobsForTab(tabID, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
deadline := time.NewTimer(stopAndCloseGrace)
|
||||
defer deadline.Stop()
|
||||
ticker := time.NewTicker(25 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if !activeWorkForController(ctrl).active() {
|
||||
return a.closeTab(tabID, false)
|
||||
}
|
||||
select {
|
||||
case <-deadline.C:
|
||||
return fmt.Errorf("background work did not stop within %s; the task was kept open", stopAndCloseGrace)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown close policy %q", policy)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/jobs"
|
||||
"reasonix/internal/remote/workbench/client"
|
||||
"reasonix/internal/workspacelease"
|
||||
)
|
||||
|
||||
type backgroundRuntimeController struct {
|
||||
control.SessionAPI
|
||||
status control.RuntimeStatus
|
||||
jobs []jobs.View
|
||||
lease workspacelease.State
|
||||
cancelled []string
|
||||
turnCancel int
|
||||
}
|
||||
|
||||
func (c *backgroundRuntimeController) RuntimeStatus() control.RuntimeStatus { return c.status }
|
||||
func (c *backgroundRuntimeController) Jobs() []jobs.View {
|
||||
return append([]jobs.View(nil), c.jobs...)
|
||||
}
|
||||
func (c *backgroundRuntimeController) CancelJob(id string) bool {
|
||||
for _, job := range c.jobs {
|
||||
if job.ID == id {
|
||||
c.cancelled = append(c.cancelled, id)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (c *backgroundRuntimeController) Cancel() { c.turnCancel++ }
|
||||
func (c *backgroundRuntimeController) WorkspaceLeaseState() workspacelease.State {
|
||||
return c.lease
|
||||
}
|
||||
|
||||
func TestBackgroundRuntimeAPIsKeepDetachedJobsActionable(t *testing.T) {
|
||||
targetCtrl := &backgroundRuntimeController{
|
||||
status: control.RuntimeStatus{BackgroundJobs: 1},
|
||||
jobs: []jobs.View{{ID: "job-target", Kind: "bash", Label: "target tests", Status: "running", StartedAt: 1}},
|
||||
}
|
||||
focusedCtrl := &backgroundRuntimeController{
|
||||
status: control.RuntimeStatus{BackgroundJobs: 1},
|
||||
jobs: []jobs.View{{ID: "job-focused", Kind: "bash", Label: "focused tests", Status: "running", StartedAt: 2}},
|
||||
}
|
||||
target := &WorkspaceTab{ID: "target", TopicTitle: "Detached task", Ctrl: targetCtrl}
|
||||
focused := &WorkspaceTab{ID: "focused", TopicTitle: "Focused task", Ctrl: focusedCtrl}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{"focused": focused},
|
||||
tabOrder: []string{"focused"},
|
||||
activeTabID: "focused",
|
||||
detachedSessions: map[string]*WorkspaceTab{"session-target": target},
|
||||
}
|
||||
|
||||
work := app.ActiveWorkForTab("target")
|
||||
if len(work.Jobs) != 1 || work.Jobs[0].ID != "job-target" {
|
||||
t.Fatalf("ActiveWorkForTab(target) = %+v, want target job", work)
|
||||
}
|
||||
result, err := app.CancelJobsForTab("target", []string{"job-target", "job-target", "missing"})
|
||||
if err != nil {
|
||||
t.Fatalf("CancelJobsForTab: %v", err)
|
||||
}
|
||||
if len(result.Cancelled) != 1 || result.Cancelled[0] != "job-target" || len(result.NotRunning) != 1 || result.NotRunning[0] != "missing" {
|
||||
t.Fatalf("CancelJobsForTab result = %+v", result)
|
||||
}
|
||||
if len(targetCtrl.cancelled) != 1 || len(focusedCtrl.cancelled) != 0 {
|
||||
t.Fatalf("cancellation routing target=%v focused=%v", targetCtrl.cancelled, focusedCtrl.cancelled)
|
||||
}
|
||||
|
||||
runtimes := app.BackgroundRuntimes()
|
||||
if len(runtimes) != 2 {
|
||||
t.Fatalf("BackgroundRuntimes = %+v, want visible and detached", runtimes)
|
||||
}
|
||||
var foundDetached bool
|
||||
for _, runtime := range runtimes {
|
||||
if runtime.TabID == "target" {
|
||||
foundDetached = runtime.Detached && runtime.Title == "Detached task" && len(runtime.Jobs) == 1
|
||||
}
|
||||
}
|
||||
if !foundDetached {
|
||||
t.Fatalf("detached runtime missing from %+v", runtimes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelJobForLocalRuntimeWhileRemoteWorkbenchIsActive(t *testing.T) {
|
||||
localCtrl := &backgroundRuntimeController{
|
||||
status: control.RuntimeStatus{BackgroundJobs: 1},
|
||||
jobs: []jobs.View{{ID: "job-local", Kind: "bash", Status: "running"}},
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{"local": {ID: "local", Ctrl: localCtrl}},
|
||||
tabOrder: []string{"local"},
|
||||
activeTabID: "local",
|
||||
}
|
||||
kernel := app.workbench()
|
||||
_, generation, err := kernel.targets.BeginRemoteConnect("remote-host", "/srv/work")
|
||||
if err != nil {
|
||||
t.Fatalf("BeginRemoteConnect: %v", err)
|
||||
}
|
||||
if err := kernel.targets.MarkRemoteConnected(generation); err != nil {
|
||||
t.Fatalf("MarkRemoteConnected: %v", err)
|
||||
}
|
||||
if _, _, _, err := kernel.targets.ActivateRemote(generation); err != nil {
|
||||
t.Fatalf("ActivateRemote: %v", err)
|
||||
}
|
||||
kernel.mu.Lock()
|
||||
kernel.remote = &client.Client{}
|
||||
kernel.remoteGen = generation
|
||||
kernel.remoteTabID = "remote"
|
||||
kernel.mu.Unlock()
|
||||
|
||||
listed := app.JobsForTab("local")
|
||||
if len(listed) != 1 || listed[0].ID != "job-local" {
|
||||
t.Fatalf("JobsForTab(local) = %+v, want local job while Remote Workbench is active", listed)
|
||||
}
|
||||
cancelled, err := app.CancelJobForTab("local", "job-local")
|
||||
if err != nil {
|
||||
t.Fatalf("CancelJobForTab(local): %v", err)
|
||||
}
|
||||
if !cancelled || len(localCtrl.cancelled) != 1 || localCtrl.cancelled[0] != "job-local" {
|
||||
t.Fatalf("local cancellation = %t, calls=%v; want local job cancelled", cancelled, localCtrl.cancelled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceConflictIdentifiesExactLocalWriterWithoutIdentity(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
waiterCtrl := &backgroundRuntimeController{
|
||||
status: control.RuntimeStatus{Running: true},
|
||||
lease: workspacelease.State{Waiting: true},
|
||||
}
|
||||
ownerCtrl := &backgroundRuntimeController{
|
||||
status: control.RuntimeStatus{BackgroundJobs: 1},
|
||||
jobs: []jobs.View{{ID: "owner-job", Kind: "bash", Status: "running"}},
|
||||
lease: workspacelease.State{Acquired: true},
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"waiter": {ID: "waiter", WorkspaceRoot: root, Ctrl: waiterCtrl},
|
||||
"owner": {ID: "owner", TopicTitle: "Writing task", WorkspaceRoot: root, Ctrl: ownerCtrl},
|
||||
},
|
||||
tabOrder: []string{"waiter", "owner"},
|
||||
activeTabID: "waiter",
|
||||
}
|
||||
|
||||
conflict := app.WorkspaceConflictForTab("waiter")
|
||||
if conflict.State != "local" || conflict.OwnerTabID != "owner" || conflict.OwnerTitle != "Writing task" || !conflict.CanReveal {
|
||||
t.Fatalf("WorkspaceConflictForTab = %+v, want exact local owner", conflict)
|
||||
}
|
||||
if len(conflict.OwnerWork.Jobs) != 1 || conflict.OwnerWork.Jobs[0].ID != "owner-job" {
|
||||
t.Fatalf("owner work = %+v, want sanitized active work", conflict.OwnerWork)
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,10 @@ func (f *botEventForwarder) Emit(e event.Event) {
|
||||
f.sendToAll(qb.String())
|
||||
|
||||
case event.Notice:
|
||||
if e.Audience == event.NoticeAudienceOperator {
|
||||
// Local runtime maintenance is not actionable in the remote IM chat.
|
||||
break
|
||||
}
|
||||
if e.Level == event.LevelWarn {
|
||||
f.sendToAll("⚠️ " + e.Text)
|
||||
}
|
||||
|
||||
@@ -131,3 +131,36 @@ func TestBotEventForwarderApprovalNoticeDoesNotExposeReplyID(t *testing.T) {
|
||||
t.Fatal("approval notice was not sent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotEventForwarderSuppressesOperatorNoticesWithoutHidingUserWarnings(t *testing.T) {
|
||||
forwarder := &botEventForwarder{
|
||||
runtime: &desktopBotRuntime{},
|
||||
targets: []botForwardTarget{{
|
||||
ConnID: "feishu-lark",
|
||||
Domain: "lark",
|
||||
ChatID: "oc-group-1",
|
||||
ChatType: bot.ChatGroup,
|
||||
}},
|
||||
queue: make(chan string, 2),
|
||||
}
|
||||
|
||||
forwarder.Emit(event.Event{
|
||||
Kind: event.Notice,
|
||||
Level: event.LevelWarn,
|
||||
Audience: event.NoticeAudienceOperator,
|
||||
Code: event.NoticeCodeSessionRecoveryForked,
|
||||
Text: "local session maintenance",
|
||||
})
|
||||
forwarder.Emit(event.Event{
|
||||
Kind: event.Notice,
|
||||
Level: event.LevelWarn,
|
||||
Text: "please retry the user action",
|
||||
})
|
||||
|
||||
if got := len(forwarder.queue); got != 1 {
|
||||
t.Fatalf("queued messages = %d, want only the actionable user warning", got)
|
||||
}
|
||||
if got := <-forwarder.queue; got != "⚠️ please retry the user action" {
|
||||
t.Fatalf("queued message = %q, want the ordinary user warning", got)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ description: |
|
||||
vendor: "Reasonix Contributors"
|
||||
homepage: "https://github.com/esengine/DeepSeek-Reasonix"
|
||||
license: "MIT"
|
||||
scripts:
|
||||
postinstall: ./build/linux/postinstall.sh
|
||||
# Runtime libraries the WebKitGTK 4.1 build links against (-tags webkit2_41); apt
|
||||
# pulls them in on install. These package names ship from Ubuntu 22.04 / Debian 12 on.
|
||||
# pkexec is required for the in-app Polkit authorization update path.
|
||||
@@ -32,8 +34,8 @@ contents:
|
||||
dst: /usr/bin/reasonix-desktop
|
||||
file_info:
|
||||
mode: 0755
|
||||
- src: ./build/bin/reasonix-guard
|
||||
dst: /usr/bin/reasonix-guard
|
||||
- src: ./build/bin/reasonix-launcher
|
||||
dst: /usr/bin/reasonix-launcher
|
||||
file_info:
|
||||
mode: 0755
|
||||
- src: ./build/bin/reasonix
|
||||
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# Refresh desktop integration after both fresh installs and upgrades. Debian's
|
||||
# package triggers usually do this too, but an explicit best-effort refresh
|
||||
# repairs stale blank icons on desktops that kept an older cache.
|
||||
|
||||
if [ -x /usr/bin/gtk-update-icon-cache ]; then
|
||||
/usr/bin/gtk-update-icon-cache --force --quiet /usr/share/icons/hicolor || true
|
||||
fi
|
||||
|
||||
if [ -x /usr/bin/update-desktop-database ]; then
|
||||
/usr/bin/update-desktop-database --quiet /usr/share/applications || true
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -2,7 +2,7 @@
|
||||
Type=Application
|
||||
Name=Reasonix
|
||||
Comment=Reasonix desktop — a Wails shell around the Go kernel
|
||||
Exec=reasonix-guard launch --detach
|
||||
Exec=reasonix-launcher
|
||||
Icon=reasonix-desktop
|
||||
Categories=Development;Utility;
|
||||
Terminal=false
|
||||
|
||||
@@ -100,6 +100,7 @@ OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the inst
|
||||
!define REASONIX_LAUNCHER "reasonix-launcher.exe"
|
||||
!define REASONIX_CLI "reasonix-cli.exe"
|
||||
!define REASONIX_PORTABLE_ENTRY "Reasonix.exe"
|
||||
!define REASONIX_LAYOUT_INSTALLER "reasonix-layout-installer.exe"
|
||||
!define REASONIX_PAYLOAD_MANIFEST "reasonix-payload.json"
|
||||
!define REASONIX_PAYLOAD_SIGNATURE "reasonix-payload.json.minisig"
|
||||
!define REASONIX_UNLOCK_RETRIES 60
|
||||
@@ -213,12 +214,21 @@ Function reasonix.waitForExecutableUnlock
|
||||
StrCpy $0 0
|
||||
|
||||
retry:
|
||||
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 check_guard
|
||||
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 check_versioned_target
|
||||
ClearErrors
|
||||
FileOpen $1 "$INSTDIR\${PRODUCT_EXECUTABLE}" a
|
||||
IfErrors locked
|
||||
FileClose $1
|
||||
|
||||
check_versioned_target:
|
||||
; A same-version recovery install replaces this directory transactionally.
|
||||
; Detect the running active binary before asking the Go activator to rename it.
|
||||
IfFileExists "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0 check_guard
|
||||
ClearErrors
|
||||
FileOpen $1 "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" a
|
||||
IfErrors locked
|
||||
FileClose $1
|
||||
|
||||
check_guard:
|
||||
IfFileExists "$INSTDIR\${REASONIX_GUARD}" 0 check_launcher
|
||||
ClearErrors
|
||||
@@ -273,72 +283,104 @@ FunctionEnd
|
||||
Section
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
; Current auto-update helpers extract the signed installer payload into a
|
||||
; unique temporary directory, then compare-and-publish the exact claimed
|
||||
; release unit. Legacy helpers omit /REASONIXSTAGE and retain the historical
|
||||
; in-place update behavior required to upgrade into the hardened release.
|
||||
StrCmp $ReasonixStageMode "1" reasonix_copy_payload
|
||||
; /REASONIXSTAGE=1: flat six-member payload for 1.18–1.19.1 helpers (and
|
||||
; the new helper's staging extract). Do not write shortcuts/uninstaller.
|
||||
; Normal install: versioned-v1 layout under versions/v${INFO_PRODUCTVERSION}/
|
||||
; with a permanent thin launcher at InstallRoot. Guard is only present in
|
||||
; STAGE payloads (as the one-shot legacy migrator) and is not persisted on
|
||||
; a normal install.
|
||||
StrCmp $ReasonixStageMode "1" reasonix_stage_payload
|
||||
!insertmacro wails.webview2runtime
|
||||
|
||||
Call reasonix.waitForExecutableUnlock
|
||||
Goto reasonix_normal_install
|
||||
|
||||
reasonix_copy_payload:
|
||||
reasonix_stage_payload:
|
||||
SetOutPath $INSTDIR
|
||||
|
||||
; The release workflow embeds a minisign-authenticated manifest after
|
||||
; SignPath has signed the five executable members. Only the extraction
|
||||
; handoff needs these files; a normal installation does not persist them.
|
||||
StrCmp $ReasonixStageMode "1" 0 reasonix_payload_metadata_done
|
||||
!if /FileExists "${REASONIX_PAYLOAD_MANIFEST}"
|
||||
File "/oname=${REASONIX_PAYLOAD_MANIFEST}" "${REASONIX_PAYLOAD_MANIFEST}"
|
||||
!endif
|
||||
!if /FileExists "${REASONIX_PAYLOAD_SIGNATURE}"
|
||||
File "/oname=${REASONIX_PAYLOAD_SIGNATURE}" "${REASONIX_PAYLOAD_SIGNATURE}"
|
||||
!endif
|
||||
reasonix_payload_metadata_done:
|
||||
|
||||
!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 fail safely until the helper is installed."
|
||||
!endif
|
||||
!if /FileExists "${REASONIX_GUARD}"
|
||||
File "/oname=${REASONIX_GUARD}" "${REASONIX_GUARD}"
|
||||
!endif
|
||||
!if /FileExists "${REASONIX_LAUNCHER}"
|
||||
File "/oname=${REASONIX_LAUNCHER}" "${REASONIX_LAUNCHER}"
|
||||
StrCmp $ReasonixStageMode "1" reasonix_launcher_payload_done
|
||||
; Portable archives expose Reasonix.exe as a second copy of the Guard
|
||||
; launcher. Preserve that layout only when upgrading an existing portable
|
||||
; directory, and keep the alias in lockstep with the canonical launcher.
|
||||
IfFileExists "$INSTDIR\${REASONIX_PORTABLE_ENTRY}" 0 reasonix_no_portable_entry
|
||||
File "/oname=${REASONIX_PORTABLE_ENTRY}" "${REASONIX_LAUNCHER}"
|
||||
reasonix_no_portable_entry:
|
||||
; Keep the Guard launcher as the target while taking the visible shortcut
|
||||
; icon from the Wails application. The launcher embeds the same icon too,
|
||||
; but an explicit icon source prevents stale/generic taskbar pins after an
|
||||
; in-place upgrade from a launcher build that had no Windows resources.
|
||||
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0
|
||||
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0
|
||||
reasonix_launcher_payload_done:
|
||||
!endif
|
||||
!if /FileExists "${REASONIX_CLI}"
|
||||
File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"
|
||||
!endif
|
||||
Goto reasonix_section_done
|
||||
|
||||
reasonix_normal_install:
|
||||
; Extract into an install-local temporary directory, then let the signed Go
|
||||
; activator validate the complete release unit, transactionally publish the
|
||||
; version/root entries, and strictly atomically replace current.json last.
|
||||
; The normal/recovery installer therefore shares the same commit protocol as
|
||||
; automatic updates instead of writing live files or current.json in place.
|
||||
System::Call 'kernel32::GetCurrentProcessId() i .R8'
|
||||
CreateDirectory "$INSTDIR\versions"
|
||||
StrCpy $R9 "$INSTDIR\versions\.installer-v${INFO_PRODUCTVERSION}-$R8"
|
||||
RMDir /r "$R9"
|
||||
CreateDirectory "$R9"
|
||||
SetOutPath "$R9"
|
||||
!insertmacro wails.files
|
||||
!if /FileExists "${REASONIX_UPDATE_HELPER}"
|
||||
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
|
||||
!else
|
||||
StrCmp $ReasonixStageMode "1" reasonix_launcher_payload_done_no_launcher
|
||||
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
reasonix_launcher_payload_done_no_launcher:
|
||||
!warning "${REASONIX_UPDATE_HELPER} was not found; Windows auto-update will fail safely until the helper is installed."
|
||||
!endif
|
||||
!if /FileExists "${REASONIX_CLI}"
|
||||
File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"
|
||||
!else
|
||||
!warning "${REASONIX_CLI} was not found; remote upload installation will be unavailable."
|
||||
!endif
|
||||
!if /FileExists "${REASONIX_LAUNCHER}"
|
||||
File "/oname=${REASONIX_LAUNCHER}" "${REASONIX_LAUNCHER}"
|
||||
!endif
|
||||
|
||||
SetOutPath "$PLUGINSDIR"
|
||||
!if /FileExists "${REASONIX_GUARD}"
|
||||
File "/oname=${REASONIX_LAYOUT_INSTALLER}" "${REASONIX_GUARD}"
|
||||
!else
|
||||
!error "${REASONIX_GUARD} was not found; normal installs require the signed layout activator."
|
||||
!endif
|
||||
ExecWait '"$PLUGINSDIR\${REASONIX_LAYOUT_INSTALLER}" --install-root "$INSTDIR" --version "v${INFO_PRODUCTVERSION}" --activate-staging "$R9" --no-relaunch' $0
|
||||
StrCmp $0 "0" reasonix_layout_activated
|
||||
DetailPrint "Reasonix layout activation failed with exit code $0; the previous version remains active."
|
||||
RMDir /r "$R9"
|
||||
SetErrorLevel 1
|
||||
Abort "Reasonix could not activate the verified release. The previous version was left unchanged."
|
||||
|
||||
reasonix_layout_activated:
|
||||
RMDir /r "$R9"
|
||||
SetOutPath "$INSTDIR"
|
||||
|
||||
; Remove flat leftovers from prior 1.18–1.19 installs when overwriting.
|
||||
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
Delete "$INSTDIR\${REASONIX_GUARD}"
|
||||
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
|
||||
|
||||
!if /FileExists "${REASONIX_LAUNCHER}"
|
||||
; Keep both target and icon on the stable launcher. Pointing IconLocation at
|
||||
; versions\vX\reasonix-desktop.exe leaves a blank shortcut as soon as version
|
||||
; retention removes that directory after a later update.
|
||||
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\${REASONIX_LAUNCHER}" 0
|
||||
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\${REASONIX_LAUNCHER}" 0
|
||||
!else
|
||||
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}"
|
||||
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}"
|
||||
!endif
|
||||
|
||||
StrCmp $ReasonixStageMode "1" reasonix_section_done
|
||||
!insertmacro wails.associateFiles
|
||||
!insertmacro wails.associateCustomProtocols
|
||||
|
||||
!insertmacro reasonix.writeUninstaller
|
||||
|
||||
reasonix_section_done:
|
||||
SectionEnd
|
||||
|
||||
@@ -347,13 +389,15 @@ Section "uninstall"
|
||||
|
||||
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
|
||||
|
||||
; Precision uninstall: delete main application files
|
||||
; Precision uninstall: flat leftovers, thin entry points, and version trees.
|
||||
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
|
||||
Delete "$INSTDIR\${REASONIX_GUARD}"
|
||||
Delete "$INSTDIR\${REASONIX_LAUNCHER}"
|
||||
Delete "$INSTDIR\${REASONIX_CLI}"
|
||||
Delete "$INSTDIR\${REASONIX_PORTABLE_ENTRY}"
|
||||
Delete "$INSTDIR\current.json"
|
||||
RMDir /r "$INSTDIR\versions"
|
||||
|
||||
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
|
||||
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
|
||||
|
||||
@@ -16,8 +16,9 @@ func TestDesktopBuildLinksSharedSourceRevision(t *testing.T) {
|
||||
`SOURCE_REVISION="$(git -C "$ROOT" rev-parse --verify HEAD)"`,
|
||||
`SOURCE_REVISION="$SOURCE_REVISION+dirty"`,
|
||||
`source_revision_ldflag="-X reasonix/internal/remote/protocol.linkedSourceRevision=$SOURCE_REVISION"`,
|
||||
`ldflags="-X main.version=$VERSION -X main.channel=$CHANNEL $source_revision_ldflag"`,
|
||||
`-X main.version=$VERSION $source_revision_ldflag`,
|
||||
`product_docs_ldflags="-X reasonix/internal/productdocs.linkedVersion=$VERSION -X reasonix/internal/productdocs.linkedRevision=$SOURCE_REVISION"`,
|
||||
`ldflags="-X main.version=$VERSION -X main.channel=$CHANNEL $source_revision_ldflag $product_docs_ldflags"`,
|
||||
`-X main.version=$VERSION $source_revision_ldflag $product_docs_ldflags`,
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Fatalf("desktop-build.sh does not preserve the shared build identity %q", want)
|
||||
|
||||
@@ -12,8 +12,86 @@ import (
|
||||
"reasonix/internal/checkpoint"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
func TestDesktopRewindCommitAndUndoUseAuthoritativeControllerState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
root := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "s.jsonl")
|
||||
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
|
||||
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filePath := filepath.Join(root, "a.txt")
|
||||
if err := os.WriteFile(filePath, []byte("after"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diskMode := uint32(fileInfo.Mode().Perm())
|
||||
before := "before"
|
||||
afterExists := true
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{
|
||||
SchemaVersion: checkpoint.SchemaV2, Turn: 1, Time: time.Now(), Prompt: "edit", MsgIndex: 3,
|
||||
Coverage: checkpoint.CoverageComplete,
|
||||
Files: []checkpoint.FileSnap{{
|
||||
Path: "a.txt", Content: &before, SHA256: checkpoint.Digest([]byte(before)), Mode: diskMode,
|
||||
AfterExisted: &afterExists, AfterSHA256: checkpoint.Digest([]byte("after")), AfterMode: diskMode,
|
||||
CaptureSource: checkpoint.CaptureBeforeMutation,
|
||||
}},
|
||||
})
|
||||
session := agent.NewSession("")
|
||||
session.Replace([]provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
{Role: provider.RoleUser, Content: "first"},
|
||||
{Role: provider.RoleAssistant, Content: "answer"},
|
||||
{Role: provider.RoleUser, Content: "edit"},
|
||||
{Role: provider.RoleAssistant, Content: "done"},
|
||||
})
|
||||
if err := session.Save(sessionPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ag := agent.New(nil, nil, session, agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{Executor: ag, Runner: ag, SessionDir: dir, SessionPath: sessionPath, WorkspaceRoot: root, Label: "test"})
|
||||
app := &App{}
|
||||
app.setTestCtrl(ctrl, "test")
|
||||
|
||||
plan := app.PreviewRewindForTab("test", 1, "both")
|
||||
if !plan.OK || !plan.CanFiles || !plan.CanConversation {
|
||||
t.Fatalf("preview = %+v", plan)
|
||||
}
|
||||
result := app.CommitRewindForTab("test", plan.PlanID, 1, "both")
|
||||
if !result.OK || !result.UndoAvailable || result.TransactionID == "" {
|
||||
t.Fatalf("commit = %+v", result)
|
||||
}
|
||||
if got, err := os.ReadFile(filePath); err != nil || string(got) != before {
|
||||
t.Fatalf("file after commit = %q err=%v", got, err)
|
||||
}
|
||||
if got := ctrl.History(); len(got) != 3 {
|
||||
t.Fatalf("controller history after commit = %d, want 3", len(got))
|
||||
}
|
||||
if got := app.HistoryForTab("test"); len(got) != 3 {
|
||||
t.Fatalf("desktop history after commit = %d, want 3", len(got))
|
||||
}
|
||||
|
||||
undo := app.UndoRewindForTab("test", result.TransactionID)
|
||||
if !undo.OK {
|
||||
t.Fatalf("undo = %+v", undo)
|
||||
}
|
||||
if got, err := os.ReadFile(filePath); err != nil || string(got) != "after" {
|
||||
t.Fatalf("file after undo = %q err=%v", got, err)
|
||||
}
|
||||
if got := ctrl.History(); len(got) != 5 {
|
||||
t.Fatalf("controller history after undo = %d, want 5", len(got))
|
||||
}
|
||||
if got := app.HistoryForTab("test"); len(got) != 5 {
|
||||
t.Fatalf("desktop history after undo = %d, want 5", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func seedCheckpoint(t *testing.T, ckptDir string, c checkpoint.Checkpoint) {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(c)
|
||||
@@ -60,11 +138,16 @@ func TestCheckpointsCanCodePropagatesToEarlierTurns(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "old"
|
||||
afterExists := true
|
||||
now := time.Now()
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 0, Time: now, Prompt: "ask only", MsgIndex: 0})
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "edit a file", MsgIndex: 2,
|
||||
Files: []checkpoint.FileSnap{{Path: "a.txt", Content: &content}}})
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 2, Time: now, Prompt: "ask again", MsgIndex: 4})
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 0, Time: now, Prompt: "ask only", MsgIndex: 0})
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 1, Time: now, Prompt: "edit a file", MsgIndex: 2,
|
||||
Coverage: checkpoint.CoverageComplete,
|
||||
Files: []checkpoint.FileSnap{{
|
||||
Path: "a.txt", Content: &content, SHA256: checkpoint.Digest([]byte(content)),
|
||||
AfterExisted: &afterExists, AfterSHA256: checkpoint.Digest([]byte("new")), CaptureSource: checkpoint.CaptureBeforeMutation,
|
||||
}}})
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 2, Time: now, Prompt: "ask again", MsgIndex: 4})
|
||||
|
||||
ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
|
||||
@@ -108,6 +191,36 @@ func TestCheckpointsCanCodePropagatesToEarlierTurns(t *testing.T) {
|
||||
assertCheckpointFilesEncodeAsArray(t, metas)
|
||||
}
|
||||
|
||||
func TestCheckpointsCanCodeDoesNotReenableLegacySuffix(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "s.jsonl")
|
||||
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
|
||||
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "old"
|
||||
now := time.Now()
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 0, Time: now, Prompt: "before", MsgIndex: 0})
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "legacy edit", MsgIndex: 2,
|
||||
Files: []checkpoint.FileSnap{{Path: "a.txt", Content: &content}}})
|
||||
|
||||
ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
|
||||
ctrl.SetSessionPath(sessionPath)
|
||||
app := &App{}
|
||||
app.setTestCtrl(ctrl, "test")
|
||||
|
||||
metas := app.CheckpointsForTab("test")
|
||||
if len(metas) != 2 {
|
||||
t.Fatalf("checkpoints = %d, want 2", len(metas))
|
||||
}
|
||||
for _, meta := range metas {
|
||||
if meta.CanCode {
|
||||
t.Fatalf("legacy suffix re-enabled code rewind at turn %d: %+v", meta.Turn, meta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointsForTabLimitsCumulativeFilePreview(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "s.jsonl")
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"reasonix/internal/installlayout"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
@@ -42,7 +43,7 @@ func main() {
|
||||
|
||||
func run(args []string) int {
|
||||
var parentPID uint
|
||||
var installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID string
|
||||
var installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID, installLayout 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")
|
||||
@@ -53,6 +54,7 @@ func run(args []string) int {
|
||||
fs.StringVar(&toVersion, "to-version", "", "Reasonix version being installed")
|
||||
fs.StringVar(&createdAt, "created-at", "", "pending update creation timestamp")
|
||||
fs.StringVar(&transactionID, "transaction-id", "", "complete pending update identity")
|
||||
fs.StringVar(&installLayout, "install-layout", "", "installation layout contract")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
@@ -69,11 +71,15 @@ func run(args []string) int {
|
||||
logger.Print("missing --to-version")
|
||||
return 2
|
||||
}
|
||||
if createdAt == "" {
|
||||
if installLayout != "" && installLayout != installlayout.InstallLayoutVersionedV1 {
|
||||
logger.Printf("unsupported --install-layout %q", installLayout)
|
||||
return 2
|
||||
}
|
||||
if installLayout != installlayout.InstallLayoutVersionedV1 && createdAt == "" {
|
||||
logger.Print("missing --created-at")
|
||||
return 2
|
||||
}
|
||||
if transactionID == "" {
|
||||
if installLayout != installlayout.InstallLayoutVersionedV1 && transactionID == "" {
|
||||
logger.Print("missing --transaction-id")
|
||||
return 2
|
||||
}
|
||||
@@ -87,6 +93,25 @@ func run(args []string) int {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if installLayout == installlayout.InstallLayoutVersionedV1 {
|
||||
return runVersionedWindowsUpdate(logger, installer, installerSHA256, installDir, relaunch, toVersion)
|
||||
}
|
||||
recoverExisting := func(reason string) int {
|
||||
if relaunch != "" {
|
||||
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
|
||||
logger.Printf("relaunch after %s: %v", reason, relaunchErr)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
// The detached helper runs from the update cache, so a plain
|
||||
// repair.ReadPendingUpdate would validate the transaction against the cache
|
||||
// directory and reject every legitimate flat install. Claim through the
|
||||
// explicit install-local launcher path instead; the repair package validates
|
||||
// the complete transaction identity and exact release-unit path set while it
|
||||
// acquires the pending + target locks.
|
||||
claimTargets := windowsReleaseUnitPaths(installDir)
|
||||
claimLauncher := filepath.Join(installDir, "reasonix-desktop.exe")
|
||||
// The old desktop may acquire the pending lock during its normal shutdown.
|
||||
// Wait for that exact process first, then claim the transaction and hold both
|
||||
// pending and target locks across the installer replacement window.
|
||||
@@ -94,13 +119,13 @@ func run(args []string) int {
|
||||
toVersion,
|
||||
createdAt,
|
||||
transactionID,
|
||||
filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
windowsReleaseUnitPaths(installDir),
|
||||
claimLauncher,
|
||||
claimTargets,
|
||||
parentExitTimeout,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Printf("claim pending update: %v", err)
|
||||
return 1
|
||||
return recoverExisting("pending update claim failure")
|
||||
}
|
||||
defer releaseClaim()
|
||||
recoverUnstarted := func() int {
|
||||
@@ -161,29 +186,41 @@ func run(args []string) int {
|
||||
}
|
||||
}
|
||||
if relaunch != "" {
|
||||
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
|
||||
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
|
||||
logger.Printf("relaunch after publish failure: %v", relaunchErr)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil {
|
||||
logger.Printf("record installed release unit: %v", err)
|
||||
if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil {
|
||||
logger.Printf("record install failure: %v", markErr)
|
||||
if receipts == nil {
|
||||
// Versioned-v1 activation: no flat publish receipts and no health
|
||||
// probation. Clear the pending transaction and relaunch the launcher.
|
||||
releaseClaim()
|
||||
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
|
||||
logger.Printf("clear pending after versioned activate: %v", cancelErr)
|
||||
}
|
||||
if relaunch != "" {
|
||||
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
|
||||
logger.Printf("relaunch after failed install verification: %v", relaunchErr)
|
||||
if clearErr := repair.ClearUpdateApplyFailureExact(claimed); clearErr != nil {
|
||||
logger.Printf("clear apply failure after versioned activate: %v", clearErr)
|
||||
}
|
||||
} else {
|
||||
if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil {
|
||||
logger.Printf("record installed release unit: %v", err)
|
||||
if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil {
|
||||
logger.Printf("record install failure: %v", markErr)
|
||||
}
|
||||
if relaunch != "" {
|
||||
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
|
||||
logger.Printf("relaunch after failed install verification: %v", relaunchErr)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil {
|
||||
logger.Printf("clear completed update marker: %v", err)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil {
|
||||
logger.Printf("clear completed update marker: %v", err)
|
||||
}
|
||||
if relaunch != "" {
|
||||
if err := startRelaunchFn(relaunch, installDir); err != nil {
|
||||
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
|
||||
logger.Printf("relaunch: %v", err)
|
||||
return 1
|
||||
}
|
||||
@@ -191,6 +228,86 @@ func run(args []string) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
func runVersionedWindowsUpdate(logger *log.Logger, installer, installerSHA256, installDir, relaunch, toVersion string) int {
|
||||
recoverExisting := func() int {
|
||||
if relaunch != "" {
|
||||
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
|
||||
logger.Printf("relaunch after versioned update failure: %v", err)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if _, err := installlayout.ReadCurrent(installDir); err != nil {
|
||||
logger.Printf("read versioned install pointer: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
|
||||
if err != nil {
|
||||
logger.Printf("bind versioned update installer: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := cleanupInstaller(); cleanupErr != nil {
|
||||
logger.Printf("preserving update installer staging: %v", cleanupErr)
|
||||
}
|
||||
}()
|
||||
stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
|
||||
if err != nil {
|
||||
logger.Printf("create versioned update staging: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
stagingOwner, err := lstatUpdateStagingFn(stagingDir)
|
||||
if err != nil {
|
||||
logger.Printf("bind versioned update staging: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
defer func() {
|
||||
if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
|
||||
logger.Printf("preserving update staging: %v", cleanupErr)
|
||||
}
|
||||
}()
|
||||
releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
|
||||
if err != nil {
|
||||
logger.Printf("recheck staged versioned installer: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
installerErr := runInstallerFn(claimedInstaller, stagingDir)
|
||||
releaseInstallerExecution()
|
||||
if installerErr != nil {
|
||||
logger.Printf("extract versioned installer payload: %v", installerErr)
|
||||
return recoverExisting()
|
||||
}
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: toVersion,
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
}
|
||||
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
|
||||
logger.Printf("activate versioned release: %v", err)
|
||||
return recoverExisting()
|
||||
}
|
||||
if relaunch != "" {
|
||||
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
|
||||
logger.Printf("relaunch versioned release: %v", err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// preferRelaunchPath chooses the thin launcher when present so post-update
|
||||
// restarts use the permanent entry point, not a flat desktop binary.
|
||||
func preferRelaunchPath(relaunch, installDir string) string {
|
||||
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe"} {
|
||||
path := filepath.Join(installDir, name)
|
||||
if info, err := os.Lstat(path); err == nil && info.Mode().IsRegular() {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return relaunch
|
||||
}
|
||||
|
||||
func validSHA256(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) != sha256.Size*2 {
|
||||
@@ -328,21 +445,15 @@ func installStagedWindowsReleaseUnit(
|
||||
claimed *repair.UpdateTransaction,
|
||||
stagingDir string,
|
||||
) (bool, []repair.FileUpdateInstallReceipt, error) {
|
||||
members, err := loadWindowsStagedReleaseUnit(claimed, stagingDir)
|
||||
if err != nil {
|
||||
return false, nil, err
|
||||
// v1.20+: versioned-v1 is the only publish path. Incomplete staged payloads
|
||||
// fail closed without mutating the live install or leaving flat pending state.
|
||||
if !preferVersionedWindowsActivation(stagingDir) {
|
||||
return false, nil, fmt.Errorf("staged payload is incomplete for versioned-v1 activation")
|
||||
}
|
||||
// Persist recovery intent before the first live rename. If the helper exits
|
||||
// between release-unit members, Guard rolls the exact pending transaction
|
||||
// back instead of launching a mixed install.
|
||||
if err := repair.MarkUpdateApplyFailedExact(claimed, "Windows update publish did not complete"); err != nil {
|
||||
return false, nil, fmt.Errorf("record update recovery intent: %w", err)
|
||||
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
|
||||
return false, nil, fmt.Errorf("versioned activate: %w", err)
|
||||
}
|
||||
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, repair.PublishClaimedFileUpdateMemberExact)
|
||||
if err != nil {
|
||||
return true, receipts, err
|
||||
}
|
||||
return true, receipts, nil
|
||||
return true, nil, nil
|
||||
}
|
||||
|
||||
func windowsReleaseUnitPaths(installDir string) []string {
|
||||
|
||||
@@ -12,11 +12,27 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/installlayout"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
const testInstallerSHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
func prepareLegacyWindowsUpdate(t *testing.T, installDir, toVersion string) *repair.UpdateTransaction {
|
||||
t.Helper()
|
||||
paths := windowsReleaseUnitPaths(installDir)
|
||||
for _, path := range paths {
|
||||
if err := os.WriteFile(path, []byte("old-"+filepath.Base(path)), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
tx, err := repair.PrepareFileUpdate("v1", toVersion, paths[0], paths[1:]...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
func TestStageVerifiedInstallerFreezesExpectedBytes(t *testing.T) {
|
||||
source := filepath.Join(t.TempDir(), "installer.exe")
|
||||
content := []byte("verified-installer")
|
||||
@@ -72,8 +88,86 @@ func TestRunRequiresTargetVersionBeforeStartingInstaller(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
|
||||
func TestRunVersionedLayoutDoesNotReadOrClaimLegacyPending(t *testing.T) {
|
||||
installDir := t.TempDir()
|
||||
seed := t.TempDir()
|
||||
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"} {
|
||||
if err := os.WriteFile(filepath.Join(seed, name), []byte("old-"+name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
|
||||
InstallRoot: installDir,
|
||||
Version: "v1.20.0",
|
||||
RequestID: "seed-windows-helper",
|
||||
Members: []installlayout.Member{
|
||||
{Name: "reasonix-desktop.exe", Path: filepath.Join(seed, "reasonix-desktop.exe")},
|
||||
{Name: "reasonix-cli.exe", Path: filepath.Join(seed, "reasonix-cli.exe")},
|
||||
{Name: "reasonix-update-helper.exe", Path: filepath.Join(seed, "reasonix-update-helper.exe")},
|
||||
},
|
||||
RequiredNames: []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
originalInstaller := runInstallerFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
originalStageInstaller := stageInstallerFn
|
||||
originalClaimInstallerExecution := claimInstallerExecutionFn
|
||||
t.Cleanup(func() {
|
||||
runInstallerFn = originalInstaller
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
stageInstallerFn = originalStageInstaller
|
||||
claimInstallerExecutionFn = originalClaimInstallerExecution
|
||||
})
|
||||
legacyClaimed := false
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
legacyClaimed = true
|
||||
return nil, func() {}, errors.New("legacy claim must not run")
|
||||
}
|
||||
stageInstallerFn = func(path, _ string) (string, func() error, error) {
|
||||
return path, func() error { return nil }, nil
|
||||
}
|
||||
claimInstallerExecutionFn = func(string, string) (func(), error) { return func() {}, nil }
|
||||
runInstallerFn = func(_ string, staging string) error {
|
||||
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe", "reasonix-launcher.exe"} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte("new-"+name), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
relaunched := false
|
||||
startRelaunchFn = func(string, string) error { relaunched = true; return nil }
|
||||
|
||||
if code := run([]string{
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-launcher.exe"),
|
||||
"--to-version", "v1.20.1",
|
||||
"--install-layout", "versioned-v1",
|
||||
}); code != 0 {
|
||||
t.Fatalf("run exit code = %d", code)
|
||||
}
|
||||
if legacyClaimed {
|
||||
t.Fatal("versioned update claimed legacy pending transaction")
|
||||
}
|
||||
if !relaunched {
|
||||
t.Fatal("versioned update did not relaunch")
|
||||
}
|
||||
ptr, err := installlayout.ReadCurrent(installDir)
|
||||
if err != nil || ptr.ActiveVersion != "v1.20.1" {
|
||||
t.Fatalf("pointer=%+v err=%v", ptr, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
installDir := t.TempDir()
|
||||
pending := prepareLegacyWindowsUpdate(t, installDir, "v2")
|
||||
var events []string
|
||||
originalWait := waitForProcessExitFn
|
||||
originalInstaller := runInstallerFn
|
||||
@@ -107,11 +201,11 @@ func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
|
||||
}
|
||||
claimPendingFileUpdateFn = func(toVersion, createdAt, transactionID, launcherPath string, paths []string, _ time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
events = append(events, "claim:"+strings.Join([]string{toVersion, createdAt, transactionID, launcherPath, strings.Join(paths, "\x00")}, "\x01"))
|
||||
return &repair.UpdateTransaction{}, func() { events = append(events, "release") }, nil
|
||||
return pending, func() { events = append(events, "release") }, nil
|
||||
}
|
||||
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
|
||||
events = append(events, "publish")
|
||||
return true, nil, nil
|
||||
return true, []repair.FileUpdateInstallReceipt{}, nil
|
||||
}
|
||||
recordInstalledUpdateFn = func(tx *repair.UpdateTransaction, _ ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
|
||||
events = append(events, "record-installed")
|
||||
@@ -132,16 +226,16 @@ func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", "v2",
|
||||
"--created-at", "2026-07-29T00:00:00Z",
|
||||
"--transaction-id", "transaction-1",
|
||||
"--to-version", pending.ToVersion,
|
||||
"--created-at", pending.CreatedAt,
|
||||
"--transaction-id", repair.UpdateTransactionID(pending),
|
||||
}); code != 0 {
|
||||
t.Fatalf("run exit code = %d", code)
|
||||
}
|
||||
wantClaim := "claim:" + strings.Join([]string{
|
||||
"v2",
|
||||
"2026-07-29T00:00:00Z",
|
||||
"transaction-1",
|
||||
pending.ToVersion,
|
||||
pending.CreatedAt,
|
||||
repair.UpdateTransactionID(pending),
|
||||
filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
strings.Join(windowsReleaseUnitPaths(installDir), "\x00"),
|
||||
}, "\x01")
|
||||
@@ -197,6 +291,44 @@ func TestRunDoesNotClaimUpdateBeforeParentExits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRelaunchesWhenLegacyPendingCannotBeClaimed(t *testing.T) {
|
||||
installDir := t.TempDir()
|
||||
originalWait := waitForProcessExitFn
|
||||
originalRelaunch := startRelaunchFn
|
||||
originalClaim := claimPendingFileUpdateFn
|
||||
t.Cleanup(func() {
|
||||
waitForProcessExitFn = originalWait
|
||||
startRelaunchFn = originalRelaunch
|
||||
claimPendingFileUpdateFn = originalClaim
|
||||
})
|
||||
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
|
||||
relaunched := false
|
||||
startRelaunchFn = func(path, dir string) error {
|
||||
relaunched = path == filepath.Join(installDir, "reasonix-desktop.exe") && dir == installDir
|
||||
return nil
|
||||
}
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
return nil, nil, errors.New("pending lock unavailable")
|
||||
}
|
||||
|
||||
if code := run([]string{
|
||||
"--parent-pid", "1234",
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
"--installer-sha256", testInstallerSHA256,
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", "v2",
|
||||
"--created-at", "2026-08-02T00:00:00Z",
|
||||
"--transaction-id", strings.Repeat("a", 64),
|
||||
}); code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
}
|
||||
if !relaunched {
|
||||
t.Fatal("helper did not relaunch the existing desktop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
installDir := t.TempDir()
|
||||
@@ -221,12 +353,7 @@ func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
|
||||
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
|
||||
runInstallerFn = func(string, string) error { return errors.New("installer failed") }
|
||||
startRelaunchFn = func(string, string) error { return nil }
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v2",
|
||||
CreatedAt: "2026-07-29T00:00:00Z",
|
||||
TargetKind: "file",
|
||||
}
|
||||
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
return claimed, func() {}, nil
|
||||
}
|
||||
@@ -236,7 +363,6 @@ func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
|
||||
claimInstallerExecutionFn = func(string, string) (func(), error) {
|
||||
return func() {}, nil
|
||||
}
|
||||
const createdAt = "2026-07-29T00:00:00Z"
|
||||
transactionID := repair.UpdateTransactionID(claimed)
|
||||
if code := run([]string{
|
||||
"--installer", filepath.Join(installDir, "installer.exe"),
|
||||
@@ -244,7 +370,7 @@ func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
|
||||
"--install-dir", installDir,
|
||||
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
"--to-version", "v2",
|
||||
"--created-at", createdAt,
|
||||
"--created-at", claimed.CreatedAt,
|
||||
"--transaction-id", transactionID,
|
||||
}); code != 1 {
|
||||
t.Fatalf("run exit code = %d, want 1", code)
|
||||
@@ -280,12 +406,7 @@ func TestRunTreatsInstalledReleaseUnitRecordingFailureAsApplyFailure(t *testing.
|
||||
relaunched = true
|
||||
return nil
|
||||
}
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v2",
|
||||
CreatedAt: "2026-07-29T00:00:00Z",
|
||||
TargetKind: "file",
|
||||
}
|
||||
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
return claimed, func() {}, nil
|
||||
}
|
||||
@@ -296,7 +417,11 @@ func TestRunTreatsInstalledReleaseUnitRecordingFailureAsApplyFailure(t *testing.
|
||||
return func() {}, nil
|
||||
}
|
||||
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
|
||||
return true, nil, nil
|
||||
return true, []repair.FileUpdateInstallReceipt{{
|
||||
UpdateTransactionID: repair.UpdateTransactionID(claimed),
|
||||
TargetPath: claimed.TargetPath,
|
||||
InstalledStateID: "installed",
|
||||
}}, nil
|
||||
}
|
||||
recordInstalledUpdateFn = func(*repair.UpdateTransaction, ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
|
||||
return nil, errors.New("release unit drifted")
|
||||
@@ -351,12 +476,7 @@ func TestRunRelaunchesWhenStagingIdentityCannotBeBound(t *testing.T) {
|
||||
relaunched = true
|
||||
return nil
|
||||
}
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v2",
|
||||
CreatedAt: "2026-07-29T00:00:00Z",
|
||||
TargetKind: "file",
|
||||
}
|
||||
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
|
||||
releases := 0
|
||||
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
|
||||
return claimed, func() { releases++ }, nil
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/installlayout"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
// activateVersionedWindowsFromStaging publishes the versioned-v1 layout from a
|
||||
// staged NSIS payload:
|
||||
//
|
||||
// InstallRoot/
|
||||
// reasonix-launcher.exe
|
||||
// Reasonix.exe (launcher alias when present or portable)
|
||||
// reasonix-cli.exe (CLI entry; full binary for now)
|
||||
// current.json
|
||||
// versions/<version>/
|
||||
// reasonix-desktop.exe
|
||||
// reasonix-cli.exe
|
||||
// reasonix-update-helper.exe
|
||||
//
|
||||
// Any failure before the current.json pointer swap leaves the previous active
|
||||
// version unchanged. The helper never counts crashes or selects prior versions.
|
||||
func activateVersionedWindowsFromStaging(claimed *repair.UpdateTransaction, stagingDir string) error {
|
||||
if claimed == nil {
|
||||
return fmt.Errorf("versioned activate: transaction is nil")
|
||||
}
|
||||
installRoot := filepath.Clean(strings.TrimSpace(filepath.Dir(claimed.TargetPath)))
|
||||
// When the claimed primary is already under versions/<ver>/, climb to root.
|
||||
if root, err := installlayout.ResolveInstallRoot(claimed.TargetPath); err == nil && root != "" {
|
||||
installRoot = root
|
||||
}
|
||||
version := strings.TrimSpace(claimed.ToVersion)
|
||||
if err := installlayout.ValidateVersionName(version); err != nil {
|
||||
// Accept bare product versions from NSIS (1.20.0 → v1.20.0).
|
||||
if !strings.HasPrefix(version, "v") {
|
||||
version = "v" + version
|
||||
}
|
||||
if err := installlayout.ValidateVersionName(version); err != nil {
|
||||
return fmt.Errorf("versioned activate: %w", err)
|
||||
}
|
||||
}
|
||||
stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
|
||||
|
||||
desktopSrc := filepath.Join(stagingDir, "reasonix-desktop.exe")
|
||||
cliSrc := filepath.Join(stagingDir, "reasonix-cli.exe")
|
||||
helperSrc := filepath.Join(stagingDir, "reasonix-update-helper.exe")
|
||||
launcherSrc := filepath.Join(stagingDir, "reasonix-launcher.exe")
|
||||
for _, path := range []string{desktopSrc, cliSrc, helperSrc, launcherSrc} {
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("versioned activate: staged %s: %w", filepath.Base(path), err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("versioned activate: staged %s is not a regular file", filepath.Base(path))
|
||||
}
|
||||
}
|
||||
|
||||
requestID := repair.UpdateTransactionID(claimed)
|
||||
if requestID == "" {
|
||||
requestID = "helper-" + version
|
||||
}
|
||||
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
|
||||
InstallRoot: installRoot,
|
||||
Version: version,
|
||||
RequestID: requestID,
|
||||
Members: []installlayout.Member{
|
||||
{Name: "reasonix-desktop.exe", Path: desktopSrc, Mode: 0o700},
|
||||
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
|
||||
{Name: "reasonix-update-helper.exe", Path: helperSrc, Mode: 0o700},
|
||||
},
|
||||
RootMembers: []installlayout.Member{
|
||||
{Name: "reasonix-launcher.exe", Path: launcherSrc, Mode: 0o700},
|
||||
{Name: "Reasonix.exe", Path: launcherSrc, Mode: 0o700},
|
||||
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
|
||||
},
|
||||
RequiredRootNames: []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove flat release-unit leftovers so the install root is the thin layout.
|
||||
// Do not remove the launcher/CLI/alias we just wrote.
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-guard.exe",
|
||||
"reasonix-update-helper.exe", // helper lives only under versions/
|
||||
} {
|
||||
_ = os.Remove(filepath.Join(installRoot, name))
|
||||
}
|
||||
// Best-effort retention GC of older version trees.
|
||||
_ = installlayout.RetainPreviousVersions(installRoot, 0)
|
||||
_ = installlayout.CleanupStaleStaging(installRoot, 0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// preferVersionedWindowsActivation reports whether the staged payload is
|
||||
// complete enough for versioned-v1 activation.
|
||||
func preferVersionedWindowsActivation(stagingDir string) bool {
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-cli.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-launcher.exe",
|
||||
} {
|
||||
info, err := os.Lstat(filepath.Join(stagingDir, name))
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/installlayout"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
func TestPreferVersionedWindowsActivation(t *testing.T) {
|
||||
staging := t.TempDir()
|
||||
if preferVersionedWindowsActivation(staging) {
|
||||
t.Fatal("empty staging must not prefer versioned")
|
||||
}
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-cli.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-launcher.exe",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !preferVersionedWindowsActivation(staging) {
|
||||
t.Fatal("complete staging must prefer versioned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivateVersionedWindowsFromStaging(t *testing.T) {
|
||||
installDir := t.TempDir()
|
||||
staging := t.TempDir()
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-cli.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-launcher.exe",
|
||||
"reasonix-guard.exe",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Seed a flat desktop so cleanup is observable.
|
||||
if err := os.WriteFile(filepath.Join(installDir, "reasonix-desktop.exe"), []byte("old"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(installDir, "reasonix-guard.exe"), []byte("old-guard"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v1.20.0",
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
CreatedAt: "2026-01-01T00:00:00Z",
|
||||
}
|
||||
if err := activateVersionedWindowsFromStaging(claimed, staging); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !installlayout.HasCurrent(installDir) {
|
||||
t.Fatal("current.json missing")
|
||||
}
|
||||
ptr, err := installlayout.ReadCurrent(installDir)
|
||||
if err != nil || ptr.ActiveVersion != "v1.20.0" {
|
||||
t.Fatalf("pointer=%+v err=%v", ptr, err)
|
||||
}
|
||||
desktop, err := installlayout.ActiveDesktopPath(installDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(desktop)
|
||||
if err != nil || string(raw) != "payload:reasonix-desktop.exe" {
|
||||
t.Fatalf("desktop payload = %q err=%v", raw, err)
|
||||
}
|
||||
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"} {
|
||||
if _, err := os.Stat(filepath.Join(installDir, name)); err != nil {
|
||||
t.Fatalf("root entry %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(installDir, "reasonix-desktop.exe")); !os.IsNotExist(err) {
|
||||
t.Fatal("flat desktop should be removed")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(installDir, "reasonix-guard.exe")); !os.IsNotExist(err) {
|
||||
t.Fatal("flat guard should be removed")
|
||||
}
|
||||
if prefer := preferRelaunchPath("", installDir); filepath.Base(prefer) != "reasonix-launcher.exe" {
|
||||
t.Fatalf("prefer relaunch = %s", prefer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallStagedWindowsReleaseUnitPrefersVersioned(t *testing.T) {
|
||||
installDir := t.TempDir()
|
||||
staging := t.TempDir()
|
||||
for _, name := range []string{
|
||||
"reasonix-desktop.exe",
|
||||
"reasonix-cli.exe",
|
||||
"reasonix-update-helper.exe",
|
||||
"reasonix-launcher.exe",
|
||||
"reasonix-guard.exe",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(staging, name), []byte("p:"+name), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Minimal claimed unit (versioned path does not need full flat file list).
|
||||
claimed := &repair.UpdateTransaction{
|
||||
SchemaVersion: 1,
|
||||
ToVersion: "v1.20.0",
|
||||
TargetKind: "file",
|
||||
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
|
||||
CreatedAt: "2026-01-01T00:00:00Z",
|
||||
}
|
||||
started, receipts, err := installStagedWindowsReleaseUnit(claimed, staging)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !started || receipts != nil {
|
||||
t.Fatalf("started=%v receipts=%v want started with nil receipts", started, receipts)
|
||||
}
|
||||
if !installlayout.HasCurrent(installDir) {
|
||||
t.Fatal("versioned activation did not write current.json")
|
||||
}
|
||||
}
|
||||
@@ -22,13 +22,35 @@ func TestParseWebView2ProcessFailure(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWebView2ProcessFailureReportIsStructured(t *testing.T) {
|
||||
report := webView2ProcessFailureReport(2)
|
||||
report := webView2ProcessFailureReportWithContext(2, 3, "132.0.2957.140")
|
||||
if report.Source != "webview2.process" || report.Label != "windows.webview2.process_failed" {
|
||||
t.Fatalf("report = %+v", report)
|
||||
}
|
||||
if report.FingerprintHint != "windows.webview2.render_process_unresponsive" {
|
||||
t.Fatalf("fingerprint hint = %q", report.FingerprintHint)
|
||||
}
|
||||
for _, want := range []string{"runtime version: 132.0.2957.140", "same-process occurrence: 3", "exit code: unavailable"} {
|
||||
if !strings.Contains(report.Message, want) {
|
||||
t.Fatalf("report message missing %q: %s", want, report.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebView2FailureTrackerDeduplicatesSessionBursts(t *testing.T) {
|
||||
var tracker webView2FailureTracker
|
||||
base := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
|
||||
if occurrence, report := tracker.observe(2, base); occurrence != 1 || !report {
|
||||
t.Fatalf("first observation = (%d, %v)", occurrence, report)
|
||||
}
|
||||
if occurrence, report := tracker.observe(2, base.Add(time.Minute)); occurrence != 2 || report {
|
||||
t.Fatalf("burst observation = (%d, %v)", occurrence, report)
|
||||
}
|
||||
if occurrence, report := tracker.observe(2, base.Add(webView2FailureReportCooldown)); occurrence != 3 || !report {
|
||||
t.Fatalf("post-cooldown observation = (%d, %v)", occurrence, report)
|
||||
}
|
||||
if occurrence, report := tracker.observe(6, base.Add(time.Minute)); occurrence != 1 || !report {
|
||||
t.Fatalf("different kind observation = (%d, %v)", occurrence, report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviousRunReportUsesOnlyBoundedLifecycleContext(t *testing.T) {
|
||||
|
||||
@@ -146,12 +146,6 @@ func (a *App) flushPendingCrash() {
|
||||
if version == "dev" {
|
||||
return
|
||||
}
|
||||
// Safe Mode boots from built-in defaults and cannot read the user's real
|
||||
// telemetry preference, so it must neither send the pending report nor
|
||||
// consume it: leave the file for the next normal boot to decide.
|
||||
if config.SafeModeRequested() {
|
||||
return
|
||||
}
|
||||
paths := pendingCrashPaths()
|
||||
if len(paths) == 0 {
|
||||
return
|
||||
|
||||
@@ -198,7 +198,9 @@ func TestFlushPendingCrashDevGuard(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushPendingCrashRetainsInSafeMode(t *testing.T) {
|
||||
func TestFlushPendingCrashIgnoresSafeModeEnv(t *testing.T) {
|
||||
// v1.20+: REASONIX_SAFE_MODE no longer blocks crash flush. With telemetry
|
||||
// off/default, the pending file is consumed (sent or dropped).
|
||||
t.Setenv("REASONIX_SAFE_MODE", "1")
|
||||
oldVersion := version
|
||||
t.Cleanup(func() {
|
||||
@@ -209,8 +211,6 @@ func TestFlushPendingCrashRetainsInSafeMode(t *testing.T) {
|
||||
|
||||
writePendingCrash("safe", "boom", []byte("stack"))
|
||||
NewApp().flushPendingCrash()
|
||||
|
||||
if _, ok := readPending(t); !ok {
|
||||
t.Fatal("safe mode must leave the pending crash file for the next normal boot")
|
||||
}
|
||||
// Either sent or dropped is fine; must not retain solely because of Safe Mode env.
|
||||
// When telemetry is off the file is removed; when on it is sent. Both clear it.
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: "off",
|
||||
},
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: true },
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
},
|
||||
rules: {
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -5,22 +5,26 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css && tsc --noEmit && vite build && node scripts/check-bundle-budget.mjs",
|
||||
"build": "pnpm lint:hooks && node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css && tsc --noEmit && vite build && node scripts/check-bundle-budget.mjs",
|
||||
"check:bundle": "node scripts/check-bundle-budget.mjs",
|
||||
"preview": "vite preview",
|
||||
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
|
||||
"lint:hooks": "eslint \"src/**/*.{ts,tsx}\"",
|
||||
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:terminal": "tsx src/__tests__/terminal-events.test.ts && tsx src/__tests__/terminal-store.test.ts && tsx src/__tests__/terminal-output.test.ts && tsx src/__tests__/workspace-layout.test.ts",
|
||||
"pretest": "pnpm test:terminal && tsx src/__tests__/rich-composer-selection.test.tsx && tsx src/__tests__/context-center-contract.test.ts && tsx src/__tests__/provider-model-cache.test.ts && pnpm test:composer-menu-viewport",
|
||||
"test:composer-menu-viewport": "tsx src/__tests__/composer-menu-viewport.test.ts",
|
||||
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/goal-action-errors.test.tsx && tsx src/__tests__/goal-activation-tab-routing.test.tsx && tsx src/__tests__/session-export.test.ts && tsx src/__tests__/conversation-width.test.ts && tsx src/__tests__/external-opener.test.tsx && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/confirm-dialog.test.tsx && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/layout-style-defaults.test.ts && tsx src/__tests__/scroll-content-observer.test.ts && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/github-link-rendering.test.tsx && tsx src/__tests__/markdown-image-proxy.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/typography-preferences.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/theme-pack.test.ts && tsx src/__tests__/theme-editor-keyboard.test.tsx && 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__/ref-token.test.tsx && tsx src/__tests__/decision-surface.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/model-switcher-refresh.test.tsx && 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__/language-highlighting.test.ts && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/composer-context-menu-clipboard.test.tsx && tsx src/__tests__/composer-resized-input-css.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts && tsx src/__tests__/use-controller-live-context.test.tsx && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/pending-prompt-stale-status.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/activate-topic-stale.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__/footer-decision-overflow.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__/shortcuts-recorder-focus.test.tsx && 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__/transcript-selection-menu.test.tsx && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/turn-actions-rendering.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/line-number-code.test.tsx && tsx src/__tests__/message-reasoning-panel.test.tsx && tsx src/__tests__/transcript-process-fold.test.ts && tsx src/__tests__/transcript-fold-preference.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__/context-window-ring.test.tsx && 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__/diagnostics-settings.test.tsx && 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__/delivery-Line truncated
|
||||
"test:terminal": "tsx src/__tests__/terminal-events.test.ts && tsx src/__tests__/terminal-store.test.ts && tsx src/__tests__/terminal-output.test.ts && tsx src/__tests__/terminal-theme.test.ts && tsx src/__tests__/workspace-layout.test.ts",
|
||||
"test:usage-stats": "tsx src/__tests__/usage-stats-format.test.ts && tsx src/__tests__/usage-stats-panel.test.tsx",
|
||||
"test:settings-responsive": "tsx src/__tests__/settings-responsive-layout.test.ts",
|
||||
"pretest": "pnpm test:terminal && tsx src/__tests__/rich-composer-selection.test.tsx && tsx src/__tests__/context-center-contract.test.ts && tsx src/__tests__/provider-model-cache.test.ts && tsx src/__tests__/format-tokens.test.ts && pnpm test:usage-stats && pnpm test:settings-responsive && pnpm test:composer-menu-viewport",
|
||||
"test:composer-menu-viewport": "tsx src/__tests__/composer-menu-viewport.test.ts && tsx src/__tests__/virtual-menu-identity.test.tsx",
|
||||
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/goal-action-errors.test.tsx && tsx src/__tests__/goal-activation-tab-routing.test.tsx && tsx src/__tests__/session-export.test.ts && tsx src/__tests__/conversation-width.test.ts && tsx src/__tests__/external-opener.test.tsx && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/confirm-dialog.test.tsx && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/layout-style-defaults.test.ts && tsx src/__tests__/scroll-content-observer.test.ts && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/github-link-rendering.test.tsx && tsx src/__tests__/markdown-image-proxy.test.ts && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/typography-preferences.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/theme-pack.test.ts && tsx src/__tests__/theme-editor-keyboard.test.tsx && 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__/ref-token.test.tsx && tsx src/__tests__/decision-surface.test.tsx && tsx src/__tests__/provider-editor-model-picker.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/model-switcher-refresh.test.tsx && 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__/language-highlighting.test.ts && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/composer-context-menu-clipboard.test.tsx && tsx src/__tests__/composer-resized-input-css.test.ts && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts && tsx src/__tests__/use-controller-live-context.test.tsx && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/pending-prompt-stale-status.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/activate-topic-stale.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__/footer-decision-overflow.test.ts && tsx src/__tests__/workspace-change-status.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__/shortcuts-recorder-focus.test.tsx && 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__/transcript-selection-menu.test.tsx && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/turn-actions-rendering.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/line-number-code.test.tsx && tsx src/__tests__/message-reasoning-panel.test.tsx && tsx src/__tests__/transcript-process-fold.test.ts && tsx src/__tests__/transcript-fold-preference.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__/context-window-ring.test.tsx && 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__/diagnostics-settings.test.tsx && 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/__teLine truncated
|
||||
"test:remote": "tsx src/__tests__/remote-store.test.ts && tsx src/__tests__/remote-error-ux.test.tsx && tsx src/__tests__/remote-hosts-page.test.tsx && tsx src/__tests__/remote-secret-dialog.test.tsx && tsx src/__tests__/provider-trust-dialog.test.tsx && tsx src/__tests__/workbench-target.test.ts",
|
||||
"test:performance": "tsx src/__tests__/history-performance-benchmark.tsx",
|
||||
"test:typecheck": "tsc --noEmit -p tsconfig.test.json",
|
||||
"test:delivery-worktree": "tsx src/__tests__/delivery-worktree.test.ts",
|
||||
"test:updater": "tsx src/__tests__/updater-shared-state.test.tsx",
|
||||
"test:all": "pnpm test:typecheck && pnpm test:updater && pnpm test && pnpm test:remote && pnpm test:performance"
|
||||
"test:window-state": "tsx src/__tests__/window-state-ordering.test.ts",
|
||||
"test:all": "pnpm test:typecheck && pnpm test:updater && pnpm test:window-state && pnpm test && pnpm test:remote && pnpm test:performance"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gsap/react": "^2.1.2",
|
||||
@@ -49,7 +53,10 @@
|
||||
"@types/node": "^26.0.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/parser": "^8.65.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"mdast-util-math": "^3.0.0",
|
||||
"terser": "^5.48.0",
|
||||
|
||||
Generated
+941
-4
File diff suppressed because it is too large.
Load diff
@@ -3,3 +3,4 @@ allowBuilds:
|
||||
|
||||
overrides:
|
||||
mdast-util-gfm-autolink-literal: 2.0.0
|
||||
undici: 7.29.0
|
||||
@@ -53,5 +53,5 @@ for (const path of localeChunks) {
|
||||
}
|
||||
|
||||
const rawInitialBytes = [...initialJS, ...initialCSS].reduce((total, path) => total + statSync(path).size, 0);
|
||||
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_250 * 1024);
|
||||
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_263 * 1024);
|
||||
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_100 * 1024);
|
||||
+480
-152
@@ -52,11 +52,13 @@ import { TranscriptSelectionMenu } from "./components/TranscriptSelectionMenu";
|
||||
import { TodoPanel } from "./components/TodoPanel";
|
||||
import { ApprovalModal } from "./components/ApprovalModal";
|
||||
import { AskCard } from "./components/AskCard";
|
||||
import { UndoRewindBanner } from "./components/UndoRewindBanner";
|
||||
import { ClearContextCard } from "./components/ClearContextCard";
|
||||
import { RuntimeDecisionCard } from "./components/RuntimeDecisionCard";
|
||||
|
||||
/** Footer decision surface kinds. Priority: tool/plan approval > ask > clear context. */
|
||||
type DecisionSurfaceKind = "tool_approval" | "plan_approval" | "ask" | "clear_context";
|
||||
const UndoRewindBanner = lazy(() => import("./components/UndoRewindBanner").then((module) => ({ default: module.UndoRewindBanner })));
|
||||
|
||||
/** Footer decision surface kinds. Runtime blockers are explicit recovery choices. */
|
||||
type DecisionSurfaceKind = "tool_approval" | "plan_approval" | "ask" | "workspace_conflict" | "mode_jobs" | "close_active" | "clear_context";
|
||||
import { StatusBar } from "./components/StatusBar";
|
||||
import { RemoteHostKeyDialog } from "./components/RemoteHostKeyDialog";
|
||||
import { RemoteSecretDialog } from "./components/RemoteSecretDialog";
|
||||
@@ -67,7 +69,6 @@ import { CommandPalette, type PaletteItem } from "./components/CommandPalette";
|
||||
import { UpdateBanner } from "./components/UpdateBanner";
|
||||
import { UpdaterProvider } from "./lib/useUpdater";
|
||||
import { ContextPanel } from "./components/ContextPanel";
|
||||
import { WorkspacePanel } from "./components/WorkspacePanel";
|
||||
import { Tooltip } from "./components/Tooltip";
|
||||
import { StartupSplash } from "./components/StartupSplash";
|
||||
import { OnboardingOverlay } from "./components/OnboardingOverlay";
|
||||
@@ -81,6 +82,7 @@ import "./custom/features/heartbeat/heartbeat.css";
|
||||
import { CopyButton } from "./components/CopyButton";
|
||||
import { ExternalOpener } from "./components/ExternalOpener";
|
||||
import { startTerminalEventBridge } from "./lib/terminalEvents";
|
||||
import { applyTerminalThemePreference } from "./lib/terminalTheme";
|
||||
import { formatTerminalOutputForComposer } from "./lib/terminalOutput";
|
||||
import { useTerminalStore } from "./store/terminal";
|
||||
import { parseTodos } from "./lib/tools";
|
||||
@@ -98,18 +100,22 @@ import {
|
||||
type BotConnectionView,
|
||||
type BotRuntimeStatusView,
|
||||
type BotSettingsView,
|
||||
type ActiveWorkView,
|
||||
type BackgroundRuntimeView,
|
||||
type CollaborationMode,
|
||||
type ComposerInsertRequest,
|
||||
type DesktopStartupSettingsView,
|
||||
type Mode,
|
||||
modeHasPlan,
|
||||
type ProjectNode,
|
||||
type RewindResultView,
|
||||
type RemoteHostView,
|
||||
type SessionMeta,
|
||||
type SettingsView,
|
||||
type TabMeta,
|
||||
type TokenMode,
|
||||
type ToolApprovalMode,
|
||||
type WorkspaceConflictView,
|
||||
} from "./lib/types";
|
||||
import type { InvocationMetadataMap, StructuredInvocationSubmit } from "./lib/invocationDisplay";
|
||||
import { formatSelectionReference, type SelectedTextInsertRequest } from "./lib/selectedTextContext";
|
||||
@@ -279,9 +285,10 @@ function NoticePreviewPanel() {
|
||||
}
|
||||
|
||||
const HistoryPanel = lazy(() => import("./components/HistoryPanel").then((module) => ({ default: module.HistoryPanel })));
|
||||
const SettingsPanel = lazy(() => import("./components/SettingsPanel").then((module) => ({ default: module.SettingsPanel })));
|
||||
const SettingsPanel = lazy(() => import("./components/SettingsPanelEntry").then((module) => ({ default: module.SettingsPanel })));
|
||||
const RemotePanel = lazy(() => import("./components/RemotePanel").then((module) => ({ default: module.RemotePanel })));
|
||||
const TerminalPanel = lazy(() => import("./components/TerminalPanel").then((module) => ({ default: module.TerminalPanel })));
|
||||
const WorkspacePanel = lazy(() => import("./components/WorkspacePanel").then((module) => ({ default: module.WorkspacePanel })));
|
||||
|
||||
const CHAT_MIN_WIDTH = 400;
|
||||
const CHAT_COMFORT_MIN_WIDTH = 560;
|
||||
@@ -1087,9 +1094,12 @@ export default function App() {
|
||||
pickWorkspace,
|
||||
switchWorkspace,
|
||||
rewindForTab,
|
||||
rewindForTabDetailed,
|
||||
undoRewindForTab,
|
||||
setModel,
|
||||
setEffort,
|
||||
setTokenMode,
|
||||
cancelJob,
|
||||
switchTab,
|
||||
openProjectTab,
|
||||
createDeliveryWorktree,
|
||||
@@ -1126,8 +1136,8 @@ export default function App() {
|
||||
const settingsFocus = useOverlayStore((s) => s.settingsFocus);
|
||||
const setSettingsFocus = useOverlayStore((s) => s.setSettingsFocus);
|
||||
const [desktopLayoutStyle, setDesktopLayoutStyle] = useState<DesktopLayoutStyle>("workbench");
|
||||
const [safeMode, setSafeMode] = useState(false);
|
||||
const singleSurfaceLayout = desktopLayoutStyle === "workbench" || desktopLayoutStyle === "creation";
|
||||
const [configLoadWarnings, setConfigLoadWarnings] = useState<string[]>([]);
|
||||
const [startupUpdateChecksEnabled, setStartupUpdateChecksEnabled] = useState<boolean | null>(null);
|
||||
const [histView, setHistView] = useState<HistoryViewState | null>(null);
|
||||
const paletteOpen = useOverlayStore((s) => s.paletteOpen);
|
||||
@@ -1295,6 +1305,16 @@ export default function App() {
|
||||
const [sidebarTogglePressed, setSidebarTogglePressed] = useState(false);
|
||||
const [workspaceTogglePressed, setWorkspaceTogglePressed] = useState(false);
|
||||
const [clearContextPending, setClearContextPending] = useState(false);
|
||||
const [backgroundRuntimes, setBackgroundRuntimes] = useState<BackgroundRuntimeView[]>([]);
|
||||
const [workspaceConflict, setWorkspaceConflict] = useState<WorkspaceConflictView | null>(null);
|
||||
const [pendingModeSwitch, setPendingModeSwitch] = useState<{
|
||||
tabId: string;
|
||||
target: TokenMode;
|
||||
previous: TokenMode;
|
||||
work: ActiveWorkView;
|
||||
stopping: boolean;
|
||||
} | null>(null);
|
||||
const [pendingClose, setPendingClose] = useState<{ tabId: string; work: ActiveWorkView; stopping: boolean } | null>(null);
|
||||
const topicRenameSkipCommitRef = useRef(false);
|
||||
const prevDecisionSurfaceRef = useRef<DecisionSurfaceKind | null>(null);
|
||||
const decisionSurfaceRef = useRef<DecisionSurfaceKind | null>(null);
|
||||
@@ -1311,6 +1331,55 @@ export default function App() {
|
||||
document.documentElement.setAttribute("data-platform", desktopPlatform);
|
||||
}, [desktopPlatform]);
|
||||
|
||||
const refreshBackgroundRuntimes = useCallback(async () => {
|
||||
try {
|
||||
setBackgroundRuntimes(await app.BackgroundRuntimes());
|
||||
} catch {
|
||||
// The global recovery entry is supplementary; the active-tab job list
|
||||
// remains available if an older Remote Workbench does not implement it.
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
const refresh = async () => {
|
||||
if (disposed) return;
|
||||
await refreshBackgroundRuntimes();
|
||||
};
|
||||
void refresh();
|
||||
const timer = window.setInterval(() => void refresh(), 1000);
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [refreshBackgroundRuntimes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabId || !state.running) {
|
||||
setWorkspaceConflict(null);
|
||||
return;
|
||||
}
|
||||
let disposed = false;
|
||||
const inspect = async () => {
|
||||
try {
|
||||
const conflict = await app.WorkspaceConflictForTab(activeTabId);
|
||||
if (!disposed) setWorkspaceConflict(conflict.state === "none" ? null : conflict);
|
||||
} catch {
|
||||
if (!disposed) setWorkspaceConflict(null);
|
||||
}
|
||||
};
|
||||
void inspect();
|
||||
const timer = window.setInterval(() => void inspect(), 500);
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [activeTabId, state.running]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingModeSwitch && pendingModeSwitch.tabId !== activeTabId) setPendingModeSwitch(null);
|
||||
}, [activeTabId, pendingModeSwitch]);
|
||||
|
||||
const closeTransientOverlays = useCallback(() => {
|
||||
setTransientOverlayDismissSignal((signal) => signal + 1);
|
||||
}, []);
|
||||
@@ -1413,10 +1482,11 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
const applyDesktopPreferences = useCallback(
|
||||
(settings: Pick<SettingsView, "desktopTheme" | "desktopThemeStyle" | "desktopLayoutStyle" | "desktopLanguage" | "checkUpdates" | "statusBarStyle" | "statusBarItems" | "conversationWidth">) => {
|
||||
(settings: Pick<SettingsView, "desktopTheme" | "desktopThemeStyle" | "desktopTerminalTheme" | "desktopLayoutStyle" | "desktopLanguage" | "checkUpdates" | "statusBarStyle" | "statusBarItems" | "conversationWidth">) => {
|
||||
const nextTheme = normalizeThemePreference(settings.desktopTheme);
|
||||
const nextStyle = normalizeThemeStyleForTheme(settings.desktopThemeStyle, nextTheme);
|
||||
applyConfiguredBaseAppearance(nextTheme, nextStyle);
|
||||
applyTerminalThemePreference(settings.desktopTerminalTheme);
|
||||
applyConversationWidth(settings.conversationWidth);
|
||||
const nextLayoutStyle = normalizeDesktopLayoutStyle(settings.desktopLayoutStyle);
|
||||
setDesktopLayoutStyle(nextLayoutStyle);
|
||||
@@ -1445,14 +1515,16 @@ export default function App() {
|
||||
]);
|
||||
if (cancelled) return;
|
||||
applyDesktopPreferences(settings);
|
||||
setSafeMode(settings.safeMode === true);
|
||||
setConfigLoadWarnings(
|
||||
Array.isArray(settings.configWarnings)
|
||||
? settings.configWarnings.filter((w): w is string => typeof w === "string" && w.trim() !== "")
|
||||
: [],
|
||||
);
|
||||
hydrateDisplayMode(settings.displayMode);
|
||||
setSidebarImConnections(sidebarImConnectionsFromBot(settings.bot, t, runtimeStatus));
|
||||
setImTopicSources(sidebarImTopicSourcesFromBot(settings.bot, t));
|
||||
// Load unified theme experience after base appearance so pack tokens win.
|
||||
if (settings.safeMode === true) {
|
||||
clearThemePack();
|
||||
} else {
|
||||
{
|
||||
try {
|
||||
const { loadThemeExperience, applyExperienceToDOM } = await import("./lib/themeExperience");
|
||||
const exp = await loadThemeExperience();
|
||||
@@ -1688,9 +1760,12 @@ export default function App() {
|
||||
return state.approval.tool === "exit_plan_mode" ? "plan_approval" : "tool_approval";
|
||||
}
|
||||
if (state.ask) return "ask";
|
||||
if (workspaceConflict) return "workspace_conflict";
|
||||
if (pendingModeSwitch) return "mode_jobs";
|
||||
if (pendingClose) return "close_active";
|
||||
if (clearContextPending) return "clear_context";
|
||||
return null;
|
||||
}, [clearContextPending, state.approval, state.ask]);
|
||||
}, [clearContextPending, pendingClose, pendingModeSwitch, state.approval, state.ask, workspaceConflict]);
|
||||
decisionSurfaceRef.current = decisionSurface;
|
||||
useEffect(() => {
|
||||
// Close composer menus/popovers when a decision takes over the footer.
|
||||
@@ -1872,21 +1947,19 @@ export default function App() {
|
||||
},
|
||||
[activeTabId, applyGoalForTab],
|
||||
);
|
||||
const applyTokenMode = useCallback(
|
||||
async (m: TokenMode): Promise<void> => {
|
||||
const tabId = activeTabId;
|
||||
if (!tabId || runtimeTransitionTabsRef.current.has(tabId) || m === composerProfile.tokenMode) return;
|
||||
const previous = composerProfile.tokenMode;
|
||||
const commitTokenModeSwitch = useCallback(
|
||||
async (tabId: string, m: TokenMode, previous: TokenMode, profile: ComposerProfile): Promise<void> => {
|
||||
if (!tabId || activeTabIdRef.current !== tabId || runtimeTransitionTabsRef.current.has(tabId)) return;
|
||||
runtimeTransitionTabsRef.current.add(tabId);
|
||||
setRuntimeTransitionsByTab((current) => ({ ...current, [tabId]: true }));
|
||||
setComposerProfilesByTab((current) => patchComposerProfile(current, tabId, composerProfile, { tokenMode: m }, ["tokenMode"]));
|
||||
setComposerProfilesByTab((current) => patchComposerProfile(current, tabId, profile, { tokenMode: m }, ["tokenMode"]));
|
||||
const switched = await setTokenMode(m);
|
||||
if (!switched) {
|
||||
setComposerProfilesByTab((current) => {
|
||||
const profile = current[tabId] ?? composerProfile;
|
||||
const pending = { ...profile.pending };
|
||||
const currentProfile = current[tabId] ?? profile;
|
||||
const pending = { ...currentProfile.pending };
|
||||
delete pending.tokenMode;
|
||||
return { ...current, [tabId]: { ...profile, tokenMode: previous, pending } };
|
||||
return { ...current, [tabId]: { ...currentProfile, tokenMode: previous, pending } };
|
||||
});
|
||||
}
|
||||
runtimeTransitionTabsRef.current.delete(tabId);
|
||||
@@ -1897,8 +1970,64 @@ export default function App() {
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[activeTabId, composerProfile, setTokenMode],
|
||||
[setTokenMode],
|
||||
);
|
||||
const applyTokenMode = useCallback(
|
||||
async (m: TokenMode): Promise<void> => {
|
||||
const tabId = activeTabId;
|
||||
if (!tabId || runtimeTransitionTabsRef.current.has(tabId) || m === composerProfile.tokenMode) return;
|
||||
const previous = composerProfile.tokenMode;
|
||||
try {
|
||||
const work = await app.ActiveWorkForTab(tabId);
|
||||
if (work.jobs.length > 0) {
|
||||
setPendingModeSwitch({ tabId, target: m, previous, work, stopping: false });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// The atomic backend guard remains authoritative on older runtimes.
|
||||
}
|
||||
await commitTokenModeSwitch(tabId, m, previous, composerProfile);
|
||||
},
|
||||
[activeTabId, commitTokenModeSwitch, composerProfile],
|
||||
);
|
||||
const stopJobsAndSwitchMode = useCallback(async () => {
|
||||
const request = pendingModeSwitch;
|
||||
if (!request || request.stopping) return;
|
||||
setPendingModeSwitch({ ...request, stopping: true });
|
||||
try {
|
||||
await app.CancelJobsForTab(request.tabId, request.work.jobs.map((job) => job.id));
|
||||
const deadline = Date.now() + 15_000;
|
||||
let work = await app.ActiveWorkForTab(request.tabId);
|
||||
while (work.jobs.length > 0 && Date.now() < deadline) {
|
||||
setPendingModeSwitch((current) => current?.tabId === request.tabId ? { ...current, work } : current);
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 150));
|
||||
work = await app.ActiveWorkForTab(request.tabId);
|
||||
}
|
||||
if (work.jobs.length > 0) {
|
||||
setPendingModeSwitch((current) => current?.tabId === request.tabId ? { ...current, work, stopping: false } : current);
|
||||
showToast(t("status.jobStopFailed"), "error");
|
||||
return;
|
||||
}
|
||||
setPendingModeSwitch(null);
|
||||
await refreshBackgroundRuntimes();
|
||||
if (activeTabIdRef.current === request.tabId) {
|
||||
await commitTokenModeSwitch(request.tabId, request.target, request.previous, composerProfile);
|
||||
}
|
||||
} catch (err) {
|
||||
setPendingModeSwitch((current) => current?.tabId === request.tabId ? { ...current, stopping: false } : current);
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
}
|
||||
}, [commitTokenModeSwitch, composerProfile, pendingModeSwitch, refreshBackgroundRuntimes, showToast, t]);
|
||||
const cancelRuntimeJob = useCallback(async (tabId: string, jobId: string): Promise<boolean> => {
|
||||
try {
|
||||
const cancelled = await app.CancelJobForTab(tabId, jobId);
|
||||
await refreshBackgroundRuntimes();
|
||||
return cancelled;
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
return false;
|
||||
}
|
||||
}, [refreshBackgroundRuntimes, showToast]);
|
||||
// Shift+Tab toggles only the collaboration axis; Ctrl/Cmd+Y toggles YOLO on the
|
||||
// tool-permission axis while preserving the Ask/Auto base mode.
|
||||
const cycleMode = useCallback(() => {
|
||||
@@ -3076,6 +3205,16 @@ export default function App() {
|
||||
[isNavigationIntentCurrent, noteNavigationIntent, refreshTabMetas, switchTab],
|
||||
);
|
||||
|
||||
const revealBackgroundRuntime = useCallback(async (tabId: string): Promise<void> => {
|
||||
try {
|
||||
const meta = await app.RevealBackgroundRuntime(tabId);
|
||||
await switchTab(meta.id, meta);
|
||||
await refreshTabMetas(undefined, { afterMutation: true });
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
}
|
||||
}, [refreshTabMetas, showToast, switchTab]);
|
||||
|
||||
const handleTabChange = useCallback((id: string) => {
|
||||
closeTransientOverlays();
|
||||
const selected = tabMetas.find((tab) => tab.id === id);
|
||||
@@ -3084,8 +3223,16 @@ export default function App() {
|
||||
setTabRevealSignal((signal) => signal + 1);
|
||||
}, [closeTransientOverlays, enqueueTabSwitch, tabMetas]);
|
||||
|
||||
const handleTabClose = useCallback(async (id: string) => {
|
||||
const finishTabClose = useCallback(async (
|
||||
id: string,
|
||||
policy: "keep_running" | "stop_and_close",
|
||||
): Promise<boolean> => {
|
||||
closeTransientOverlays();
|
||||
const closed = await closeTab(id, policy);
|
||||
if (!closed) {
|
||||
showToast(t("runtime.closeFailed"), "error");
|
||||
return false;
|
||||
}
|
||||
setComposerProfilesByTab((current) => {
|
||||
if (!(id in current)) return current;
|
||||
const next = { ...current };
|
||||
@@ -3103,10 +3250,58 @@ export default function App() {
|
||||
const nextActiveId = remaining[nextIndex]?.id;
|
||||
return remaining.map((tab) => ({ ...tab, active: tab.id === nextActiveId }));
|
||||
});
|
||||
await closeTab(id);
|
||||
await refreshTabMetas(undefined, { afterMutation: true });
|
||||
await refreshBackgroundRuntimes();
|
||||
setTabRevealSignal((signal) => signal + 1);
|
||||
}, [activeTabId, closeTab, closeTransientOverlays, refreshTabMetas]);
|
||||
return true;
|
||||
}, [activeTabId, closeTab, closeTransientOverlays, refreshBackgroundRuntimes, refreshTabMetas, showToast, t]);
|
||||
|
||||
const handleTabClose = useCallback(async (id: string) => {
|
||||
try {
|
||||
const work = await app.ActiveWorkForTab(id);
|
||||
if (work.running || work.pendingPrompt || work.jobs.length > 0) {
|
||||
setPendingClose({ tabId: id, work, stopping: false });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// CloseTabWithPolicy re-checks the controller state atomically.
|
||||
}
|
||||
await finishTabClose(id, "stop_and_close");
|
||||
}, [finishTabClose]);
|
||||
|
||||
const resolvePendingClose = useCallback(async (policy: "keep_running" | "stop_and_close") => {
|
||||
const request = pendingClose;
|
||||
if (!request || request.stopping) return;
|
||||
if (policy === "stop_and_close") setPendingClose({ ...request, stopping: true });
|
||||
const closed = await finishTabClose(request.tabId, policy);
|
||||
if (closed) setPendingClose(null);
|
||||
else setPendingClose((current) => current?.tabId === request.tabId ? { ...current, stopping: false } : current);
|
||||
}, [finishTabClose, pendingClose]);
|
||||
|
||||
const revealWorkspaceWriter = useCallback(async () => {
|
||||
if (!activeTabId) return;
|
||||
try {
|
||||
const meta = await app.RevealWorkspaceWriterForTab(activeTabId);
|
||||
setWorkspaceConflict(null);
|
||||
await switchTab(meta.id, meta);
|
||||
await refreshTabMetas(undefined, { afterMutation: true });
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
}
|
||||
}, [activeTabId, refreshTabMetas, showToast, switchTab]);
|
||||
|
||||
const continueInDeliveryWorktree = useCallback(async () => {
|
||||
const root = state.meta?.workspaceRoot || state.meta?.workspacePath || state.meta?.cwd;
|
||||
if (!root) return;
|
||||
cancel();
|
||||
setWorkspaceConflict(null);
|
||||
try {
|
||||
await createDeliveryWorktree(root);
|
||||
await refreshTabMetas(undefined, { afterMutation: true });
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err), "error");
|
||||
}
|
||||
}, [cancel, createDeliveryWorktree, refreshTabMetas, showToast, state.meta?.cwd, state.meta?.workspacePath, state.meta?.workspaceRoot]);
|
||||
|
||||
const handleTabsClose = useCallback(async (ids: string[], nextActiveTabId?: string) => {
|
||||
closeTransientOverlays();
|
||||
@@ -3114,7 +3309,15 @@ export default function App() {
|
||||
const targets = ids.filter((id, index) => currentIds.includes(id) && ids.indexOf(id) === index);
|
||||
if (targets.length === 0) return;
|
||||
for (const id of targets) {
|
||||
await closeTab(id);
|
||||
let work: ActiveWorkView | null = null;
|
||||
try {
|
||||
work = await app.ActiveWorkForTab(id);
|
||||
} catch { /* the close path remains authoritative */ }
|
||||
if (work && (work.running || work.pendingPrompt || work.jobs.length > 0)) {
|
||||
setPendingClose({ tabId: id, work, stopping: false });
|
||||
return;
|
||||
}
|
||||
await finishTabClose(id, "stop_and_close");
|
||||
}
|
||||
if (nextActiveTabId && currentIds.includes(nextActiveTabId)) {
|
||||
const selected = tabMetas.find((tab) => tab.id === nextActiveTabId);
|
||||
@@ -3123,7 +3326,7 @@ export default function App() {
|
||||
}
|
||||
await refreshTabMetas(undefined, { afterMutation: true });
|
||||
setTabRevealSignal((signal) => signal + 1);
|
||||
}, [closeTab, closeTransientOverlays, enqueueTabSwitch, refreshTabMetas, tabMetas]);
|
||||
}, [closeTransientOverlays, enqueueTabSwitch, finishTabClose, refreshTabMetas, tabMetas]);
|
||||
|
||||
const handleTabsReorder = useCallback(async (ids: string[]) => {
|
||||
setTabOrderIds(ids);
|
||||
@@ -3139,18 +3342,16 @@ export default function App() {
|
||||
|
||||
const [rewindSignal, setRewindSignal] = useState(0);
|
||||
|
||||
// ── Optimistic rewind ─────────────────────────────────────────────────
|
||||
// Rewind is optimistic: the UI immediately truncates, scrolls to the
|
||||
// target, fills the composer, and shows an undo banner. The real Go
|
||||
// Rewind is deferred until the user SENDS a new message. Undo simply
|
||||
// restores the full items list — no Go call needed.
|
||||
// ── Immediate rewind ──────────────────────────────────────────────────
|
||||
// On confirm, call Go prepare+commit immediately. Only after success does
|
||||
// the UI truncate the transcript, refresh files, and fill the composer.
|
||||
// Real backend undo uses UndoRewindForTab when a transaction id is available.
|
||||
type RewindState = {
|
||||
turn: number;
|
||||
scope: string;
|
||||
fullItems: Item[]; // pre-truncation items (for undo)
|
||||
boundaryIdx: number; // first item index of the rewound-to turn
|
||||
turnDiff: number; // turns rolled back
|
||||
prompt: string; // user message text for composer fill
|
||||
transactionId?: string;
|
||||
undoAvailable?: boolean;
|
||||
filesRestored?: string[];
|
||||
filesRemoved?: string[];
|
||||
};
|
||||
const [rewindStatesByTab, setRewindStatesByTab] = useState<Record<string, RewindState>>({});
|
||||
const rewindStatesByTabRef = useRef(rewindStatesByTab);
|
||||
@@ -3177,19 +3378,40 @@ export default function App() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSessionRevertCommitted = useCallback((sourceTabId: string, outcome: RewindResultView) => {
|
||||
if (!sourceTabId || !outcome.ok) return;
|
||||
setRewindStateForTab(sourceTabId, {
|
||||
turnDiff: 0,
|
||||
transactionId: outcome.transactionId,
|
||||
undoAvailable: outcome.undoAvailable,
|
||||
filesRestored: outcome.written ?? [],
|
||||
filesRemoved: outcome.deleted ?? [],
|
||||
});
|
||||
setDockRefreshKey((value) => value + 1);
|
||||
setProjectRevision((value) => value + 1);
|
||||
}, [setRewindStateForTab]);
|
||||
|
||||
const hydratePlaceholderActive = Boolean(
|
||||
state.hydrating &&
|
||||
state.items.length === 0 &&
|
||||
state.hydratePlaceholderItems?.length,
|
||||
);
|
||||
const transcriptHydrating = state.hydrating && !state.hydrateHistoryLoaded;
|
||||
// Creation hero only after history hydration settles on a truly empty session.
|
||||
// Avoid flash while switching tabs: items may be empty while placeholders show.
|
||||
// Exclude IM/Bot detail: hero CSS collapses .main, which also hosts that panel.
|
||||
// (desktopLayoutStyle is available here; sidebarCreation is declared later.)
|
||||
const creationEmptyHero =
|
||||
desktopLayoutStyle === "creation" &&
|
||||
!sidebarImDetailConnection &&
|
||||
!sessionHasContent &&
|
||||
!transcriptHydrating &&
|
||||
!hydratePlaceholderActive;
|
||||
const transcriptItems = hydratePlaceholderActive ? state.hydratePlaceholderItems! : state.items;
|
||||
|
||||
// Display items: truncated when an optimistic rewind is pending.
|
||||
const displayItems = useMemo(() => {
|
||||
if (!rewindState) return transcriptItems;
|
||||
return transcriptItems.slice(0, rewindState.boundaryIdx).filter((it) => it.kind !== "compaction");
|
||||
}, [transcriptItems, rewindState]);
|
||||
// Display items: backend history is authoritative after immediate commit.
|
||||
// rewindState only drives the undo banner, not optimistic truncation.
|
||||
const displayItems = transcriptItems;
|
||||
const latestGuidanceConsumed = useMemo(() => {
|
||||
for (let i = state.items.length - 1; i >= 0; i--) {
|
||||
const item = state.items[i];
|
||||
@@ -3200,7 +3422,8 @@ export default function App() {
|
||||
return null;
|
||||
}, [state.items]);
|
||||
|
||||
// send wrapper: commits any pending optimistic rewind before sending.
|
||||
// send wrapper: clear local undo banner state before sending a new turn
|
||||
// (new mutation invalidates undo). Rewind itself already committed immediately.
|
||||
const commitThenSend = useCallback(async (
|
||||
sourceTabId: string,
|
||||
displayText: string,
|
||||
@@ -3223,31 +3446,12 @@ export default function App() {
|
||||
) {
|
||||
throw new Error(sourceTab.runtime?.issue?.message || sourceTab.startupErr || t("composer.workspaceStarting"));
|
||||
}
|
||||
const rs = rewindStatesByTabRef.current[sourceTabId];
|
||||
if (rs) {
|
||||
// New turn invalidates the last undo slot.
|
||||
if (rewindStatesByTabRef.current[sourceTabId]) {
|
||||
setRewindStateForTab(sourceTabId, null);
|
||||
setRewindCommittingForTab(sourceTabId, true);
|
||||
let ok = false;
|
||||
try {
|
||||
ok = await rewindForTab(sourceTabId, rs.turn, rs.scope);
|
||||
} finally {
|
||||
setRewindCommittingForTab(sourceTabId, false);
|
||||
}
|
||||
if (!ok) {
|
||||
// Rewind failed: the Go conversation is intact. Do not send; the
|
||||
// controller emits a notice with the reason.
|
||||
setRewindStateForTab(sourceTabId, rs);
|
||||
throw new Error(t("rewind.failed"));
|
||||
}
|
||||
setRewindSignal((v) => v + 1);
|
||||
if (rs.scope === "both") {
|
||||
// Code was only reverted now (deferred), so refresh the dock here.
|
||||
setDockRefreshKey((v) => v + 1);
|
||||
setProjectRevision((v) => v + 1);
|
||||
}
|
||||
}
|
||||
await sendToTab(sourceTabId, displayText, submitText, undefined, structured, initialGoal);
|
||||
}, [rewindForTab, sendToTab, setRewindCommittingForTab, setRewindStateForTab, t, tabMetas]);
|
||||
}, [sendToTab, setRewindStateForTab, t, tabMetas]);
|
||||
|
||||
const handleTranscriptPrompt = useCallback((text: string) => {
|
||||
if (!activeTabId || !controllerReady) return;
|
||||
@@ -3285,8 +3489,17 @@ export default function App() {
|
||||
// Code-only rewind only affects files — no message truncation,
|
||||
// no optimistic UI needed. Execute immediately.
|
||||
if (scope === "code") {
|
||||
rewindForTab(sourceTabId, turn, scope).then((ok) => {
|
||||
if (!ok) return;
|
||||
setRewindCommittingForTab(sourceTabId, true);
|
||||
void rewindForTabDetailed(sourceTabId, turn, scope).then((outcome) => {
|
||||
setRewindCommittingForTab(sourceTabId, false);
|
||||
if (!outcome.ok) return;
|
||||
setRewindStateForTab(sourceTabId, {
|
||||
turnDiff: 0,
|
||||
transactionId: outcome.transactionId,
|
||||
undoAvailable: outcome.undoAvailable,
|
||||
filesRestored: outcome.written ?? [],
|
||||
filesRemoved: outcome.deleted ?? [],
|
||||
});
|
||||
setDockRefreshKey((v) => v + 1);
|
||||
setProjectRevision((v) => v + 1);
|
||||
});
|
||||
@@ -3334,28 +3547,36 @@ export default function App() {
|
||||
|
||||
const prevUserCount = items.filter((it) => it.kind === "user").length;
|
||||
const turnDiff = prevUserCount - targetUserCount;
|
||||
|
||||
// Save full items for undo.
|
||||
const userItem = items[boundaryIdx]?.kind === "user" ? items[boundaryIdx] as Extract<Item, { kind: "user" }> : undefined;
|
||||
const prompt = userItem?.text ?? "";
|
||||
setRewindStateForTab(sourceTabId, {
|
||||
turn,
|
||||
scope,
|
||||
fullItems: items,
|
||||
boundaryIdx,
|
||||
turnDiff,
|
||||
prompt,
|
||||
|
||||
// Immediate backend commit — only update UI after success.
|
||||
setRewindCommittingForTab(sourceTabId, true);
|
||||
void rewindForTabDetailed(sourceTabId, turn, scope).then((outcome) => {
|
||||
setRewindCommittingForTab(sourceTabId, false);
|
||||
if (!outcome.ok) {
|
||||
// Keep conversation/files as-is; notices already carry the reason.
|
||||
return;
|
||||
}
|
||||
setRewindStateForTab(sourceTabId, {
|
||||
turnDiff,
|
||||
transactionId: outcome.transactionId,
|
||||
undoAvailable: outcome.undoAvailable,
|
||||
filesRestored: outcome.written ?? [],
|
||||
filesRemoved: outcome.deleted ?? [],
|
||||
});
|
||||
const insertId = Date.now();
|
||||
setComposerInsertRequestsByTab((current) => ({
|
||||
...current,
|
||||
[sourceTabId]: { id: insertId, text: prompt, mode: "replace" },
|
||||
}));
|
||||
setRewindSignal((v) => v + 1);
|
||||
if (scope === "both" || scope === "code") {
|
||||
setDockRefreshKey((v) => v + 1);
|
||||
setProjectRevision((v) => v + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Fill composer with the rewound-to user message.
|
||||
const insertId = Date.now();
|
||||
setComposerInsertRequestsByTab((current) => ({
|
||||
...current,
|
||||
[sourceTabId]: { id: insertId, text: prompt, mode: "replace" },
|
||||
}));
|
||||
|
||||
setRewindSignal((v) => v + 1);
|
||||
}, [activeTab?.readOnly, activeTabId, hydratePlaceholderActive, state.items, rewindForTab, refreshTabMetas, setRewindStateForTab]);
|
||||
}, [activeTab?.readOnly, activeTabId, hydratePlaceholderActive, state.items, rewindForTab, rewindForTabDetailed, refreshTabMetas, setRewindStateForTab, setRewindCommittingForTab]);
|
||||
|
||||
const handleEditPrompt = useCallback(async (turn: number, displayText: string, submitText?: string): Promise<boolean> => {
|
||||
const sourceTabId = activeTabId;
|
||||
@@ -4231,7 +4452,7 @@ export default function App() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
<section className={`chat-pane${sidebarCreation && !sessionHasContent ? " chat-pane--creation-empty" : ""}`}>
|
||||
<section className={`chat-pane${creationEmptyHero ? " chat-pane--creation-empty" : ""}`}>
|
||||
<>
|
||||
<header className="topicbar">
|
||||
{workbenchChromeHidden && (
|
||||
@@ -4318,23 +4539,8 @@ export default function App() {
|
||||
</div>
|
||||
<div className="topicbar__spacer" />
|
||||
<div className="topicbar__actions">
|
||||
{workbenchChromeHidden && (
|
||||
<Tooltip label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>
|
||||
<button
|
||||
className={[
|
||||
"topicbar__chrome-btn",
|
||||
"topicbar__chrome-btn--workspace",
|
||||
workspacePanelRenderable ? "topicbar__chrome-btn--active" : "",
|
||||
workspaceTogglePressed ? "topicbar__chrome-btn--pressed" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
type="button"
|
||||
onClick={toggleWorkspacePanel}
|
||||
aria-label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}
|
||||
aria-pressed={workspacePanelRenderable}
|
||||
>
|
||||
<PanelRight size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{sidebarCreation && !sidebarImDetailConnection && activeTab?.scope === "project" && (
|
||||
<ExternalOpener tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
|
||||
)}
|
||||
{!sidebarImDetailConnection && (
|
||||
<>
|
||||
@@ -4410,7 +4616,7 @@ export default function App() {
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!sidebarImDetailConnection && activeTab?.scope === "project" && (
|
||||
{!sidebarCreation && !sidebarImDetailConnection && activeTab?.scope === "project" && (
|
||||
<ExternalOpener tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
|
||||
)}
|
||||
<Tooltip label={t("shortcuts.cheatsheetTitle")}>
|
||||
@@ -4442,7 +4648,7 @@ export default function App() {
|
||||
{!sidebarCreation && <span>{t("topicBar.command")}</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{sidebarCreation && (
|
||||
{(sidebarCreation || workbenchChromeHidden) && (
|
||||
<Tooltip label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>
|
||||
<button
|
||||
className={[
|
||||
@@ -4466,8 +4672,41 @@ export default function App() {
|
||||
{state.meta?.startupErr && (
|
||||
<div className="banner banner--error">{t("topbar.startupError", { msg: state.meta.startupErr })}</div>
|
||||
)}
|
||||
{safeMode && (
|
||||
<div className="banner banner--warning">{t("guard.safeMode")}</div>
|
||||
{configLoadWarnings.length > 0 && (
|
||||
<div className="banner banner--warning banner--actionable">
|
||||
<span className="banner__msg" title={configLoadWarnings.join("\n")}>
|
||||
{t("config.loadWarning", { msg: configLoadWarnings[0] })}
|
||||
</span>
|
||||
<span className="banner__spacer" />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--small"
|
||||
onClick={() => void app.OpenUserConfigPath?.().catch(() => {})}
|
||||
>
|
||||
{t("config.openConfig")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--small"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const view = await app.ReloadUserConfig?.();
|
||||
if (view?.configWarnings) setConfigLoadWarnings(view.configWarnings);
|
||||
else setConfigLoadWarnings([]);
|
||||
} catch {
|
||||
/* keep banner */
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t("config.reloadConfig")}
|
||||
</button>
|
||||
<span className="banner__hint">{t("config.doctorHint")}</span>
|
||||
<button type="button" className="btn btn--small" onClick={() => setConfigLoadWarnings([])}>
|
||||
{t("updater.dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{providerSetupNeeded && !needsOnboarding && (
|
||||
<div className="banner banner--warning banner--actionable">
|
||||
@@ -4543,22 +4782,30 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
{rewindState && (
|
||||
<UndoRewindBanner
|
||||
<Suspense fallback={null}><UndoRewindBanner
|
||||
meta={{
|
||||
turns: rewindState.turnDiff,
|
||||
filesRestored: [], // optimistic: files haven't changed yet
|
||||
filesRemoved: [],
|
||||
filesRestored: rewindState.filesRestored ?? [],
|
||||
filesRemoved: rewindState.filesRemoved ?? [],
|
||||
onUndo: () => {
|
||||
if (activeTabId) setRewindStateForTab(activeTabId, null);
|
||||
if (activeTabId) {
|
||||
const tabId = activeTabId;
|
||||
if (!tabId) return;
|
||||
const tx = rewindState.transactionId;
|
||||
const undo = tx && rewindState.undoAvailable ? undoRewindForTab(tabId, tx) : Promise.resolve(true);
|
||||
void undo.then((ok) => {
|
||||
if (!ok) return;
|
||||
setRewindStateForTab(tabId, null);
|
||||
setComposerInsertRequestsByTab((current) => ({
|
||||
...current,
|
||||
[activeTabId]: { id: Date.now(), text: "", mode: "replace" },
|
||||
[tabId]: { id: Date.now(), text: "", mode: "replace" },
|
||||
}));
|
||||
}
|
||||
setRewindSignal((v) => v + 1);
|
||||
setDockRefreshKey((v) => v + 1);
|
||||
setProjectRevision((v) => v + 1);
|
||||
});
|
||||
},
|
||||
}}
|
||||
/>
|
||||
/></Suspense>
|
||||
)}
|
||||
{decisionSurface === "tool_approval" || decisionSurface === "plan_approval"
|
||||
? state.approval && (
|
||||
@@ -4608,6 +4855,81 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: decisionSurface === "workspace_conflict" && workspaceConflict ? (
|
||||
<RuntimeDecisionCard
|
||||
id="workspace-conflict"
|
||||
title={t("runtime.workspaceConflictTitle")}
|
||||
badge={t("runtime.workspaceConflictBadge")}
|
||||
meta={workspaceConflict.state === "local"
|
||||
? t("runtime.workspaceConflictLocal", { title: workspaceConflict.ownerTitle || t("runtime.unknownTask") })
|
||||
: t("runtime.workspaceConflictExternal")}
|
||||
note={t("runtime.workspaceConflictNote")}
|
||||
onCancel={() => {
|
||||
cancel();
|
||||
setWorkspaceConflict(null);
|
||||
}}
|
||||
actions={[
|
||||
...(workspaceConflict.canReveal ? [{
|
||||
key: "1", label: t("runtime.revealWriter"), description: t("runtime.revealWriterDesc"),
|
||||
onClick: () => void revealWorkspaceWriter(),
|
||||
}] : []),
|
||||
...(workspaceConflict.canCreateWorktree ? [{
|
||||
key: "2", label: t("runtime.openWorktree"), description: t("runtime.openWorktreeDesc"),
|
||||
onClick: () => void continueInDeliveryWorktree(),
|
||||
}] : []),
|
||||
{
|
||||
key: "Esc", label: t("runtime.cancelWait"), description: t("runtime.cancelWaitDesc"),
|
||||
onClick: () => { cancel(); setWorkspaceConflict(null); }, danger: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
: decisionSurface === "mode_jobs" && pendingModeSwitch ? (
|
||||
<RuntimeDecisionCard
|
||||
id="mode-jobs"
|
||||
title={t("runtime.modeJobsTitle")}
|
||||
badge={t("status.jobs", { n: pendingModeSwitch.work.jobs.length })}
|
||||
meta={t("runtime.modeJobsMeta")}
|
||||
note={pendingModeSwitch.work.jobs.map((job) => job.label || job.kind).join(" · ")}
|
||||
onCancel={() => setPendingModeSwitch(null)}
|
||||
actions={[
|
||||
{
|
||||
key: "1", label: t("common.cancel"), description: t("runtime.keepModeDesc"),
|
||||
onClick: () => setPendingModeSwitch(null), disabled: pendingModeSwitch.stopping,
|
||||
},
|
||||
{
|
||||
key: "2", label: pendingModeSwitch.stopping ? t("status.jobStopping") : t("runtime.stopAndSwitch"),
|
||||
description: t("runtime.stopAndSwitchDesc"), onClick: () => void stopJobsAndSwitchMode(),
|
||||
danger: true, disabled: pendingModeSwitch.stopping,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
: decisionSurface === "close_active" && pendingClose ? (
|
||||
<RuntimeDecisionCard
|
||||
id="close-active"
|
||||
title={t("runtime.closeTitle")}
|
||||
badge={t("runtime.closeBadge")}
|
||||
meta={t("runtime.closeMeta", { n: pendingClose.work.jobs.length })}
|
||||
note={t("runtime.closeNote")}
|
||||
onCancel={() => setPendingClose(null)}
|
||||
actions={[
|
||||
{
|
||||
key: "1", label: t("common.cancel"), description: t("runtime.closeCancelDesc"),
|
||||
onClick: () => setPendingClose(null), disabled: pendingClose.stopping,
|
||||
},
|
||||
{
|
||||
key: "2", label: t("runtime.keepRunning"), description: t("runtime.keepRunningDesc"),
|
||||
onClick: () => void resolvePendingClose("keep_running"), disabled: pendingClose.stopping,
|
||||
},
|
||||
{
|
||||
key: "3", label: pendingClose.stopping ? t("status.jobStopping") : t("runtime.stopAndClose"),
|
||||
description: t("runtime.stopAndCloseDesc"), onClick: () => void resolvePendingClose("stop_and_close"),
|
||||
danger: true, disabled: pendingClose.stopping,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
: decisionSurface === "clear_context" ? (
|
||||
<ClearContextCard
|
||||
onCancel={cancelClearContext}
|
||||
@@ -4619,11 +4941,18 @@ export default function App() {
|
||||
{/* Composer stays mounted under a decision so per-session draft
|
||||
caches (text, attachments, paste blocks, guidance) survive. */}
|
||||
<div
|
||||
className={decisionSurface ? "composer-decision-host composer-decision-host--hidden" : "composer-decision-host"}
|
||||
className={[
|
||||
"composer-decision-host",
|
||||
decisionSurface ? "composer-decision-host--hidden" : "",
|
||||
creationEmptyHero ? "composer-decision-host--creation-hero" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
hidden={Boolean(decisionSurface) || undefined}
|
||||
inert={decisionSurface ? true : undefined}
|
||||
aria-hidden={decisionSurface ? true : undefined}
|
||||
>
|
||||
{creationEmptyHero && (
|
||||
<h2 className="welcome-creation__headline">{t("welcome.creation.title")}</h2>
|
||||
)}
|
||||
<Composer
|
||||
running={state.running || rewindCommitting}
|
||||
collaborationMode={collaborationMode}
|
||||
@@ -4670,6 +4999,7 @@ export default function App() {
|
||||
guidanceConsumedText={latestGuidanceConsumed?.text}
|
||||
guidanceQueuePreviewItems={guidanceQueueMockItems}
|
||||
showContextWindowRing={sidebarCreation}
|
||||
heroMode={creationEmptyHero}
|
||||
context={state.context}
|
||||
turnCost={state.turnCost}
|
||||
currency={state.sessionCurrency}
|
||||
@@ -4753,16 +5083,6 @@ export default function App() {
|
||||
<span className="workbench-dock__tab-label">{t("rightDock.remote")}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={terminalPanelOpen}
|
||||
className={`workbench-dock__tab${terminalPanelOpen ? " workbench-dock__tab--active" : ""}`}
|
||||
onClick={toggleTerminalPanel}
|
||||
>
|
||||
<TerminalSquare size={13} />
|
||||
<span className="workbench-dock__tab-label">{t("rightDock.terminal")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="workbench-dock__body">
|
||||
@@ -4787,31 +5107,34 @@ export default function App() {
|
||||
usageSeq={state.usageSeq}
|
||||
/>
|
||||
) : (
|
||||
<WorkspacePanel
|
||||
open={workspacePanelRenderable}
|
||||
tabId={activeTabId}
|
||||
cwd={state.meta?.cwd}
|
||||
workspaceScopeKey={workspaceScopeKey}
|
||||
workspaceMemoryKey={workspaceTreeMemoryKey}
|
||||
workspaceMemoryVisitId={workspaceTreeMemoryVisitId}
|
||||
maximized={workspacePanelMaximized}
|
||||
panelWidth={workspacePanelRenderWidth}
|
||||
onClose={() => setWorkspacePanel(false)}
|
||||
onToggleMaximized={() => {
|
||||
closeTransientOverlays();
|
||||
setWorkspacePanelMaximized((value) => !value);
|
||||
}}
|
||||
onPreviewModeChange={handleWorkspacePreviewModeChange}
|
||||
onAddToChat={addWorkspaceTextToComposer}
|
||||
onAddCodeToChat={addWorkspaceCodeToComposer}
|
||||
onRequestPanelWidth={ensureWorkspacePanelWidth}
|
||||
onFileTreeRefresh={refreshComposerFileRefs}
|
||||
onOpenInTerminal={openTerminalForPath}
|
||||
refreshKey={dockRefreshKey}
|
||||
initialViewMode={rightDockMode === "changed" ? "changed" : "files"}
|
||||
showViewTabs={false}
|
||||
creationMode={sidebarCreation}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<WorkspacePanel
|
||||
open={workspacePanelRenderable}
|
||||
tabId={activeTabId}
|
||||
cwd={state.meta?.cwd}
|
||||
workspaceScopeKey={workspaceScopeKey}
|
||||
workspaceMemoryKey={workspaceTreeMemoryKey}
|
||||
workspaceMemoryVisitId={workspaceTreeMemoryVisitId}
|
||||
maximized={workspacePanelMaximized}
|
||||
panelWidth={workspacePanelRenderWidth}
|
||||
onClose={() => setWorkspacePanel(false)}
|
||||
onToggleMaximized={() => {
|
||||
closeTransientOverlays();
|
||||
setWorkspacePanelMaximized((value) => !value);
|
||||
}}
|
||||
onPreviewModeChange={handleWorkspacePreviewModeChange}
|
||||
onAddToChat={addWorkspaceTextToComposer}
|
||||
onAddCodeToChat={addWorkspaceCodeToComposer}
|
||||
onRequestPanelWidth={ensureWorkspacePanelWidth}
|
||||
onFileTreeRefresh={refreshComposerFileRefs}
|
||||
onSessionRevertCommitted={handleSessionRevertCommitted}
|
||||
onOpenInTerminal={openTerminalForPath}
|
||||
refreshKey={dockRefreshKey}
|
||||
initialViewMode={rightDockMode === "changed" ? "changed" : "files"}
|
||||
showViewTabs={false}
|
||||
creationMode={sidebarCreation}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
@@ -4862,6 +5185,11 @@ export default function App() {
|
||||
usage={state.usage}
|
||||
balance={state.balance}
|
||||
running={state.running || rewindCommitting}
|
||||
jobs={state.jobs}
|
||||
onCancelJob={cancelJob}
|
||||
backgroundRuntimes={backgroundRuntimes}
|
||||
onCancelRuntimeJob={cancelRuntimeJob}
|
||||
onRevealRuntime={revealBackgroundRuntime}
|
||||
sessionTurns={sessionTurns}
|
||||
sessionTokens={state.sessionTokens}
|
||||
turnTokens={state.turnTotalTokens}
|
||||
|
||||
@@ -16,6 +16,8 @@ const transcriptSource = readFileSync(resolve(testDir, "../components/Transcript
|
||||
const composerSource = readFileSync(resolve(testDir, "../components/Composer.tsx"), "utf8");
|
||||
const controllerSource = readFileSync(resolve(testDir, "../lib/useController.ts"), "utf8");
|
||||
const bridgeSource = readFileSync(resolve(testDir, "../lib/bridge.ts"), "utf8");
|
||||
const workspacePanelSource = readFileSync(resolve(testDir, "../components/WorkspacePanel.tsx"), "utf8");
|
||||
const rewindCommitSource = readFileSync(resolve(testDir, "../lib/rewindCommit.ts"), "utf8");
|
||||
const layoutStoreSource = readFileSync(resolve(testDir, "../store/layout.ts"), "utf8");
|
||||
const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
|
||||
@@ -429,6 +431,18 @@ ok(
|
||||
"committing optimistic rewind clears only the source tab before awaiting the backend",
|
||||
);
|
||||
|
||||
ok(
|
||||
/if \(scope === "code"\) \{[\s\S]*?rewindForTabDetailed\(sourceTabId, turn, scope\)[\s\S]*?transactionId: outcome\.transactionId/.test(appSource),
|
||||
"code-only rewind retains the committed transaction id for real undo",
|
||||
);
|
||||
|
||||
ok(
|
||||
/onSessionRevertCommitted\?\.\(workspaceTabId, result\)/.test(workspacePanelSource) &&
|
||||
/onSessionRevertCommitted=\{handleSessionRevertCommitted\}/.test(appSource) &&
|
||||
/handleSessionRevertCommitted[\s\S]*?transactionId: outcome\.transactionId/.test(appSource),
|
||||
"single-file session revert publishes its transaction id to the app undo state",
|
||||
);
|
||||
|
||||
ok(
|
||||
/const controllerReady =\s*state\.meta\?\.ready === true &&\s*\(!state\.meta\.runtime \|\| state\.meta\.runtime\.phase === "ready"\) &&\s*!state\.meta\.startupErr &&\s*!state\.backendActivationPending &&\s*!runtimeTransitioning;/.test(appSource) &&
|
||||
/if \(!activeTabId \|\| !controllerReady\) return;\s*void commitThenSend\(activeTabId, text\)\.catch/.test(appSource) &&
|
||||
@@ -465,21 +479,49 @@ ok(
|
||||
/app\.NewSessionForTab\(tabId\)/.test(controllerSource) &&
|
||||
/app\.ClearSessionForTab\(tabId\)/.test(controllerSource) &&
|
||||
/app\.CompactForTab\(tabId\)/.test(controllerSource) &&
|
||||
/app\.RewindForTab\(sourceTabId, turn, actionScope\)/.test(controllerSource) &&
|
||||
/import\("\.\/rewindCommit"\)/.test(controllerSource) &&
|
||||
/app\.PreviewRewindForTab\(sourceTabId, turn, scope\)/.test(rewindCommitSource) &&
|
||||
/app\.CommitRewindForTab\(sourceTabId, remoteLegacy \? "" : \(plan\.planId \|\| ""\), turn, scope\)/.test(rewindCommitSource) &&
|
||||
/app\.UndoRewindForTab\(sourceTabId, transactionId\)/.test(rewindCommitSource) &&
|
||||
/app\.ForkForTab\(sourceTabId, turn\)/.test(controllerSource) &&
|
||||
/app\.SummarizeFromForTab\(sourceTabId, turn\)/.test(controllerSource) &&
|
||||
/NewSessionForTab\(tabID: string\)/.test(bridgeSource) &&
|
||||
/CompactForTab\(tabID: string\)/.test(bridgeSource) &&
|
||||
/RewindForTab\(tabID: string, turn: number, scope: string\)/.test(bridgeSource),
|
||||
/PreviewRewindForTab\(tabID: string, turn: number, scope: string\)/.test(bridgeSource) &&
|
||||
/CommitRewindForTab\(tabID: string, planID: string, turn: number, scope: string\)/.test(bridgeSource) &&
|
||||
/UndoRewindForTab\(tabID: string, transactionID: string\)/.test(bridgeSource),
|
||||
"session-changing controller actions use explicit tab-scoped Wails bindings",
|
||||
);
|
||||
|
||||
ok(
|
||||
/plan\.coverage === "partial"/.test(rewindCommitSource) &&
|
||||
/window\.confirm\(t\("rewind\.confirmPartialCoverage"/.test(rewindCommitSource) &&
|
||||
/plan\?\.conflicts\?\.length \? "overwrite_checkpoint" : ""/.test(workspacePanelSource),
|
||||
"rewind previews warn on incomplete coverage and only authorize file overwrite after a conflict confirmation",
|
||||
);
|
||||
|
||||
ok(
|
||||
/const transcriptHydrating = state\.hydrating && !state\.hydrateHistoryLoaded;/.test(appSource) &&
|
||||
/hydrating=\{transcriptHydrating\}/.test(appSource),
|
||||
"Welcome is suppressed only until transcript history has loaded",
|
||||
);
|
||||
|
||||
ok(
|
||||
/const creationEmptyHero =/.test(appSource) &&
|
||||
/!sidebarImDetailConnection/.test(appSource) &&
|
||||
/!transcriptHydrating/.test(appSource) &&
|
||||
/!hydratePlaceholderActive/.test(appSource) &&
|
||||
/chat-pane\$\{creationEmptyHero \? " chat-pane--creation-empty" : ""\}/.test(appSource) &&
|
||||
/heroMode=\{creationEmptyHero\}/.test(appSource),
|
||||
"Creation empty hero waits for hydration and skips IM/Bot detail panels",
|
||||
);
|
||||
|
||||
ok(
|
||||
/if \(heroMode\) \{[\s\S]*?const maxHeight = 96;[\s\S]*?setTextareaAutoHeight/.test(composerSource) &&
|
||||
!/if \(heroMode\) \{\s*setTextareaAutoHeight\(20\);/.test(composerSource),
|
||||
"Creation hero composer auto-grows multi-line drafts instead of clipping at 20px",
|
||||
);
|
||||
|
||||
ok(
|
||||
/const \[workspaceControllerEpoch, setWorkspaceControllerEpoch\] = useState\(0\);/.test(appSource) &&
|
||||
/const workspaceScopeKey = \[/.test(appSource) &&
|
||||
@@ -598,8 +640,15 @@ ok(
|
||||
ok(
|
||||
finalDeclaration(".app--windows.app--creation .topicbar", "position") === "relative" &&
|
||||
finalDeclaration(".app--windows.app--creation .topicbar", "z-index") === "var(--z-app-chrome)" &&
|
||||
finalDeclaration(".app--windows.app--creation .topicbar", "transform") === "translateY(-4px) !important",
|
||||
"Windows Creation topic bar lifts its menus above conversation content without changing titlebar alignment",
|
||||
finalDeclaration(".app--windows.app--creation .topicbar", "min-height") === "40px" &&
|
||||
finalDeclaration(":root[data-theme-style] .app--windows.app--creation .topicbar", "min-height") === "40px" &&
|
||||
finalDeclaration(".app--windows.app--creation .topicbar", "transform") === "none !important" &&
|
||||
finalDeclaration(".app--windows.app--creation .topicbar__title-row", "transform") === "none" &&
|
||||
finalDeclaration(".app--windows-frameless.app--creation", "--windows-window-controls-height") === "40px" &&
|
||||
finalDeclaration(".app--creation .topicbar", "min-height") === "56px" &&
|
||||
finalDeclaration(":root[data-theme-style] .app--creation .topicbar", "padding-top") === "14px" &&
|
||||
finalDeclaration(".app--creation .topicbar__title-row", "transform") === "translateY(-3px)",
|
||||
"Windows Creation stays 40px while macOS and Linux keep the shared Creation geometry",
|
||||
);
|
||||
|
||||
for (const selector of [
|
||||
@@ -639,6 +688,63 @@ ok(
|
||||
"maximized workbench dock keeps a draggable title region while tabs remain clickable",
|
||||
);
|
||||
|
||||
ok(
|
||||
finalDeclaration(":root[data-theme-style] .workbench-dock__tab--active::after", "bottom") === "1px",
|
||||
"active dock underline stays inside the visible dock edge",
|
||||
);
|
||||
|
||||
for (const selector of [
|
||||
".app--classic .workbench-dock__tab + .workbench-dock__tab::before",
|
||||
".app--workbench .workbench-dock__tab + .workbench-dock__tab::before",
|
||||
]) {
|
||||
ok(
|
||||
finalDeclaration(selector, "width") === "1px" &&
|
||||
finalDeclaration(selector, "height") === "16px" &&
|
||||
finalDeclaration(selector, "background")?.includes("--border-soft"),
|
||||
`${selector} renders a restrained divider between right-dock tabs`,
|
||||
);
|
||||
}
|
||||
|
||||
ok(
|
||||
finalDeclaration(".app--creation .workbench-dock__tab + .workbench-dock__tab::before", "content") === undefined,
|
||||
"Creation right-dock tabs keep their equal-column treatment without dividers",
|
||||
);
|
||||
|
||||
for (const selector of [
|
||||
".app--windows-frameless.app--workbench .workbench-dock__tools",
|
||||
":root[data-theme-style] .app--windows-frameless.app--workbench .workbench-dock__tools",
|
||||
]) {
|
||||
const padding = finalDeclaration(selector, "padding") ?? "";
|
||||
ok(
|
||||
finalDeclaration(selector, "height") === "calc(40px + var(--windows-window-controls-height))" &&
|
||||
padding === "var(--windows-window-controls-height) 12px 0" &&
|
||||
!padding.includes("--windows-window-controls-safe"),
|
||||
`${selector} keeps dock tabs on a full-width row below Windows controls`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const selector of [
|
||||
".app--windows-frameless.app--workbench .workbench-dock__tools::before",
|
||||
":root[data-theme-style] .app--windows-frameless.app--workbench .workbench-dock__tools::before",
|
||||
]) {
|
||||
ok(
|
||||
finalDeclaration(selector, "top") === "calc(var(--windows-window-controls-height) - 1px)" &&
|
||||
finalDeclaration(selector, "height") === "1px",
|
||||
`${selector} separates the Windows title row from the dock tabs`,
|
||||
);
|
||||
}
|
||||
|
||||
ok(
|
||||
finalDeclaration(".app--windows-frameless:not(.app--workbench) .workbench-dock__tools", "padding-right") === undefined &&
|
||||
finalDeclaration(":root[data-theme-style] .app--windows-frameless:not(.app--workbench) .workbench-dock__tools", "padding-right") === undefined,
|
||||
"classic dock tabs do not reserve native window-control space on their separate chrome row",
|
||||
);
|
||||
|
||||
ok(
|
||||
/@container \(max-width: 420px\) \{[\s\S]*?\.app--classic \.workbench-dock__tab,[\s\S]*?\.app--workbench \.workbench-dock__tab,[\s\S]*?padding-left:\s*10px;[\s\S]*?padding-right:\s*10px;[\s\S]*?gap:\s*4px;/.test(stylesSource),
|
||||
"classic and workbench share the same compact four-tab spacing at narrow dock widths",
|
||||
);
|
||||
|
||||
for (const selector of [
|
||||
":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar",
|
||||
":root[data-theme-style] .layout--workbench-chrome-hidden .topicbar__chrome-btn",
|
||||
|
||||
@@ -54,6 +54,25 @@ function installDom() {
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return this.classList.contains("prompt-action__desc") ? 42 : 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollHeight", {
|
||||
configurable: true,
|
||||
get() {
|
||||
if (!this.classList.contains("prompt-action__desc")) return 0;
|
||||
return this.textContent?.includes("Reuse the archive flow") ? 84 : 42;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value() {
|
||||
this.setAttribute("data-scrolled-into-view", "true");
|
||||
},
|
||||
});
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = styles;
|
||||
@@ -77,7 +96,10 @@ console.log("\nask card layout");
|
||||
header: "Review",
|
||||
prompt: "baoguanPutArchive needs a user-owned decision: fully align archive logic, or only repair the current compiler error?",
|
||||
options: [
|
||||
{ label: "Full alignment", description: "Reuse the archive flow and keep behavior consistent." },
|
||||
{
|
||||
label: "Full alignment",
|
||||
description: "Reuse the archive flow and keep behavior consistent across every constructor, dynamically computed path, runtime boundary, and release validation step.",
|
||||
},
|
||||
{ label: "Minimal repair", description: "Touch only the failing path and keep the patch smaller." },
|
||||
],
|
||||
},
|
||||
@@ -99,8 +121,10 @@ console.log("\nask card layout");
|
||||
});
|
||||
|
||||
const card = document.querySelector(".prompt-shelf__card") as HTMLElement | null;
|
||||
const content = document.querySelector(".prompt-shelf__content") as HTMLElement | null;
|
||||
const meta = document.querySelector(".prompt-shelf__meta") as HTMLElement | null;
|
||||
if (!card || !meta) throw new Error("ask prompt shelf did not render");
|
||||
const footer = document.querySelector(".prompt-shelf__footer") as HTMLElement | null;
|
||||
if (!card || !content || !meta || !footer) throw new Error("ask prompt shelf did not render");
|
||||
|
||||
eq(meta.textContent, ask.questions[0].prompt, "ask question text remains complete in the prompt shelf");
|
||||
|
||||
@@ -111,6 +135,10 @@ 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");
|
||||
ok(document.querySelector(".prompt-shelf--decision") != null, "ask uses the unified decision surface layout");
|
||||
eq(window.getComputedStyle(card).maxHeight, "min(82vh, 720px)", "Ask card stays bounded by the viewport");
|
||||
eq(window.getComputedStyle(card).overflow, "hidden", "Ask card delegates overflow to one content scroller");
|
||||
eq(window.getComputedStyle(content).overflow, "auto", "Ask title, question, and options share one scroll region");
|
||||
eq(content.contains(footer), false, "Ask confirmation footer stays outside the scrolling content");
|
||||
|
||||
const optionButtons = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
|
||||
// options + custom + skip
|
||||
@@ -119,11 +147,80 @@ console.log("\nask card layout");
|
||||
optionButtons[0]?.textContent?.includes("Reuse the archive flow") === true,
|
||||
"option descriptions render inline on each decision row",
|
||||
);
|
||||
const actions = document.querySelector(".prompt-shelf__actions") as HTMLElement | null;
|
||||
const firstOption = optionButtons[0];
|
||||
const firstDescription = firstOption?.querySelector(".prompt-action__desc") as HTMLElement | null;
|
||||
if (!actions || !firstOption || !firstDescription) throw new Error("ask option layout did not render");
|
||||
|
||||
const actionsStyle = window.getComputedStyle(actions);
|
||||
eq(actionsStyle.gridAutoRows, "max-content", "decision rows use their full content height before scrolling");
|
||||
eq(actionsStyle.alignContent, "start", "decision rows stay content-sized at the top of the scroll region");
|
||||
eq(actionsStyle.maxHeight, "none", "Ask options do not create a nested scroll region");
|
||||
eq(actionsStyle.overflow, "visible", "Ask option overflow belongs to the shared content scroller");
|
||||
|
||||
const optionStyle = window.getComputedStyle(firstOption);
|
||||
eq(optionStyle.height, "auto", "long decision rows are not fixed-height");
|
||||
eq(optionStyle.minHeight, "40px", "short decision rows retain the compact minimum height");
|
||||
eq(optionStyle.alignItems, "flex-start", "multi-line decision copy aligns with the option key");
|
||||
|
||||
const descriptionStyle = window.getComputedStyle(firstDescription);
|
||||
eq(descriptionStyle.whiteSpace, "normal", "long option descriptions can wrap");
|
||||
eq(descriptionStyle.display, "-webkit-box", "long Ask descriptions use a multi-line clamp box");
|
||||
eq(descriptionStyle.overflow, "hidden", "collapsed Ask descriptions stay within three lines");
|
||||
eq(descriptionStyle.getPropertyValue("-webkit-line-clamp"), "3", "collapsed Ask descriptions show three summary lines");
|
||||
eq(descriptionStyle.textOverflow, "clip", "wrapped descriptions do not use a single-line ellipsis");
|
||||
eq(descriptionStyle.overflowWrap, "anywhere", "unspaced option descriptions can wrap without overflowing");
|
||||
eq(firstOption.getAttribute("title"), ask.questions[0].options[0].description, "collapsed descriptions keep the full native tooltip");
|
||||
|
||||
const descriptionToggle = document.querySelector(".prompt-action__description-toggle") as HTMLButtonElement | null;
|
||||
if (!descriptionToggle) throw new Error("long Ask description disclosure did not render");
|
||||
eq(descriptionToggle.textContent?.trim(), "View full description", "truncated descriptions expose an explicit full-text action");
|
||||
eq(descriptionToggle.getAttribute("aria-expanded"), "false", "full description starts collapsed");
|
||||
eq(descriptionToggle.getAttribute("aria-controls"), firstDescription.id, "disclosure identifies the controlled description");
|
||||
|
||||
let disclosureEnterDefaultPrevented = true;
|
||||
await act(async () => {
|
||||
const event = new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
|
||||
descriptionToggle.dispatchEvent(event);
|
||||
disclosureEnterDefaultPrevented = event.defaultPrevented;
|
||||
await flushTimers();
|
||||
});
|
||||
eq(answers.length, 0, "Enter on Ask disclosure never confirms the selected answer");
|
||||
eq(disclosureEnterDefaultPrevented, true, "Ask disclosure owns keyboard activation before global shortcuts");
|
||||
eq(descriptionToggle.getAttribute("aria-expanded"), "true", "Enter expands the Ask description");
|
||||
|
||||
await act(async () => {
|
||||
descriptionToggle.dispatchEvent(new window.KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(descriptionToggle.getAttribute("aria-expanded"), "false", "Enter collapses the Ask description again");
|
||||
|
||||
await act(async () => {
|
||||
descriptionToggle.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(firstOption.classList.contains("prompt-action--description-expanded"), true, "full-text action expands the selected card");
|
||||
eq(descriptionToggle.getAttribute("aria-expanded"), "true", "expanded state is announced");
|
||||
const expandedDescriptionStyle = window.getComputedStyle(firstDescription);
|
||||
eq(expandedDescriptionStyle.display, "block", "expanded description restores normal block flow");
|
||||
eq(expandedDescriptionStyle.overflow, "visible", "expanded description reveals the complete text");
|
||||
|
||||
await act(async () => {
|
||||
descriptionToggle.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(firstOption.classList.contains("prompt-action--description-expanded"), false, "full description can be collapsed again");
|
||||
eq(descriptionToggle.getAttribute("aria-expanded"), "false", "collapsed state is announced");
|
||||
|
||||
await act(async () => {
|
||||
optionButtons[1].click();
|
||||
await flushTimers(200);
|
||||
});
|
||||
eq(document.querySelector(".prompt-action__description-toggle"), null, "short selected descriptions do not show a redundant disclosure");
|
||||
eq(answers.length, 0, "single-select click only selects and does not auto-advance/submit");
|
||||
|
||||
await act(async () => {
|
||||
@@ -245,6 +342,7 @@ console.log("\nask card layout");
|
||||
});
|
||||
eq(optionButtons[0]?.getAttribute("aria-selected"), "false", "ArrowDown moves the single-select cursor off the first row");
|
||||
eq(optionButtons[1]?.getAttribute("aria-selected"), "true", "ArrowDown selects the second option visually");
|
||||
eq(optionButtons[1]?.getAttribute("data-scrolled-into-view"), "true", "keyboard selection stays inside the visible option viewport");
|
||||
eq(confirm.disabled, false, "ArrowDown keeps confirm enabled for the highlighted option");
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -20,6 +20,7 @@ function ok(cond: boolean, label: string) {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
|
||||
const projectTreeSource = readFileSync(resolve(here, "../components/ProjectTree.tsx"), "utf8");
|
||||
const settingsEntrySource = readFileSync(resolve(here, "../components/SettingsPanelEntry.tsx"), "utf8");
|
||||
const settingsSource = readFileSync(resolve(here, "../components/SettingsPanel.tsx"), "utf8");
|
||||
const markdownSource = readFileSync(resolve(here, "../components/Markdown.tsx"), "utf8");
|
||||
const i18nSource = readFileSync(resolve(here, "../lib/i18n.tsx"), "utf8");
|
||||
@@ -42,10 +43,16 @@ ok(
|
||||
"App keeps secondary drawers out of the initial chunk",
|
||||
);
|
||||
ok(
|
||||
appSource.includes('import("./components/SettingsPanel")') &&
|
||||
appSource.includes('import("./components/SettingsPanelEntry")') &&
|
||||
appSource.includes('import("./components/HistoryPanel")'),
|
||||
"App loads secondary drawers on demand",
|
||||
);
|
||||
ok(
|
||||
settingsEntrySource.includes('import "./CompactRatioSettings.css"') &&
|
||||
settingsEntrySource.includes('from "./SettingsPanel"') &&
|
||||
!settingsSource.includes('import "./CompactRatioSettings.css"'),
|
||||
"Settings CSS stays in the lazy production entry without breaking direct module tests",
|
||||
);
|
||||
ok(
|
||||
!appSource.includes("openAllHistory") &&
|
||||
!appSource.includes("cmd-history") &&
|
||||
|
||||
@@ -244,7 +244,7 @@ const builtinGroupIndex = subagentsSource.indexOf('aria-labelledby="subagents-bu
|
||||
ok(customGroupIndex >= 0 && builtinGroupIndex > customGroupIndex, "custom subagents should render before built-in subagents");
|
||||
ok((subagentsSource.match(/className="subagents-profile-group"/g) ?? []).length === 2, "custom and built-in subagents should use separate sections");
|
||||
ok(subagentsSource.includes('className="btn btn--small subagents-reset-override"'), "override status and reset should share one compact action");
|
||||
ok(subagentsStyles.includes("repeat(2, minmax(200px, 1fr)) 152px"), "built-in subagent pickers should use equal columns and reserve one stable status column");
|
||||
ok(subagentsStyles.includes("repeat(2, minmax(0, 1fr)) 152px"), "built-in subagent pickers should use equal shrinkable columns and reserve one stable status column");
|
||||
ok(subagentsSource.includes('className="settings-model-picker subagents-effort-picker"'), "effort and model overrides should share the same picker interaction pattern");
|
||||
ok(subagentsSource.includes("<SubagentInvocation name={skill.name}"), "every subagent card should show its chat invocation affordance");
|
||||
ok(subagentsSource.includes("onUseInChat(command)"), "subagent cards should send their slash command to the chat composer");
|
||||
|
||||
@@ -138,6 +138,43 @@ async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {
|
||||
return { root, calls, rerender: paint };
|
||||
}
|
||||
|
||||
function installWindowTimerQueue() {
|
||||
let clock = 0;
|
||||
let nextId = 1;
|
||||
const tasks = new Map<number, { at: number; callback: () => void }>();
|
||||
const originalSetTimeout = window.setTimeout;
|
||||
const originalClearTimeout = window.clearTimeout;
|
||||
|
||||
window.setTimeout = ((handler: TimerHandler, delay = 0, ...args: unknown[]) => {
|
||||
if (typeof handler !== "function") throw new Error("string timers are unsupported in tests");
|
||||
const id = nextId++;
|
||||
tasks.set(id, { at: clock + Number(delay), callback: () => handler(...args) });
|
||||
return id;
|
||||
}) as typeof window.setTimeout;
|
||||
window.clearTimeout = ((id?: number) => {
|
||||
if (id !== undefined) tasks.delete(id);
|
||||
}) as typeof window.clearTimeout;
|
||||
|
||||
return {
|
||||
advance(ms: number) {
|
||||
clock += ms;
|
||||
while (true) {
|
||||
const next = [...tasks.entries()]
|
||||
.filter(([, task]) => task.at <= clock)
|
||||
.sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0];
|
||||
if (!next) break;
|
||||
tasks.delete(next[0]);
|
||||
next[1].callback();
|
||||
}
|
||||
},
|
||||
restore() {
|
||||
tasks.clear();
|
||||
window.setTimeout = originalSetTimeout;
|
||||
window.clearTimeout = originalClearTimeout;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
console.log("\ncomposer run strip");
|
||||
|
||||
// Idle: no strip, no stop button, plain send arrow.
|
||||
@@ -218,6 +255,48 @@ console.log("\ncomposer run strip");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// A short Creation hover must stay a no-op. In particular, leaving before the
|
||||
// 120ms open delay must not manufacture a closing-only popover or flash the
|
||||
// trigger's open styling 140ms later.
|
||||
{
|
||||
const dom = installDom();
|
||||
const timers = installWindowTimerQueue();
|
||||
const { root } = await renderComposer({ showContextWindowRing: true });
|
||||
|
||||
for (const selector of [".composer-task-mode-trigger", ".composer-profile-trigger"]) {
|
||||
const trigger = document.querySelector(selector) as HTMLButtonElement | null;
|
||||
if (!trigger) throw new Error(`missing Creation hover trigger: ${selector}`);
|
||||
|
||||
await act(async () => {
|
||||
trigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: null }));
|
||||
timers.advance(119);
|
||||
trigger.dispatchEvent(new MouseEvent("mouseout", { bubbles: true, relatedTarget: document.body }));
|
||||
timers.advance(140);
|
||||
});
|
||||
|
||||
ok(!trigger.classList.contains(`${selector.slice(1)}--open`), `${selector} stays visually closed after a short hover`);
|
||||
ok(
|
||||
document.querySelector(selector.includes("task") ? ".composer-intent-menu" : ".composer-profile-menu") === null,
|
||||
`${selector} does not render a closing-only menu`,
|
||||
);
|
||||
}
|
||||
|
||||
const intentTrigger = document.querySelector(".composer-task-mode-trigger") as HTMLButtonElement | null;
|
||||
if (!intentTrigger) throw new Error("missing Creation intent trigger");
|
||||
await act(async () => {
|
||||
intentTrigger.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: null }));
|
||||
timers.advance(120);
|
||||
});
|
||||
ok(intentTrigger.classList.contains("composer-task-mode-trigger--open"), "a sustained Creation hover still opens the trigger");
|
||||
ok(document.querySelector(".composer-intent-menu") !== null, "a sustained Creation hover still renders the menu");
|
||||
|
||||
timers.restore();
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Runtime controller transitions disable every mode axis and submit together,
|
||||
// so rapid Goal + Delivery + approval-mode clicks cannot mutate a half-rebuilt runtime.
|
||||
{
|
||||
|
||||
@@ -241,6 +241,7 @@ const sourceRows = contextSourceRows({
|
||||
requestCount: 2,
|
||||
sessionCost: 0.42,
|
||||
sessionCurrency: "¥",
|
||||
estimated: true,
|
||||
},
|
||||
},
|
||||
}, "¥");
|
||||
@@ -258,6 +259,8 @@ eq(
|
||||
"executor source row exposes input, output, cache hit, cache miss, and request count",
|
||||
);
|
||||
eq(sourceRows[1].requests, 1, "planner source row remains visible without cache metadata");
|
||||
eq(sourceRows[0].estimated, true, "executor source row preserves estimated usage metadata");
|
||||
eq(sourceRows[1].estimated, false, "missing estimated metadata remains exact for backward compatibility");
|
||||
eq(sourceRows[1].cacheHitTokens + sourceRows[1].cacheMissTokens, 0, "planner source preserves absent cache metadata as empty");
|
||||
|
||||
const executorOnlyRows = contextSourceRows({
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
opaqueScriptFingerprintHint,
|
||||
parseReportedPerf,
|
||||
performanceLabelForReason,
|
||||
performanceFingerprintHintForReason,
|
||||
serializeReportedPerf,
|
||||
shouldPromptForLongTasks,
|
||||
shouldPromptForEventLoopLag,
|
||||
@@ -54,7 +55,7 @@ const legacyObject = function LegacyObject() {} as unknown as ObjectConstructor
|
||||
installObjectHasOwnPolyfill(legacyObject);
|
||||
eq(legacyObject.hasOwn?.({ own: true }, "own"), true, "Object.hasOwn polyfill accepts own properties");
|
||||
eq(legacyObject.hasOwn?.(Object.create({ inherited: true }), "inherited"), false, "Object.hasOwn polyfill rejects inherited properties");
|
||||
for (const file of ["../components/VirtualMenu.tsx", "../components/WorkspacePanel.tsx", "../components/editors/HljsDiff.tsx"]) {
|
||||
for (const file of ["../components/VirtualMenu.tsx", "../components/WorkspacePanel.tsx", "../components/editors/HljsDiff.tsx", "../components/editors/LineNumberCode.tsx"]) {
|
||||
const source = readFileSync(resolve(testDir, file), "utf8");
|
||||
const fileParts = file.split("/");
|
||||
const label = fileParts[fileParts.length - 1];
|
||||
@@ -186,6 +187,14 @@ eq(formatPerformanceContext(perf).includes("long tasks: 3"), true, "formats long
|
||||
eq(perfPayload.message.includes("event loop lag 1300ms"), true, "payload message keeps lag context");
|
||||
eq(performanceLabelForReason("long task 900ms"), "performance.longtask", "labels long task pressure");
|
||||
eq(performanceLabelForReason("js heap 87% of limit"), "performance.heap", "labels heap pressure");
|
||||
eq(performanceFingerprintHintForReason("js heap 87% of limit"), "frontend.performance.heap.high", "tracks high heap pressure separately");
|
||||
eq(performanceFingerprintHintForReason("js heap 97% of limit"), "frontend.performance.heap.critical", "tracks critical heap pressure separately");
|
||||
eq(performanceFingerprintHintForReason("long task 900ms"), undefined, "does not repartition non-heap performance groups");
|
||||
eq(
|
||||
buildPerformancePayload({ ...perf, reason: "js heap 97% of limit" }).fingerprintHint,
|
||||
"frontend.performance.heap.critical",
|
||||
"adds the heap tier to the report fingerprint",
|
||||
);
|
||||
eq(shouldRecordLongTaskSample(14_000, 900, 15_000), false, "ignores startup long tasks before grace ends");
|
||||
eq(shouldRecordLongTaskSample(16_000, 40, 15_000), false, "ignores short long-task observer entries");
|
||||
eq(shouldRecordLongTaskSample(16_000, 900, 15_000), true, "records post-grace long tasks");
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Decision surfaces: ordinary approvals stay select-then-confirm while Plan
|
||||
// and Auto boundary cards use immediate buttons; no double submit.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
@@ -10,9 +11,12 @@ import { createRoot } from "react-dom/client";
|
||||
import gsap from "gsap";
|
||||
import { ApprovalModal } from "../components/ApprovalModal";
|
||||
import { ClearContextCard } from "../components/ClearContextCard";
|
||||
import { RuntimeDecisionCard } from "../components/RuntimeDecisionCard";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { WireApproval } from "../lib/types";
|
||||
|
||||
const styles = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
@@ -47,7 +51,7 @@ function flushTimers(ms = 0): Promise<void> {
|
||||
}
|
||||
|
||||
function installDom(language = "en-US") {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
const dom = new JSDOM("<!doctype html><html><head></head><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
@@ -68,6 +72,27 @@ function installDom(language = "en-US") {
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return this.classList.contains("prompt-action__desc") ? 42 : 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollHeight", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return this.classList.contains("prompt-action__desc") ? 84 : 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value() {
|
||||
this.setAttribute("data-scrolled-into-view", "true");
|
||||
},
|
||||
});
|
||||
const style = document.createElement("style");
|
||||
style.textContent = styles;
|
||||
document.head.appendChild(style);
|
||||
return dom;
|
||||
}
|
||||
|
||||
@@ -109,6 +134,14 @@ console.log("\ndecision surface");
|
||||
ok(!document.querySelector(".decision-confirm-bar__confirm"), "Plan has no redundant confirm button");
|
||||
ok(actions[2].textContent?.includes("Exit without executing"), "Plan card exposes a clear non-executing exit");
|
||||
ok(!document.body.textContent?.includes("Stop task"), "Plan card relies on the global Stop control");
|
||||
const planDescriptionToggle = document.querySelector(".prompt-action-row .prompt-action__description-toggle") as HTMLButtonElement | null;
|
||||
if (!planDescriptionToggle) throw new Error("Plan description disclosure did not render");
|
||||
await act(async () => {
|
||||
planDescriptionToggle.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(answers.length, 0, "expanding a Plan description never starts execution");
|
||||
eq(planDescriptionToggle.getAttribute("aria-expanded"), "true", "Plan disclosure announces its expanded state");
|
||||
|
||||
await act(async () => {
|
||||
actions[1].click();
|
||||
@@ -224,6 +257,39 @@ console.log("\ndecision surface");
|
||||
const actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
|
||||
eq(actions.length, 4, "ordinary tool approval has four options");
|
||||
ok(actions[0].classList.contains("prompt-action--selected"), "default selection is allow once");
|
||||
ok(Boolean(actions[0].title), "tool approval keeps the complete option description in a desktop tooltip");
|
||||
const toolDescriptionToggle = document.querySelector(".prompt-shelf__footnote .prompt-action__description-toggle") as HTMLButtonElement | null;
|
||||
if (!toolDescriptionToggle) throw new Error("tool approval description disclosure did not render");
|
||||
const toolContent = document.querySelector(".prompt-shelf__content") as HTMLElement | null;
|
||||
const toolFooter = document.querySelector(".prompt-shelf__footer") as HTMLElement | null;
|
||||
if (!toolContent || !toolFooter) throw new Error("tool approval scroll layout did not render");
|
||||
eq(window.getComputedStyle(toolContent).overflow, "auto", "tool approval uses the shared decision scroller");
|
||||
eq(toolContent.contains(toolFooter), false, "tool approval confirm footer stays visible outside the scroller");
|
||||
await act(async () => {
|
||||
toolDescriptionToggle.dispatchEvent(new window.KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(answers.length, 0, "Enter on tool disclosure never approves the default permission");
|
||||
eq(toolDescriptionToggle.getAttribute("aria-expanded"), "true", "Enter expands tool approval copy");
|
||||
await act(async () => {
|
||||
toolDescriptionToggle.dispatchEvent(new window.KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(toolDescriptionToggle.getAttribute("aria-expanded"), "false", "Enter collapses tool approval copy again");
|
||||
await act(async () => {
|
||||
toolDescriptionToggle.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(answers.length, 0, "expanding tool approval copy never submits the selected permission");
|
||||
eq(toolDescriptionToggle.getAttribute("aria-expanded"), "true", "tool approval disclosure announces its expanded state");
|
||||
|
||||
await act(async () => {
|
||||
actions[3].click();
|
||||
@@ -305,6 +371,22 @@ console.log("\ndecision surface");
|
||||
ok(guidanceTrigger.textContent?.includes("Tell Auto"), "guidance link uses plain user-facing copy");
|
||||
ok(!document.querySelector(".recovery-guidance__input"), "custom requirements editor stays collapsed by default");
|
||||
eq(actions.length, 2, "custom requirements do not become a third decision card");
|
||||
const recoveryDescriptionToggle = document.querySelector(".prompt-action-row .prompt-action__description-toggle") as HTMLButtonElement | null;
|
||||
if (!recoveryDescriptionToggle) throw new Error("recovery description disclosure did not render");
|
||||
const recoveryCard = document.querySelector(".prompt-shelf--recovery-approval .prompt-shelf__card") as HTMLElement | null;
|
||||
const recoveryContent = document.querySelector(".prompt-shelf--recovery-approval .prompt-shelf__content") as HTMLElement | null;
|
||||
const recoveryActions = document.querySelector(".prompt-shelf--recovery-approval .prompt-shelf__actions") as HTMLElement | null;
|
||||
if (!recoveryCard || !recoveryContent || !recoveryActions) throw new Error("recovery height bounds did not render");
|
||||
eq(window.getComputedStyle(recoveryCard).maxHeight, "min(82vh, 720px)", "recovery card stays bounded by the viewport");
|
||||
eq(window.getComputedStyle(recoveryCard).overflow, "hidden", "recovery card clips only at its shared scroll boundary");
|
||||
eq(window.getComputedStyle(recoveryContent).overflow, "auto", "recovery content uses one internal scroller");
|
||||
eq(window.getComputedStyle(recoveryActions).maxHeight, "none", "recovery options avoid a nested height cap");
|
||||
eq(window.getComputedStyle(recoveryActions).overflow, "visible", "recovery option text remains available in the shared scroller");
|
||||
await act(async () => {
|
||||
recoveryDescriptionToggle.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(decisions.length, 0, "expanding recovery details never resolves the pending action");
|
||||
const taskGrant = document.querySelector(".recovery-task-grant input") as HTMLInputElement;
|
||||
ok(taskGrant, "bounded recovery offers a current-task semantic grant");
|
||||
ok(!taskGrant.checked, "task grant is opt-in");
|
||||
@@ -619,6 +701,126 @@ console.log("\ndecision surface");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Runtime decisions expose direct actions, so their displayed number keys must
|
||||
// invoke the same callbacks while preserving normal typing behavior.
|
||||
{
|
||||
const dom = installDom();
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
const selected: string[] = [];
|
||||
let cancelled = 0;
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<RuntimeDecisionCard
|
||||
id="runtime-shortcuts"
|
||||
title="Choose runtime action"
|
||||
badge="Runtime"
|
||||
meta="Select an action"
|
||||
actions={["1", "2", "3"].map((key) => ({
|
||||
key,
|
||||
label: `Action ${key}`,
|
||||
description: `Run action ${key} across a deliberately long runtime boundary that must wrap without overlapping the next decision row.`,
|
||||
danger: key === "3",
|
||||
onClick: () => selected.push(key),
|
||||
}))}
|
||||
onCancel={() => { cancelled += 1; }}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
|
||||
const runtimeActions = document.querySelector(".prompt-shelf__actions") as HTMLElement | null;
|
||||
const runtimeAction = document.querySelector(".prompt-action") as HTMLElement | null;
|
||||
const runtimeActionKey = runtimeAction?.querySelector(".prompt-action__key") as HTMLElement | null;
|
||||
const runtimeActionLabel = runtimeAction?.querySelector(".prompt-action__label") as HTMLElement | null;
|
||||
const runtimeActionDescription = runtimeAction?.querySelector(".prompt-action__desc") as HTMLElement | null;
|
||||
if (!runtimeActions || !runtimeAction || !runtimeActionKey || !runtimeActionLabel || !runtimeActionDescription) {
|
||||
throw new Error("runtime decision layout did not render");
|
||||
}
|
||||
|
||||
const runtimeActionsStyle = window.getComputedStyle(runtimeActions);
|
||||
eq(runtimeActionsStyle.gridAutoRows, "max-content", "all decision rows use their full content height");
|
||||
eq(runtimeActionsStyle.alignContent, "start", "all decision rows stay aligned at the top of the scroll region");
|
||||
|
||||
const runtimeActionStyle = window.getComputedStyle(runtimeAction);
|
||||
eq(runtimeActionStyle.height, "auto", "shared decision rows are not fixed-height");
|
||||
eq(runtimeActionStyle.minHeight, "40px", "shared decision rows retain the compact product minimum");
|
||||
eq(runtimeActionStyle.alignItems, "flex-start", "shared decision copy aligns from the top");
|
||||
eq(window.getComputedStyle(runtimeActionKey).marginTop, "1px", "decision keys align visually with the label cap height");
|
||||
eq(window.getComputedStyle(runtimeActionLabel).fontWeight, "620", "decision labels keep a clear visual hierarchy");
|
||||
|
||||
const runtimeDescriptionStyle = window.getComputedStyle(runtimeActionDescription);
|
||||
eq(runtimeDescriptionStyle.whiteSpace, "normal", "shared decision descriptions can wrap");
|
||||
eq(runtimeDescriptionStyle.display, "-webkit-box", "supplementary runtime copy uses the shared three-line clamp");
|
||||
eq(runtimeDescriptionStyle.overflow, "hidden", "collapsed runtime descriptions stay inside their row");
|
||||
eq(runtimeDescriptionStyle.overflowWrap, "anywhere", "unspaced decision descriptions stay inside their row");
|
||||
eq(runtimeDescriptionStyle.lineHeight, "1.4", "wrapped decision descriptions keep readable density");
|
||||
const runtimeDescriptionToggle = document.querySelector(".prompt-action-row .prompt-action__description-toggle") as HTMLButtonElement | null;
|
||||
if (!runtimeDescriptionToggle) throw new Error("runtime description disclosure did not render");
|
||||
eq(runtimeDescriptionToggle.getAttribute("aria-expanded"), "false", "runtime description starts collapsed");
|
||||
await act(async () => {
|
||||
runtimeDescriptionToggle.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(selected.length, 0, "expanding runtime details never triggers the decision action");
|
||||
eq(runtimeDescriptionToggle.getAttribute("aria-expanded"), "true", "runtime description expansion is announced");
|
||||
eq(window.getComputedStyle(runtimeActionDescription).overflow, "visible", "expanded runtime description reveals the full copy");
|
||||
const runtimeActionButtons = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
|
||||
const dangerousRuntimeDescription = runtimeActionButtons[2]?.querySelector(".prompt-action__desc") as HTMLElement | null;
|
||||
if (!dangerousRuntimeDescription) throw new Error("dangerous runtime description did not render");
|
||||
ok(!runtimeActionButtons[2].classList.contains("prompt-action--description-collapsible"), "dangerous runtime consequences are never clamped");
|
||||
eq(window.getComputedStyle(dangerousRuntimeDescription).overflow, "visible", "dangerous runtime consequences stay fully visible");
|
||||
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "1", bubbles: true }));
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "2", bubbles: true }));
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "3", bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(selected.join(","), "1,2,3", "runtime decision number keys invoke their displayed actions");
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<RuntimeDecisionCard
|
||||
id="runtime-shortcuts"
|
||||
title="Choose runtime action"
|
||||
badge="Runtime"
|
||||
meta="Select an action"
|
||||
actions={[{
|
||||
key: "2", label: "Unavailable action", description: "Cannot run yet",
|
||||
disabled: true, onClick: () => selected.push("disabled"),
|
||||
}]}
|
||||
onCancel={() => { cancelled += 1; }}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushTimers();
|
||||
});
|
||||
await act(async () => {
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "2", bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(selected.join(","), "1,2,3", "runtime decision shortcuts do not invoke disabled actions");
|
||||
|
||||
const input = document.createElement("input");
|
||||
document.body.append(input);
|
||||
await act(async () => {
|
||||
input.dispatchEvent(new window.KeyboardEvent("keydown", { key: "1", bubbles: true }));
|
||||
document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(selected.join(","), "1,2,3", "runtime decision shortcuts ignore editable fields");
|
||||
eq(cancelled, 1, "Escape cancels the runtime decision");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
// Clear context: default cancel; clear requires explicit confirm; Escape cancels.
|
||||
{
|
||||
const dom = installDom();
|
||||
@@ -637,6 +839,34 @@ console.log("\ndecision surface");
|
||||
|
||||
const actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
|
||||
ok(actions[0].classList.contains("prompt-action--selected"), "clear context defaults to cancel");
|
||||
const clearDescriptionToggle = document.querySelector(".prompt-shelf__footnote .prompt-action__description-toggle") as HTMLButtonElement | null;
|
||||
if (!clearDescriptionToggle) throw new Error("clear-context description disclosure did not render");
|
||||
await act(async () => {
|
||||
clearDescriptionToggle.dispatchEvent(new window.KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(cancelled, 0, "Enter on clear-context disclosure does not trigger the safe default");
|
||||
eq(confirmed, 0, "Enter on clear-context disclosure never clears the conversation");
|
||||
eq(clearDescriptionToggle.getAttribute("aria-expanded"), "true", "Enter expands clear-context copy");
|
||||
await act(async () => {
|
||||
clearDescriptionToggle.dispatchEvent(new window.KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}));
|
||||
await flushTimers();
|
||||
});
|
||||
eq(clearDescriptionToggle.getAttribute("aria-expanded"), "false", "Enter collapses clear-context copy again");
|
||||
await act(async () => {
|
||||
clearDescriptionToggle.click();
|
||||
await flushTimers();
|
||||
});
|
||||
eq(cancelled, 0, "expanding clear-context copy does not trigger the safe default");
|
||||
eq(confirmed, 0, "expanding clear-context copy does not clear anything");
|
||||
|
||||
await act(async () => {
|
||||
actions[1].click();
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { formatTokens, formatOptionalTokens } from "../lib/format";
|
||||
|
||||
describe("formatTokens", () => {
|
||||
it("returns '-' for undefined, zero, and negative values", () => {
|
||||
assert.equal(formatTokens(undefined), "-");
|
||||
assert.equal(formatTokens(0), "-");
|
||||
assert.equal(formatTokens(-100), "-");
|
||||
});
|
||||
|
||||
it("formats values below threshold as plain numbers", () => {
|
||||
assert.equal(formatTokens(1), "1");
|
||||
assert.equal(formatTokens(500), "500");
|
||||
assert.equal(formatTokens(999), "999");
|
||||
});
|
||||
|
||||
it("abbreviates thousands with K suffix", () => {
|
||||
assert.equal(formatTokens(1000), "1K");
|
||||
assert.equal(formatTokens(1500), "1.5K");
|
||||
assert.equal(formatTokens(142000), "142K");
|
||||
assert.equal(formatTokens(999999), "1000K");
|
||||
});
|
||||
|
||||
it("abbreviates millions with M suffix", () => {
|
||||
assert.equal(formatTokens(1_000_000), "1M");
|
||||
assert.equal(formatTokens(1_500_000), "1.5M");
|
||||
assert.equal(formatTokens(25_000_000), "25M");
|
||||
});
|
||||
|
||||
it("strips trailing .0 for exact multiples", () => {
|
||||
assert.equal(formatTokens(1000), "1K");
|
||||
assert.equal(formatTokens(2000), "2K");
|
||||
assert.equal(formatTokens(1_000_000), "1M");
|
||||
});
|
||||
|
||||
it("keeps one decimal place when needed", () => {
|
||||
assert.equal(formatTokens(1500), "1.5K");
|
||||
assert.equal(formatTokens(234000), "234K");
|
||||
assert.equal(formatTokens(1_230_000), "1.2M");
|
||||
});
|
||||
|
||||
it("respects custom decimals option", () => {
|
||||
assert.equal(formatTokens(1500, { decimals: 2 }), "1.5K");
|
||||
assert.equal(formatTokens(1555, { decimals: 2 }), "1.55K");
|
||||
assert.equal(formatTokens(1_555_000, { decimals: 2 }), "1.55M");
|
||||
});
|
||||
|
||||
it("respects custom threshold option", () => {
|
||||
assert.equal(formatTokens(500, { threshold: 100 }), "0.5K");
|
||||
assert.equal(formatTokens(99, { threshold: 100 }), "99");
|
||||
assert.equal(formatTokens(100, { threshold: 100 }), "0.1K");
|
||||
});
|
||||
|
||||
it("returns locale-formatted string when compact is false", () => {
|
||||
const result = formatTokens(142000, { compact: false });
|
||||
// toLocaleString output varies by locale, but should contain digits
|
||||
assert.match(result, /142/);
|
||||
assert.doesNotMatch(result, /[KM]/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatOptionalTokens", () => {
|
||||
it("returns '-' for missing, zero, and negative values", () => {
|
||||
assert.equal(formatOptionalTokens(undefined), "-");
|
||||
assert.equal(formatOptionalTokens(null), "-");
|
||||
assert.equal(formatOptionalTokens(0), "-");
|
||||
assert.equal(formatOptionalTokens(-50), "-");
|
||||
});
|
||||
|
||||
it("formats positive values identically to formatTokens", () => {
|
||||
assert.equal(formatOptionalTokens(1500), "1.5K");
|
||||
assert.equal(formatOptionalTokens(1_000_000), "1M");
|
||||
assert.equal(formatOptionalTokens(500), "500");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ModelSwitcher } from "../components/ModelSwitcher";
|
||||
import { ModelSwitcher, normalizeModelInfo } from "../components/ModelSwitcher";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { ModelInfo } from "../lib/types";
|
||||
|
||||
@@ -53,6 +53,15 @@ let calls = 0;
|
||||
const picked: string[] = [];
|
||||
const pickGates: Array<ReturnType<typeof deferred<boolean>>> = [];
|
||||
let catalogLoader: (() => Promise<ModelInfo[]>) | undefined;
|
||||
const normalizedNullMetadata = normalizeModelInfo({
|
||||
ref: "provider-a/model-a",
|
||||
provider: null,
|
||||
model: null,
|
||||
current: true,
|
||||
} as unknown as ModelInfo);
|
||||
if (normalizedNullMetadata.provider !== "" || normalizedNullMetadata.model !== "") {
|
||||
throw new Error(`null model metadata was not normalized: ${JSON.stringify(normalizedNullMetadata)}`);
|
||||
}
|
||||
let currentCatalog: ModelInfo[] = [
|
||||
{ ref: "glm-cn/glm-5.2", provider: "glm-cn", model: "glm-5.2", current: true },
|
||||
];
|
||||
@@ -312,5 +321,24 @@ await act(async () => {
|
||||
await newerPickGate.promise;
|
||||
});
|
||||
|
||||
// Legacy or malformed settings can surface null display metadata despite the
|
||||
// bridge contract. Catalog loading must normalize it before grouping/search.
|
||||
currentCatalog = [
|
||||
{ ref: "provider-a/model-a", provider: null, model: null, current: true } as unknown as ModelInfo,
|
||||
{ ref: "provider-b/model-b", provider: "provider-b", model: "model-b", current: false },
|
||||
];
|
||||
await act(async () => {
|
||||
root.render(renderSwitcher("model-a", "tab-null-metadata"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
await act(async () => {
|
||||
(document.querySelector(".modelsw__trigger") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
const nullSafeOptions = Array.from(document.querySelectorAll<HTMLElement>("[role='option']"));
|
||||
if (nullSafeOptions.length !== 2) {
|
||||
throw new Error(`null model metadata prevented catalog rendering: ${nullSafeOptions.length}`);
|
||||
}
|
||||
|
||||
await act(async () => root.unmount());
|
||||
console.log("model switcher refresh: PASS");
|
||||
@@ -343,6 +343,8 @@ async function waitFor(label: string, predicate: () => boolean) {
|
||||
throw new Error(`timed out waiting for ${label}`);
|
||||
}
|
||||
|
||||
let projectedRuntimeEpoch = "runtime-local";
|
||||
|
||||
function tabMeta(): TabMeta {
|
||||
return {
|
||||
id: "tab-a",
|
||||
@@ -355,6 +357,7 @@ function tabMeta(): TabMeta {
|
||||
sessionPath: "/repo/sessions/tab-a.jsonl",
|
||||
label: "model",
|
||||
ready: true,
|
||||
runtime: { phase: "ready", epoch: projectedRuntimeEpoch },
|
||||
running: false,
|
||||
cancellable: false,
|
||||
mode: "normal",
|
||||
@@ -369,6 +372,7 @@ function metaForTab(): Meta {
|
||||
return {
|
||||
label: "model",
|
||||
ready: true,
|
||||
runtime: { phase: "ready", epoch: projectedRuntimeEpoch },
|
||||
eventChannel: "agent:event",
|
||||
cwd: "/repo",
|
||||
workspaceRoot: "/repo",
|
||||
@@ -387,7 +391,7 @@ function metaForTab(): Meta {
|
||||
const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
|
||||
const effortInfo: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
|
||||
const eventHandlers: Array<(e: WireEvent) => void> = [];
|
||||
const rebuiltHandlers: Array<(tabId?: string) => void> = [];
|
||||
const rebuiltHandlers: Array<(tabId?: string, runtimeEpoch?: string) => void> = [];
|
||||
let holdNextListTabs: Promise<void> | undefined;
|
||||
let modeDrain: ReturnType<typeof deferred<string[]>> | undefined;
|
||||
let toolApprovalModeDrain: ReturnType<typeof deferred<string[]>> | undefined;
|
||||
@@ -398,7 +402,7 @@ let rejectNextComposerProfile = false;
|
||||
window.runtime = {
|
||||
EventsOn: (name: string, cb: (payload: unknown) => void) => {
|
||||
if (name === "agent:event") eventHandlers.push(cb as (e: WireEvent) => void);
|
||||
if (name === "runtime:rebuilt") rebuiltHandlers.push(cb as (tabId?: string) => void);
|
||||
if (name === "runtime:rebuilt") rebuiltHandlers.push(cb as (tabId?: string, runtimeEpoch?: string) => void);
|
||||
return () => {};
|
||||
},
|
||||
BrowserOpenURL: () => {},
|
||||
@@ -461,6 +465,34 @@ await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
// A Local tab already has an accepted epoch before Workbench projects a
|
||||
// Remote runtime onto the same surface. The Remote transition must publish its
|
||||
// authority before tagged Host events arrive, or the frontend rejects them all
|
||||
// as stale Local traffic.
|
||||
const remoteEpochApproval = {
|
||||
kind: "approval_request",
|
||||
tabId: "tab-a",
|
||||
runtimeEpoch: "runtime-remote",
|
||||
approval: { id: "remote-epoch-1", tool: "bash", subject: "Remote epoch prompt" },
|
||||
} as WireEvent;
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) handler(remoteEpochApproval);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.approval, undefined, "a Remote event is fenced while the Local epoch is still authoritative");
|
||||
await act(async () => {
|
||||
projectedRuntimeEpoch = "runtime-remote";
|
||||
for (const handler of rebuiltHandlers) handler("tab-a", "runtime-remote");
|
||||
for (const handler of eventHandlers) handler(remoteEpochApproval);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.approval?.id, "remote-epoch-1", "the projected Remote epoch admits tagged Host events");
|
||||
await act(async () => {
|
||||
for (const handler of eventHandlers) handler({ kind: "turn_done", tabId: "tab-a", runtimeEpoch: "runtime-remote" } as WireEvent);
|
||||
await flushPromises();
|
||||
});
|
||||
eq(controller?.state.approval, undefined, "the Remote epoch regression fixture resets cleanly");
|
||||
|
||||
// A reconciliation fetch starts (its snapshot time is captured now), then the
|
||||
// backend attach replays the pending plan approval before the fetch resolves.
|
||||
const gate = deferred<void>();
|
||||
@@ -623,13 +655,23 @@ eq(controller?.state.running, false, "fresh idle snapshot releases the blocked s
|
||||
eq(controller?.state.approval?.subject, "new composer-profile prompt", "a late atomic profile drain cannot dismiss a new same-id prompt");
|
||||
|
||||
composerProfileDrain = undefined;
|
||||
const rebuiltProfileCallsBefore = composerProfileCalls;
|
||||
const falseRebuildProfileCallsBefore = composerProfileCalls;
|
||||
await act(async () => {
|
||||
await controller?.setComposerProfileForTab("tab-a", "plan", "auto", "");
|
||||
await controller?.setComposerProfileForTab("tab-a", "plan", "auto", "");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(composerProfileCalls, rebuiltProfileCallsBefore + 1, "same profile applies once per rebuilt controller generation");
|
||||
eq(composerProfileCalls, falseRebuildProfileCallsBefore, "a rebuild notice without a new runtime identity does not replay the same profile");
|
||||
|
||||
const rebuiltProfileCallsBefore = composerProfileCalls;
|
||||
await act(async () => {
|
||||
projectedRuntimeEpoch = "runtime-next";
|
||||
for (const handler of rebuiltHandlers) handler("tab-a", "runtime-next");
|
||||
await controller?.setComposerProfileForTab("tab-a", "plan", "auto", "");
|
||||
await controller?.setComposerProfileForTab("tab-a", "plan", "auto", "");
|
||||
await flushPromises();
|
||||
});
|
||||
eq(composerProfileCalls, rebuiltProfileCallsBefore + 1, "same profile applies once per actual runtime generation");
|
||||
|
||||
const concurrentProfileDrain = deferred<string[]>();
|
||||
composerProfileDrain = concurrentProfileDrain;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// Run: tsx src/__tests__/provider-editor-model-picker.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ProviderEditor, ProviderEditorModelPicker } from "../components/SettingsPanel";
|
||||
import type { ProviderView } 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 flushPromises(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
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.sessionStorage = dom.window.sessionStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
window.scrollTo = () => {};
|
||||
|
||||
function renderPicker(candidates: string[]) {
|
||||
return (
|
||||
<LocaleProvider>
|
||||
<ProviderEditorModelPicker
|
||||
candidates={candidates}
|
||||
selectedModels={[]}
|
||||
visionModels={[]}
|
||||
contextWindows={{}}
|
||||
disabled={false}
|
||||
onToggleModel={() => undefined}
|
||||
onToggleVision={() => undefined}
|
||||
onContextWindowChange={() => undefined}
|
||||
onSelectAll={() => undefined}
|
||||
onClear={() => undefined}
|
||||
/>
|
||||
</LocaleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\nprovider editor model picker");
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
|
||||
let threw = false;
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(renderPicker([]));
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
root.render(renderPicker(["zen-v1", "zen-v1-pro"]));
|
||||
await flushPromises();
|
||||
});
|
||||
} catch (error) {
|
||||
threw = true;
|
||||
process.stdout.write(` ERROR ${String(error)}\n`);
|
||||
}
|
||||
|
||||
ok(!threw, "model picker can render after async model fetch returns candidates");
|
||||
ok(rootEl.textContent?.includes("zen-v1") === true, "model picker shows fetched custom provider models");
|
||||
|
||||
const builtInProvider: ProviderView = {
|
||||
name: "deepseek",
|
||||
builtIn: true,
|
||||
added: true,
|
||||
kind: "openai",
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
models: ["deepseek-chat"],
|
||||
visionModels: [],
|
||||
visionModelsConfigured: false,
|
||||
modelsUrl: "",
|
||||
default: "deepseek-chat",
|
||||
apiKeyEnv: "",
|
||||
keySet: false,
|
||||
balanceUrl: "",
|
||||
contextWindow: 128_000,
|
||||
reasoningProtocol: "deepseek",
|
||||
thinking: "",
|
||||
supportedEfforts: [],
|
||||
defaultEffort: "",
|
||||
};
|
||||
|
||||
function renderProviderEditor(initial?: ProviderView) {
|
||||
return (
|
||||
<LocaleProvider>
|
||||
<ProviderEditor
|
||||
initial={initial}
|
||||
kinds={["openai"]}
|
||||
busy={false}
|
||||
onCancel={() => undefined}
|
||||
onSave={() => undefined}
|
||||
/>
|
||||
</LocaleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
let editorThrew = false;
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(renderProviderEditor(builtInProvider));
|
||||
await flushPromises();
|
||||
});
|
||||
await act(async () => {
|
||||
root.render(renderProviderEditor());
|
||||
await flushPromises();
|
||||
});
|
||||
} catch (error) {
|
||||
editorThrew = true;
|
||||
process.stdout.write(` ERROR ${String(error)}\n`);
|
||||
}
|
||||
|
||||
ok(!editorThrew, "provider editor can switch from built-in to custom without changing hook order");
|
||||
ok(rootEl.textContent?.includes("OpenAI-compatible") === true, "provider editor renders the custom provider fields after the switch");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -39,8 +39,8 @@ ok(!appSource.includes("OpenTabRecoveryParent"), "App does not expose recovery c
|
||||
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");
|
||||
ok(controllerSource.includes("if (quietTranscriptNoticeKey(rawText, code))"), "raw quiet notices are skipped before localization");
|
||||
ok(controllerSource.includes("if (quietTranscriptNoticeKey(text, code))"), "localized quiet notices are skipped before rendering");
|
||||
|
||||
const removedPromptKeys = [
|
||||
"recovery.open",
|
||||
|
||||
@@ -198,6 +198,8 @@ eq(readinessNotice.kind === "notice" && readinessNotice.detail, "Still needed: v
|
||||
eq(readinessNotice.kind === "notice" && readinessNotice.action, "continue_delivery", "final readiness offers a recovery action");
|
||||
const readinessUser = readinessState.items.find((it) => it.kind === "user");
|
||||
eq(readinessUser?.kind === "user" && Boolean(readinessUser.failed), false, "final readiness does not mark the delivered user message as failed");
|
||||
eq(readinessState.running, false, "an unclicked continue-check action does not keep the turn running");
|
||||
eq(readinessState.pendingPrompt, false, "an unclicked continue-check action does not create a pending prompt");
|
||||
|
||||
const recovering = reducer(readinessState, { type: "user", text: "Continue checks", seq: readinessState.seq, deliveryRecovery: true });
|
||||
const recovered = reducer(recovering, { type: "event", e: { kind: "turn_done" } as WireEvent });
|
||||
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
providerBaseURLFromChatURL,
|
||||
providerChatURLPreview,
|
||||
providerEditorEffectiveKind,
|
||||
normalizeProviderView,
|
||||
} from "../components/SettingsPanel";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { SettingsView } from "../lib/types";
|
||||
import type { ProviderView, SettingsView } from "../lib/types";
|
||||
import {
|
||||
applyTypographyPreferences,
|
||||
createDefaultTypographyPreferences,
|
||||
@@ -83,7 +84,7 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
|
||||
permissions: { mode: "ask", allow: [], ask: [], deny: [] },
|
||||
sandbox: { bash: "enforce", network: false, workspaceRoot: "", allowWrite: [], effectiveWorkspaceRoot: "/work", effectiveWriteRoots: ["/work"], shell: "auto" },
|
||||
network: { proxyMode: "auto", proxyUrl: "", noProxy: "", proxy: { type: "socks5", server: "", port: 0, username: "", password: "" } },
|
||||
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" },
|
||||
agent: { temperature: 0, maxSteps: 0, plannerMaxSteps: 0, maxSubagentDepth: 2, maxSubagentConcurrency: 6, maxParallelWriters: 3, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto", compactRatio: 0.8 },
|
||||
bot: {
|
||||
enabled: false,
|
||||
model: "",
|
||||
@@ -133,6 +134,7 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
|
||||
desktopLayoutStyle: "workbench",
|
||||
desktopTheme: "auto",
|
||||
desktopThemeStyle: "graphite",
|
||||
desktopTerminalTheme: "auto",
|
||||
closeBehavior: "background",
|
||||
displayMode,
|
||||
statusBarStyle: "text",
|
||||
@@ -151,6 +153,20 @@ function baseSettings(displayMode: "standard" | "compact" = "standard"): Setting
|
||||
|
||||
console.log("\nsettings refresh snapshot");
|
||||
|
||||
const nullableProvider = normalizeProviderView({
|
||||
name: null,
|
||||
baseUrl: null,
|
||||
} as unknown as ProviderView);
|
||||
eq(nullableProvider.name, "", "provider snapshots normalize a null name at the settings boundary");
|
||||
eq(nullableProvider.baseUrl, "", "provider snapshots normalize a null base URL at the settings boundary");
|
||||
|
||||
const glmProvider = normalizeProviderView({
|
||||
name: "custom-glm",
|
||||
baseUrl: "https://gateway.example.com/v1",
|
||||
reasoningProtocol: "glm",
|
||||
} as ProviderView);
|
||||
eq(glmProvider.reasoningProtocol, "glm", "provider snapshots preserve the explicit GLM reasoning protocol");
|
||||
|
||||
eq(providerEditorEffectiveKind(true, "anthropic", ["anthropic", "openai"]), "anthropic", "new custom providers keep the selected Anthropic-compatible kind");
|
||||
eq(providerEditorEffectiveKind(false, "anthropic", ["anthropic", "openai"]), "anthropic", "existing providers preserve their stored kind");
|
||||
eq(providerChatURLPreview("https://proxy.example.com/v1", "", false), "https://proxy.example.com/v1/chat/completions", "base URL mode previews chat completions URL");
|
||||
@@ -190,6 +206,11 @@ const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body>
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
// React's legacy input-event fallback expects these IE hooks when JSDOM does
|
||||
// not expose native input event support. The custom threshold editor focuses
|
||||
// its input on open, so keep that production behavior testable without noise.
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
|
||||
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;
|
||||
@@ -274,6 +295,160 @@ await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
// Models > Agent runtime: the compaction preference is directly visible, shows
|
||||
// the effective token threshold, and reloads the persisted Settings snapshot.
|
||||
const compactRootEl = document.createElement("div");
|
||||
document.body.appendChild(compactRootEl);
|
||||
const compactRoot = createRoot(compactRootEl);
|
||||
let compactSettings = baseSettings("standard");
|
||||
delete compactSettings.agent.compactRatio; // Old backends omit the additive field.
|
||||
compactSettings.agent.effectiveCompactRatio = 0.75;
|
||||
compactSettings.agent.compactRatioOverridden = true;
|
||||
compactSettings.agent.compactRatioRemote = true;
|
||||
compactSettings.defaultModel = "context-provider/context-model";
|
||||
compactSettings.providers = [{
|
||||
name: "context-provider",
|
||||
builtIn: false,
|
||||
added: true,
|
||||
kind: "openai",
|
||||
baseUrl: "https://context.example.com/v1",
|
||||
chatUrl: "",
|
||||
models: ["context-model"],
|
||||
visionModels: [],
|
||||
visionModelsConfigured: false,
|
||||
modelsUrl: "",
|
||||
default: "context-model",
|
||||
apiKeyEnv: "",
|
||||
keySet: false,
|
||||
requiresKey: false,
|
||||
configured: true,
|
||||
balanceUrl: "",
|
||||
contextWindow: 100_000,
|
||||
reasoningProtocol: "",
|
||||
thinking: "",
|
||||
supportedEfforts: [],
|
||||
defaultEffort: "",
|
||||
modelOverrides: [],
|
||||
}];
|
||||
let compactRatioCalls: number[] = [];
|
||||
window.go = {
|
||||
main: {
|
||||
App: {
|
||||
Settings: async () => compactSettings,
|
||||
FetchAllProviderModels: async () => ({}),
|
||||
SetCompactRatio: async (ratio: number) => {
|
||||
compactRatioCalls.push(ratio);
|
||||
compactSettings = { ...compactSettings, agent: { ...compactSettings.agent, compactRatio: ratio } };
|
||||
},
|
||||
} as Partial<AppBindings> as AppBindings,
|
||||
},
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
compactRoot.render(
|
||||
<LocaleProvider>
|
||||
<SettingsPanel initialTab="models" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
await flushPromises();
|
||||
});
|
||||
ok(compactRootEl.textContent?.includes("Advanced context management") === false, "compaction preference has no redundant advanced disclosure");
|
||||
ok(compactRootEl.textContent?.includes("Automatic compaction threshold") === true, "compaction preference is visible without expanding a disclosure");
|
||||
ok(compactRootEl.textContent?.includes("80,000 tokens") === true, "compact ratio shows the default model token threshold");
|
||||
ok(compactRootEl.textContent?.includes("Current threshold: 80% · Balanced") === true, "compact ratio summarizes the saved preset separately");
|
||||
ok(compactRootEl.textContent?.includes("effective threshold is 75%") === true, "project override shows the active effective threshold");
|
||||
ok(compactRootEl.textContent?.includes("changes only the local default") === true, "remote workbench scope is explicit");
|
||||
ok(compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') === null, "custom compact ratio editor stays hidden on the default path");
|
||||
const balancedCompactButton = compactRootEl.querySelector('button[aria-label="80% · Balanced"]') as HTMLButtonElement | null;
|
||||
if (!balancedCompactButton) throw new Error("balanced compaction preset did not render");
|
||||
ok(balancedCompactButton.getAttribute("aria-pressed") === "true", "saved compact ratio starts selected");
|
||||
const customCompactButton = Array.from(compactRootEl.querySelectorAll("button")).find((button) => button.textContent?.includes("Custom threshold…")) as HTMLButtonElement | undefined;
|
||||
if (!customCompactButton) throw new Error("custom compaction threshold option did not render");
|
||||
ok(customCompactButton.closest(".compact-ratio-presets") === null, "custom compaction is a separate disclosure rather than a preset value");
|
||||
ok(customCompactButton.hasAttribute("aria-pressed") === false, "custom disclosure does not announce a saved selection state");
|
||||
await act(async () => {
|
||||
customCompactButton.click();
|
||||
await flushPromises();
|
||||
});
|
||||
let customCompactInput = compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') as HTMLInputElement | null;
|
||||
if (!customCompactInput) throw new Error("custom compaction threshold input did not open");
|
||||
eq(customCompactInput.value, "80", "custom compaction threshold defaults older backends to 80 percent");
|
||||
ok(compactRootEl.textContent?.includes("Tool output is trimmed at 60%") === true, "custom compact ratio explains the lower guard rail");
|
||||
ok(compactRootEl.textContent?.includes("90% forces compaction") === true, "custom compact ratio explains the upper guard rail");
|
||||
ok(document.activeElement === customCompactInput, "opening the custom compact ratio moves focus to its input");
|
||||
ok(customCompactButton.getAttribute("aria-expanded") === "true", "custom compact ratio exposes its expanded state");
|
||||
ok(balancedCompactButton.getAttribute("aria-pressed") === "true", "opening custom editing preserves the saved preset selection");
|
||||
const customCompactApply = Array.from(customCompactInput.closest(".compact-ratio-custom")?.querySelectorAll("button") ?? []).find((button) => button.textContent === "Apply") as HTMLButtonElement | undefined;
|
||||
if (!customCompactApply) throw new Error("custom compaction threshold apply action did not render");
|
||||
const inputValueSetter = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")?.set;
|
||||
const setCustomCompactInput = (input: HTMLInputElement, value: string) => {
|
||||
const previous = input.value;
|
||||
inputValueSetter?.call(input, value);
|
||||
(input as HTMLInputElement & { _valueTracker?: { setValue: (next: string) => void } })._valueTracker?.setValue(previous);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
};
|
||||
await act(async () => {
|
||||
setCustomCompactInput(customCompactInput, "64");
|
||||
await flushPromises();
|
||||
});
|
||||
ok(customCompactApply.disabled, "out-of-range custom compact ratio cannot be applied");
|
||||
eq(compactRatioCalls.length, 0, "editing a custom compact ratio does not save eagerly");
|
||||
await act(async () => {
|
||||
setCustomCompactInput(customCompactInput, "75");
|
||||
await flushPromises();
|
||||
});
|
||||
ok(!customCompactApply.disabled, "valid custom compact ratio enables explicit apply");
|
||||
await act(async () => {
|
||||
customCompactApply.click();
|
||||
await flushPromises();
|
||||
});
|
||||
eq(compactRatioCalls.length, 1, "custom compact ratio mutation is invoked once after apply");
|
||||
eq(compactRatioCalls[0], 0.75, "custom compact ratio converts percentage to fraction");
|
||||
ok(compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') === null, "successful custom compact ratio apply collapses the editor");
|
||||
ok(compactRootEl.textContent?.includes("Current threshold: 75% · Custom") === true, "saved custom compact ratio is summarized independently from the disclosure");
|
||||
ok(customCompactButton.textContent?.includes("Custom threshold…") === true, "custom disclosure keeps an action label after saving");
|
||||
await act(async () => {
|
||||
customCompactButton.click();
|
||||
await flushPromises();
|
||||
});
|
||||
customCompactInput = compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') as HTMLInputElement | null;
|
||||
if (!customCompactInput) throw new Error("saved custom compaction threshold did not reopen");
|
||||
await act(async () => {
|
||||
setCustomCompactInput(customCompactInput, "74");
|
||||
customCompactInput.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
await flushPromises();
|
||||
});
|
||||
eq(compactRatioCalls.length, 1, "Escape cancels a custom compact ratio without saving");
|
||||
ok(compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') === null, "Escape collapses the custom compact ratio editor");
|
||||
await act(async () => {
|
||||
customCompactButton.click();
|
||||
await flushPromises();
|
||||
});
|
||||
customCompactInput = compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') as HTMLInputElement | null;
|
||||
if (!customCompactInput) throw new Error("custom compaction threshold did not reopen for cancel");
|
||||
const customCompactCancel = Array.from(customCompactInput.closest(".compact-ratio-custom")?.querySelectorAll("button") ?? []).find((button) => button.textContent === "Cancel") as HTMLButtonElement | undefined;
|
||||
if (!customCompactCancel) throw new Error("custom compaction threshold cancel action did not render");
|
||||
await act(async () => {
|
||||
customCompactCancel.click();
|
||||
await flushPromises();
|
||||
});
|
||||
eq(compactRatioCalls.length, 1, "Cancel closes a custom compact ratio without saving");
|
||||
ok(compactRootEl.querySelector('input[aria-label="Custom compaction threshold percentage"]') === null, "Cancel collapses the custom compact ratio editor");
|
||||
const earlierCompactButton = compactRootEl.querySelector('button[aria-label="70% · Earlier"]') as HTMLButtonElement | null;
|
||||
if (!earlierCompactButton) throw new Error("earlier compaction preset did not render");
|
||||
await act(async () => {
|
||||
earlierCompactButton.click();
|
||||
await flushPromises();
|
||||
});
|
||||
eq(compactRatioCalls.length, 2, "compact ratio preset adds one mutation");
|
||||
eq(compactRatioCalls[1], 0.7, "compact ratio preset sends the expected fraction");
|
||||
ok(earlierCompactButton.getAttribute("aria-pressed") === "true", "saved compact ratio is selected after Settings reload");
|
||||
|
||||
await act(async () => {
|
||||
compactRoot.unmount();
|
||||
});
|
||||
|
||||
const retryRootEl = document.createElement("div");
|
||||
document.body.appendChild(retryRootEl);
|
||||
const retryRoot = createRoot(retryRootEl);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Run: tsx src/__tests__/settings-responsive-layout.test.ts
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8");
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function ruleBlock(source: string, selector: string): string {
|
||||
const start = source.indexOf(`${selector} {`);
|
||||
if (start < 0) return "";
|
||||
const bodyStart = source.indexOf("{", start) + 1;
|
||||
const end = source.indexOf("}", bodyStart);
|
||||
return source.slice(bodyStart, end);
|
||||
}
|
||||
|
||||
function declaration(block: string, property: string): string | undefined {
|
||||
const match = block.match(new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`));
|
||||
return match?.[1].trim();
|
||||
}
|
||||
|
||||
console.log("\nsettings responsive layout contract");
|
||||
|
||||
const wideTail = ruleBlock(styles, ".memory-tabs-row__tail");
|
||||
eq(declaration(wideTail, "display"), "contents", "wide memory controls preserve the original row flex items");
|
||||
eq(declaration(wideTail, "flex"), undefined, "wide memory wrapper does not compete in flex sizing");
|
||||
|
||||
const settingsResponsiveStart = styles.indexOf("/* ── responsive: compact content on narrow screens");
|
||||
const narrowStart = styles.indexOf("@media (max-width: 900px)", settingsResponsiveStart);
|
||||
const narrowEnd = styles.indexOf("@media (max-width: 760px)", narrowStart);
|
||||
const narrowSettings = styles.slice(narrowStart, narrowEnd);
|
||||
const narrowTail = ruleBlock(narrowSettings, ".memory-tabs-row__tail");
|
||||
|
||||
eq(declaration(narrowTail, "display"), "flex", "narrow memory controls form a flex group");
|
||||
eq(declaration(narrowTail, "flex"), "0 1 100%", "narrow memory controls stay on their own row");
|
||||
eq(declaration(narrowTail, "min-width"), "0", "narrow memory controls may shrink without overflowing");
|
||||
eq(declaration(narrowTail, "gap"), "12px", "narrow memory controls retain their compact spacing");
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -65,17 +65,40 @@ ok(
|
||||
/case "deepseek-anthropic":\s*return t\("settings\.addProvider\.preset\.deepseekAnthropicDesc"\)/.test(settingsSource),
|
||||
"DeepSeek Anthropic preset uses a localized description",
|
||||
);
|
||||
ok(
|
||||
/case "token-rhythm":\s*return t\("settings\.addProvider\.preset\.tokenRhythmDesc"\)/.test(settingsSource) &&
|
||||
/preset\.id === "token-rhythm"\) return t\("settings\.addProvider\.preset\.tokenRhythmLabel"\)/.test(settingsSource),
|
||||
"Token Rhythm preset localizes the English and Chinese brand names",
|
||||
);
|
||||
ok(
|
||||
[enLocaleSource, zhLocaleSource, zhTWLocaleSource].every((source) =>
|
||||
source.includes('"settings.addProvider.preset.deepseekResponsesDesc"') &&
|
||||
source.includes('"settings.addProvider.preset.deepseekAnthropicDesc"'),
|
||||
source.includes('"settings.addProvider.preset.deepseekAnthropicDesc"') &&
|
||||
source.includes('"settings.addProvider.preset.tokenRhythmLabel"') &&
|
||||
source.includes('"settings.addProvider.preset.tokenRhythmDesc"'),
|
||||
),
|
||||
"DeepSeek protocol preset descriptions are present in every supported locale",
|
||||
"provider preset localization is present in every supported locale",
|
||||
);
|
||||
ok(
|
||||
enLocaleSource.includes('"settings.addProvider.preset.tokenRhythmLabel": "Token Rhythm"') &&
|
||||
zhLocaleSource.includes('"settings.addProvider.preset.tokenRhythmLabel": "基元律动"') &&
|
||||
zhTWLocaleSource.includes('"settings.addProvider.preset.tokenRhythmLabel": "基元律动"'),
|
||||
"Token Rhythm preset uses the official English and Chinese brand names",
|
||||
);
|
||||
ok(
|
||||
[enLocaleSource, zhLocaleSource, zhTWLocaleSource].every((source) =>
|
||||
source.includes('"settings.reasoningProtocol.glm"'),
|
||||
),
|
||||
"GLM reasoning protocol is localized in every supported locale",
|
||||
);
|
||||
ok(
|
||||
/mockPreset\("deepseek-anthropic",\s*"DeepSeek Anthropic"/.test(bridgeSource),
|
||||
"browser mock exposes the DeepSeek Anthropic preset",
|
||||
);
|
||||
ok(
|
||||
/mockPreset\("token-rhythm",\s*"Token Rhythm"/.test(bridgeSource),
|
||||
"browser mock exposes the Token Rhythm preset",
|
||||
);
|
||||
ok(
|
||||
/function mockProviderPresetDisplayRank\(id: string\): number \{\s*if \(id === "deepseek-responses"\) return -1;\s*if \(id === "deepseek-anthropic"\) return 0;/.test(bridgeSource),
|
||||
"browser mock ranks Responses first and Anthropic as the adjacent compatibility preset",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Run: tsx src/__tests__/statusbar-workspace.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { StatusBar } from "../components/StatusBar";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
@@ -121,7 +124,28 @@ console.log("\nstatus bar workspace");
|
||||
{
|
||||
const html = renderStatusBar({ items: ["model"] });
|
||||
ok(!html.includes("YOLO"), "status bar renders only configured status items, not mode indicators");
|
||||
ok(!html.includes("后台作业") && !html.includes("Background jobs"), "status bar omits non-configurable job indicators");
|
||||
ok(!html.includes("后台作业") && !html.includes("Background jobs"), "status bar hides the operational jobs entry while idle");
|
||||
}
|
||||
|
||||
{
|
||||
const html = renderStatusBar({
|
||||
items: ["model"],
|
||||
jobs: [{ id: "bash-1", kind: "bash", label: "run tests", status: "running", startedAt: 1 }],
|
||||
});
|
||||
ok(html.includes("Background jobs"), "running background jobs remain visible outside configurable metrics");
|
||||
ok(html.includes("1"), "background jobs entry exposes the running count");
|
||||
}
|
||||
|
||||
{
|
||||
const html = renderStatusBar({
|
||||
items: ["model"],
|
||||
backgroundRuntimes: [{
|
||||
tabId: "running-1", title: "Detached delivery", detached: true,
|
||||
running: true, pendingPrompt: false, jobs: [],
|
||||
}],
|
||||
});
|
||||
ok(html.includes("Background jobs"), "a running detached task remains visible without child jobs");
|
||||
ok(html.includes("<b>1</b>"), "a jobless active runtime contributes to the recovery count");
|
||||
}
|
||||
|
||||
{
|
||||
@@ -129,5 +153,170 @@ console.log("\nstatus bar workspace");
|
||||
ok(!defaultItems.includes("autoresearch"), "autoresearch is not a configurable status bar UI item");
|
||||
}
|
||||
|
||||
{
|
||||
const estimated = renderStatusBar({
|
||||
items: ["session_tokens", "turn_tokens", "turn_cost", "cost"],
|
||||
context: { used: 0, window: 0, sessionTokens: 1_200, estimated: true },
|
||||
usage: {
|
||||
promptTokens: 800,
|
||||
completionTokens: 200,
|
||||
totalTokens: 1_000,
|
||||
cacheHitTokens: 0,
|
||||
cacheMissTokens: 800,
|
||||
estimated: true,
|
||||
},
|
||||
sessionTokens: 1_200,
|
||||
turnTokens: 1_000,
|
||||
turnCost: 0.2,
|
||||
cost: 0.3,
|
||||
currency: "USD",
|
||||
});
|
||||
ok((estimated.match(/≈/g) ?? []).length === 4, "estimated token and cost metrics use an approximation marker");
|
||||
|
||||
const empty = renderStatusBar({
|
||||
items: ["session_tokens", "turn_tokens", "turn_cost", "cost"],
|
||||
context: { used: 0, window: 0, sessionTokens: 0, estimated: true },
|
||||
usage: {
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
totalTokens: 0,
|
||||
cacheHitTokens: 0,
|
||||
cacheMissTokens: 0,
|
||||
estimated: true,
|
||||
},
|
||||
currency: "USD",
|
||||
});
|
||||
ok(!empty.includes("≈-"), "empty estimated metrics remain a plain dash");
|
||||
}
|
||||
|
||||
{
|
||||
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;
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
|
||||
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);
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: () => ({ matches: true, addEventListener() {}, removeEventListener() {} }),
|
||||
});
|
||||
|
||||
let stopped = "";
|
||||
const rootEl = document.getElementById("root")!;
|
||||
const root = createRoot(rootEl);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<StatusBar
|
||||
context={{ used: 0, window: 0, sessionTokens: 0 }}
|
||||
running={false}
|
||||
jobs={[{ id: "bash-1", kind: "bash", label: "run tests", status: "running", startedAt: 1 }]}
|
||||
onCancelJob={async (jobID) => { stopped = jobID; return true; }}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
});
|
||||
const jobsButton = rootEl.querySelector<HTMLButtonElement>(".statusbar__jobs-trigger");
|
||||
await act(async () => { jobsButton?.click(); });
|
||||
const stopButton = document.body.querySelector<HTMLButtonElement>(".jobs-popover__stop");
|
||||
await act(async () => { stopButton?.click(); await Promise.resolve(); });
|
||||
ok(stopped === "bash-1", "background jobs popover routes Stop to the selected job");
|
||||
|
||||
let routed = "";
|
||||
let revealed = "";
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<StatusBar
|
||||
context={{ used: 0, window: 0, sessionTokens: 0 }}
|
||||
running={false}
|
||||
backgroundRuntimes={[
|
||||
{
|
||||
tabId: "detached-1", title: "Detached delivery", detached: true,
|
||||
running: false, pendingPrompt: false,
|
||||
jobs: [{ id: "go-1", kind: "go", label: "go test", status: "running", startedAt: 1 }],
|
||||
},
|
||||
]}
|
||||
onCancelRuntimeJob={async (tabID, jobID) => { routed = `${tabID}:${jobID}`; return true; }}
|
||||
onRevealRuntime={async (tabID) => { revealed = tabID; }}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
});
|
||||
const globalJobsButton = rootEl.querySelector<HTMLButtonElement>(".statusbar__jobs-trigger");
|
||||
if (globalJobsButton?.getAttribute("aria-expanded") !== "true") {
|
||||
await act(async () => { globalJobsButton?.click(); });
|
||||
}
|
||||
const globalStop = document.body.querySelector<HTMLButtonElement>(".jobs-popover__stop");
|
||||
const openTask = Array.from(document.body.querySelectorAll<HTMLButtonElement>(".jobs-popover__runtime-header button"))[0];
|
||||
await act(async () => { globalStop?.click(); openTask?.click(); await Promise.resolve(); });
|
||||
ok(routed === "detached-1:go-1", "global jobs route Stop to the owning detached task");
|
||||
ok(revealed === "detached-1", "global jobs can reopen the exact detached task");
|
||||
|
||||
revealed = "";
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<StatusBar
|
||||
context={{ used: 0, window: 0, sessionTokens: 0 }}
|
||||
running={false}
|
||||
backgroundRuntimes={[
|
||||
{
|
||||
tabId: "prompt-1", title: "Waiting delivery", detached: true,
|
||||
running: false, pendingPrompt: true, jobs: [],
|
||||
},
|
||||
]}
|
||||
onRevealRuntime={async (tabID) => { revealed = tabID; }}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
});
|
||||
const promptJobsButton = rootEl.querySelector<HTMLButtonElement>(".statusbar__jobs-trigger");
|
||||
if (promptJobsButton?.getAttribute("aria-expanded") !== "true") {
|
||||
await act(async () => { promptJobsButton?.click(); });
|
||||
}
|
||||
ok(document.body.textContent?.includes("Waiting for input") === true, "pending-prompt runtime explains why it remains active");
|
||||
const promptOpenTask = document.body.querySelector<HTMLButtonElement>(".jobs-popover__runtime-header button");
|
||||
await act(async () => { promptOpenTask?.click(); await Promise.resolve(); });
|
||||
ok(revealed === "prompt-1", "a pending-prompt runtime can be reopened without child jobs");
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<LocaleProvider>
|
||||
<StatusBar
|
||||
context={{ used: 0, window: 0, sessionTokens: 0 }}
|
||||
running={false}
|
||||
workbenchTarget={{ kind: "ssh", hostId: "remote-1" }}
|
||||
jobs={[{ id: "remote-job", kind: "bash", label: "remote build", status: "running", startedAt: 1 }]}
|
||||
backgroundRuntimes={[
|
||||
{
|
||||
tabId: "local-1", title: "Local delivery", detached: true,
|
||||
running: false, pendingPrompt: false,
|
||||
jobs: [{ id: "local-job", kind: "go", label: "local test", status: "running", startedAt: 1 }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</LocaleProvider>,
|
||||
);
|
||||
});
|
||||
const mixedJobsButton = rootEl.querySelector<HTMLButtonElement>(".statusbar__jobs-trigger");
|
||||
ok(mixedJobsButton?.textContent?.includes("2") === true, "local and active Remote Workbench jobs share the total count");
|
||||
if (mixedJobsButton?.getAttribute("aria-expanded") !== "true") {
|
||||
await act(async () => { mixedJobsButton?.click(); });
|
||||
}
|
||||
ok(document.body.textContent?.includes("local test") === true, "local background jobs remain visible in Remote Workbench");
|
||||
ok(document.body.textContent?.includes("remote build") === true, "active Remote Workbench jobs remain visible beside local jobs");
|
||||
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,213 @@
|
||||
// Run: tsx src/__tests__/terminal-theme.test.ts
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><head></head><body><div id='terminal'></div></body></html>");
|
||||
Object.assign(globalThis, {
|
||||
window: dom.window,
|
||||
document: dom.window.document,
|
||||
MutationObserver: dom.window.MutationObserver,
|
||||
getComputedStyle: dom.window.getComputedStyle.bind(dom.window),
|
||||
});
|
||||
Object.defineProperty(dom.window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
matches: false,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}),
|
||||
});
|
||||
|
||||
const { applyTheme } = await import("../lib/theme");
|
||||
const {
|
||||
applyTerminalThemePreference,
|
||||
createTerminalThemeSaveQueue,
|
||||
getResolvedTerminalTheme,
|
||||
normalizeTerminalThemePreference,
|
||||
onTerminalThemePreferenceChange,
|
||||
terminalThemeForElement,
|
||||
} = await import("../lib/terminalTheme");
|
||||
|
||||
assert.equal(normalizeTerminalThemePreference("unknown"), "auto");
|
||||
assert.equal(normalizeTerminalThemePreference(undefined), "auto", "older settings payloads fall back safely");
|
||||
assert.equal(normalizeTerminalThemePreference("light"), "light");
|
||||
|
||||
applyTheme("light", "graphite");
|
||||
applyTerminalThemePreference("auto");
|
||||
assert.equal(getResolvedTerminalTheme(), "light", "follow-app resolves the current app theme");
|
||||
assert.equal(document.documentElement.hasAttribute("data-terminal-theme"), false);
|
||||
|
||||
applyTerminalThemePreference("dark");
|
||||
assert.equal(getResolvedTerminalTheme(), "dark", "explicit terminal theme overrides the app theme");
|
||||
assert.equal(document.documentElement.getAttribute("data-terminal-theme"), "dark");
|
||||
|
||||
let notifications = 0;
|
||||
const unsubscribe = onTerminalThemePreferenceChange(() => { notifications += 1; });
|
||||
applyTerminalThemePreference("light");
|
||||
unsubscribe();
|
||||
assert.equal(notifications, 1, "open terminals are notified when the preference changes");
|
||||
|
||||
const host = document.getElementById("terminal")!;
|
||||
host.style.setProperty("--terminal-bg", "#fafafa");
|
||||
host.style.setProperty("--terminal-fg", "#202124");
|
||||
host.style.setProperty("--terminal-cursor", "#9a4f00");
|
||||
const xtermTheme = terminalThemeForElement(host);
|
||||
assert.equal(xtermTheme.background, "#fafafa");
|
||||
assert.equal(xtermTheme.foreground, "#202124");
|
||||
assert.equal(xtermTheme.cursor, "#9a4f00");
|
||||
assert.equal(xtermTheme.black, "#25272a", "light terminal uses a contrast-safe ANSI palette");
|
||||
|
||||
function relativeLuminance(hex: string): number {
|
||||
assert.match(hex, /^#[0-9a-f]{6}$/i);
|
||||
const channels = [1, 3, 5].map((start) => Number.parseInt(hex.slice(start, start + 2), 16) / 255);
|
||||
const linear = channels.map((channel) => channel <= 0.04045
|
||||
? channel / 12.92
|
||||
: ((channel + 0.055) / 1.055) ** 2.4);
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
|
||||
}
|
||||
|
||||
function contrastRatio(foreground: string, background: string): number {
|
||||
const [lighter, darker] = [relativeLuminance(foreground), relativeLuminance(background)]
|
||||
.sort((a, b) => b - a);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
const ansiKeys = [
|
||||
"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white",
|
||||
"brightBlack", "brightRed", "brightGreen", "brightYellow", "brightBlue",
|
||||
"brightMagenta", "brightCyan", "brightWhite",
|
||||
] as const;
|
||||
for (const key of ansiKeys) {
|
||||
const color = xtermTheme[key];
|
||||
assert.equal(typeof color, "string");
|
||||
assert.ok(
|
||||
contrastRatio(color!, xtermTheme.background!) >= 4.5,
|
||||
`${key} must remain readable against the light terminal background`,
|
||||
);
|
||||
}
|
||||
|
||||
let releaseFirstSave!: () => void;
|
||||
const firstSaveGate = new Promise<void>((resolve) => { releaseFirstSave = resolve; });
|
||||
const saveOrder: string[] = [];
|
||||
const saveTerminalTheme = createTerminalThemeSaveQueue(async (theme) => {
|
||||
saveOrder.push(`start:${theme}`);
|
||||
if (theme === "light") await firstSaveGate;
|
||||
saveOrder.push(`end:${theme}`);
|
||||
});
|
||||
const firstSave = saveTerminalTheme("light");
|
||||
await Promise.resolve();
|
||||
const secondSave = saveTerminalTheme("dark");
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(saveOrder, ["start:light"], "newer intent waits for the active Wails save");
|
||||
releaseFirstSave();
|
||||
await Promise.all([firstSave, secondSave]);
|
||||
assert.deepEqual(saveOrder, ["start:light", "end:light", "start:dark", "end:dark"]);
|
||||
|
||||
const recoveryOrder: string[] = [];
|
||||
const recoverAfterFailure = createTerminalThemeSaveQueue(async (theme) => {
|
||||
recoveryOrder.push(theme);
|
||||
if (theme === "light") throw new Error("simulated save failure");
|
||||
});
|
||||
const failedSave = recoverAfterFailure("light");
|
||||
const recoveredSave = recoverAfterFailure("dark");
|
||||
await assert.rejects(failedSave, /simulated save failure/);
|
||||
await recoveredSave;
|
||||
assert.deepEqual(recoveryOrder, ["light", "dark"], "a failed save does not poison newer intent");
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const terminalSource = readFileSync(resolve(testDir, "../components/TerminalView.tsx"), "utf8");
|
||||
const settingsSource = readFileSync(resolve(testDir, "../components/SettingsPanel.tsx"), "utf8");
|
||||
const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8");
|
||||
|
||||
function cssRuleDeclarations(css: string, selector: string): Record<string, string> {
|
||||
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
|
||||
assert.ok(match, `missing CSS rule for ${selector}`);
|
||||
return Object.fromEntries(
|
||||
match[1]
|
||||
.split(";")
|
||||
.map((declaration) => declaration.trim())
|
||||
.filter(Boolean)
|
||||
.map((declaration) => {
|
||||
const colon = declaration.indexOf(":");
|
||||
assert.ok(colon > 0, `invalid declaration in ${selector}: ${declaration}`);
|
||||
return [declaration.slice(0, colon).trim(), declaration.slice(colon + 1).trim()];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function assertCssRule(
|
||||
css: string,
|
||||
selector: string,
|
||||
expected: Record<string, string>,
|
||||
): void {
|
||||
const actual = cssRuleDeclarations(css, selector);
|
||||
for (const [property, value] of Object.entries(expected)) {
|
||||
assert.equal(actual[property], value, `${selector} must set ${property} from terminal-owned tokens`);
|
||||
}
|
||||
}
|
||||
|
||||
const terminalStylesStart = stylesSource.indexOf("/* Integrated terminal */");
|
||||
const terminalStylesEnd = stylesSource.indexOf("@media (max-width: 760px)", terminalStylesStart);
|
||||
assert.ok(terminalStylesStart >= 0 && terminalStylesEnd > terminalStylesStart, "integrated terminal CSS section must exist");
|
||||
const terminalStyles = stylesSource.slice(terminalStylesStart, terminalStylesEnd);
|
||||
|
||||
assert.ok(terminalSource.includes("terminal.options.theme = terminalThemeForElement(host)"));
|
||||
assert.ok(!terminalSource.includes('background: "#111315"'));
|
||||
assert.ok(settingsSource.includes("createTerminalThemeSaveQueue"));
|
||||
assert.ok(settingsSource.includes("if (!terminalThemeSavePending.current)"));
|
||||
assert.ok(settingsSource.includes("onTerminalTheme={setTerminalThemePreference}"));
|
||||
assert.ok(stylesSource.includes(':root[data-terminal-theme="light"]'));
|
||||
assert.ok(stylesSource.includes(':root[data-terminal-theme="dark"]'));
|
||||
assert.doesNotMatch(terminalStyles, /var\(--(?:bg-elev-1|fg-muted)\)/, "auto mode must use defined app tokens");
|
||||
|
||||
assertCssRule(terminalStyles, ":root", {
|
||||
"--terminal-surface": "var(--bg-elev)",
|
||||
"--terminal-muted": "var(--fg-dim)",
|
||||
"--terminal-cursor": "var(--accent)",
|
||||
});
|
||||
assertCssRule(terminalStyles, ':root[data-terminal-theme="dark"] .terminal-panel', { "color-scheme": "dark" });
|
||||
assertCssRule(terminalStyles, ':root[data-terminal-theme="light"] .terminal-panel', { "color-scheme": "light" });
|
||||
|
||||
const terminalChromeMatrix: Array<[string, Record<string, string>]> = [
|
||||
[".terminal-panel__header", {
|
||||
"border-bottom": "1px solid var(--terminal-border)",
|
||||
background: "var(--terminal-surface)",
|
||||
}],
|
||||
[".terminal-panel__identity span", { color: "var(--terminal-muted)" }],
|
||||
[".terminal-shell-select", {
|
||||
border: "1px solid var(--terminal-border)",
|
||||
background: "var(--terminal-active)",
|
||||
color: "var(--terminal-fg)",
|
||||
}],
|
||||
[".terminal-shell-select:hover, .terminal-shell-select:focus-visible", {
|
||||
"border-color": "var(--terminal-cursor)",
|
||||
}],
|
||||
[".terminal-icon-button", { color: "var(--terminal-muted)" }],
|
||||
[".terminal-icon-button:hover, .terminal-icon-button:focus-visible", {
|
||||
"border-color": "var(--terminal-border)",
|
||||
background: "var(--terminal-active)",
|
||||
color: "var(--terminal-fg)",
|
||||
}],
|
||||
[".terminal-session--active", {
|
||||
"border-bottom-color": "var(--terminal-cursor)",
|
||||
background: "var(--terminal-active)",
|
||||
}],
|
||||
[".terminal-empty__spinner", { "border-top-color": "var(--terminal-cursor)" }],
|
||||
];
|
||||
for (const [selector, expected] of terminalChromeMatrix) {
|
||||
assertCssRule(terminalStyles, selector, expected);
|
||||
}
|
||||
assertCssRule(stylesSource, ".layout--terminal-drawer-expanded .terminal-drawer", {
|
||||
background: "var(--terminal-bg, #111315)",
|
||||
"border-top": "1px solid var(--terminal-border)",
|
||||
});
|
||||
assertCssRule(stylesSource, ".layout--terminal-drawer-open .terminal-drawer .terminal-panel__header", {
|
||||
"border-bottom": "1px solid var(--terminal-border)",
|
||||
});
|
||||
|
||||
console.log("terminal theme tests passed");
|
||||
@@ -30,7 +30,6 @@ const experience: ThemeExperienceView = {
|
||||
baseStyle: "graphite",
|
||||
effectiveStyle: "graphite",
|
||||
activePack: null,
|
||||
safeMode: false,
|
||||
};
|
||||
|
||||
const savedPack: ThemePackView = {
|
||||
|
||||
@@ -20,9 +20,16 @@ import {
|
||||
setBaseAppearance,
|
||||
themePackKind,
|
||||
} from "../lib/themePack";
|
||||
import { applyTheme, getThemeStyle } from "../lib/theme";
|
||||
import { applyTheme, getThemeStyle, THEME_STYLES } from "../lib/theme";
|
||||
import {
|
||||
baseCodeReadabilityStylesheet,
|
||||
codeReadabilityRatios,
|
||||
contrastRatio,
|
||||
deriveCreationCodeReadabilityPalette,
|
||||
deriveCodeReadabilityPalette,
|
||||
} from "../lib/codeReadability";
|
||||
import { BASE_STYLE_PREVIEW_PALETTES, themePreviewPalette } from "../lib/themePreviewPalette";
|
||||
import { themePreviewPaneAlpha } from "../components/ThemePreviewSurface";
|
||||
import { themePreviewCodePalette, themePreviewPaneAlpha } from "../components/ThemePreviewSurface";
|
||||
import {
|
||||
activateThemePack,
|
||||
applyExperienceToDOM,
|
||||
@@ -38,6 +45,7 @@ const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8");
|
||||
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
|
||||
const librarySource = readFileSync(resolve(testDir, "../components/ThemeLibrary.tsx"), "utf8");
|
||||
const gallerySource = readFileSync(resolve(testDir, "../components/ThemeGallery.tsx"), "utf8");
|
||||
const previewSurfaceSource = readFileSync(resolve(testDir, "../components/ThemePreviewSurface.tsx"), "utf8");
|
||||
const confirmDialogSource = readFileSync(resolve(testDir, "../components/ConfirmDialog.tsx"), "utf8");
|
||||
const overviewSource = readFileSync(resolve(testDir, "../components/AppearanceOverview.tsx"), "utf8");
|
||||
const settingsSource = readFileSync(resolve(testDir, "../components/SettingsPanel.tsx"), "utf8");
|
||||
@@ -64,22 +72,49 @@ function ok(value: boolean, label: string) {
|
||||
// Minimal DOM for applyThemePack
|
||||
const attrs = new Map<string, string>();
|
||||
const styleProps = new Map<string, string>();
|
||||
const styleEl: {
|
||||
type MockHead = {
|
||||
appendChild: (el: MockStyleElement) => MockStyleElement;
|
||||
removeChild: (el: MockStyleElement) => void;
|
||||
};
|
||||
|
||||
type MockStyleElement = {
|
||||
id: string;
|
||||
textContent: string;
|
||||
parentElement: { removeChild: (el: unknown) => void } | null;
|
||||
parentElement: MockHead | null;
|
||||
remove: () => void;
|
||||
} = {
|
||||
id: "",
|
||||
textContent: "",
|
||||
parentElement: null,
|
||||
remove() {
|
||||
const idx = headChildren.indexOf(styleEl);
|
||||
};
|
||||
const headChildren: MockStyleElement[] = [];
|
||||
const mockHead: MockHead = {
|
||||
appendChild(el: MockStyleElement) {
|
||||
const existing = headChildren.indexOf(el);
|
||||
if (existing >= 0) headChildren.splice(existing, 1);
|
||||
headChildren.push(el);
|
||||
el.parentElement = mockHead;
|
||||
return el;
|
||||
},
|
||||
removeChild(el: MockStyleElement) {
|
||||
const idx = headChildren.indexOf(el);
|
||||
if (idx >= 0) headChildren.splice(idx, 1);
|
||||
styleEl.textContent = "";
|
||||
el.parentElement = null;
|
||||
},
|
||||
};
|
||||
const headChildren: unknown[] = [];
|
||||
|
||||
function createMockStyleElement(): MockStyleElement {
|
||||
const el: MockStyleElement = {
|
||||
id: "",
|
||||
textContent: "",
|
||||
parentElement: null,
|
||||
remove() {
|
||||
mockHead.removeChild(el);
|
||||
el.textContent = "";
|
||||
},
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
function styleText(id: string): string {
|
||||
return headChildren.find((el) => el.id === id)?.textContent || "";
|
||||
}
|
||||
|
||||
(globalThis as unknown as { document: unknown }).document = {
|
||||
documentElement: {
|
||||
@@ -98,24 +133,12 @@ const headChildren: unknown[] = [];
|
||||
},
|
||||
},
|
||||
},
|
||||
head: {
|
||||
appendChild(el: unknown) {
|
||||
headChildren.push(el);
|
||||
styleEl.parentElement = {
|
||||
removeChild(child: unknown) {
|
||||
const idx = headChildren.indexOf(child);
|
||||
if (idx >= 0) headChildren.splice(idx, 1);
|
||||
},
|
||||
};
|
||||
return el;
|
||||
},
|
||||
},
|
||||
head: mockHead,
|
||||
getElementById(id: string) {
|
||||
if (id === "reasonix-theme-pack-overlay" && headChildren.includes(styleEl)) return styleEl;
|
||||
return null;
|
||||
return headChildren.find((el) => el.id === id) || null;
|
||||
},
|
||||
createElement(tag: string) {
|
||||
if (tag === "style") return styleEl;
|
||||
if (tag === "style") return createMockStyleElement();
|
||||
return {};
|
||||
},
|
||||
querySelector() {
|
||||
@@ -137,6 +160,88 @@ ok(!isSafeHex("url(x)"), "rejects url()");
|
||||
ok(!isSafeHex("linear-gradient(red,blue)"), "rejects gradient");
|
||||
ok(isThemeTokenKey("accent") && !isThemeTokenKey("hack"), "token whitelist");
|
||||
|
||||
const translucentCodePalette = deriveCodeReadabilityPalette("dark", "graphite", {
|
||||
bg: "#102030",
|
||||
bgSoft: "#ffffff33",
|
||||
fg: "#f4f5f7",
|
||||
borderSoft: "#ffffff22",
|
||||
});
|
||||
ok(/^#[0-9a-f]{6}$/.test(translucentCodePalette.background), "code background is flattened to an opaque color");
|
||||
ok(translucentCodePalette.background === "#404d59", "transparent bgSoft is composited over the theme background");
|
||||
ok(
|
||||
Object.values(codeReadabilityRatios(translucentCodePalette)).every((ratio) => ratio >= 4.5),
|
||||
"every code and diff text role reaches WCAG AA on its rendered background",
|
||||
);
|
||||
ok(
|
||||
/^#[0-9a-f]{6}$/.test(translucentCodePalette.additionBackground) &&
|
||||
/^#[0-9a-f]{6}$/.test(translucentCodePalette.deletionBackground),
|
||||
"diff row backgrounds are pre-composited opaque colors",
|
||||
);
|
||||
ok(
|
||||
contrastRatio(translucentCodePalette.addition, translucentCodePalette.additionBackground) >= 4.5 &&
|
||||
contrastRatio(translucentCodePalette.deletion, translucentCodePalette.deletionBackground) >= 4.5,
|
||||
"diff semantic text reaches WCAG AA on the final tinted row",
|
||||
);
|
||||
const adversarialMidtonePalette = deriveCodeReadabilityPalette("dark", "graphite", {
|
||||
bg: "#3fcd1c",
|
||||
bgSoft: "#cd1ce4",
|
||||
fg: "#3fcd1c",
|
||||
ok: "#15803d",
|
||||
err: "#dc2626",
|
||||
});
|
||||
ok(
|
||||
Object.values(codeReadabilityRatios(adversarialMidtonePalette)).every((ratio) => ratio >= 4.5),
|
||||
"midtone custom themes keep every syntax role readable on plain and tinted diff rows",
|
||||
);
|
||||
let generatedPaletteMinimum = Number.POSITIVE_INFINITY;
|
||||
for (let index = 0; index < 512; index += 1) {
|
||||
const sample = ((index * 2654435761) >>> 0).toString(16).padStart(8, "0");
|
||||
const palette = deriveCodeReadabilityPalette(index % 2 === 0 ? "dark" : "light", "graphite", {
|
||||
bg: `#${sample.slice(0, 6)}`,
|
||||
bgSoft: `#${sample.slice(2, 8)}`,
|
||||
fg: `#${sample.slice(0, 6)}`,
|
||||
ok: `#${sample.slice(1, 7)}`,
|
||||
err: `#${sample.slice(2, 8)}`,
|
||||
});
|
||||
generatedPaletteMinimum = Math.min(generatedPaletteMinimum, ...Object.values(codeReadabilityRatios(palette)));
|
||||
}
|
||||
ok(generatedPaletteMinimum >= 4.5, "generated custom palettes preserve WCAG AA across every rendered code surface");
|
||||
const invertedDarkPack = deriveCodeReadabilityPalette("dark", "graphite", { bg: "#ffffff", bgSoft: "#fafafa" });
|
||||
ok(invertedDarkPack.string === "#0a3069", "syntax direction follows final code luminance instead of global dark mode");
|
||||
|
||||
const baseReadabilityCSS = baseCodeReadabilityStylesheet(THEME_STYLES);
|
||||
for (const style of THEME_STYLES) {
|
||||
for (const mode of ["dark", "light"] as const) {
|
||||
const palette = deriveCodeReadabilityPalette(mode, style);
|
||||
const basePack = draftPackView({
|
||||
id: `base-${style}`,
|
||||
name: style,
|
||||
baseStyle: style,
|
||||
tokens: {},
|
||||
recipes: { density: "comfortable", corners: "soft" },
|
||||
});
|
||||
ok(
|
||||
Object.values(codeReadabilityRatios(palette)).every((ratio) => ratio >= 4.5),
|
||||
`${style} ${mode} base code palette reaches WCAG AA`,
|
||||
);
|
||||
ok(
|
||||
JSON.stringify(themePreviewCodePalette(basePack, mode)) === JSON.stringify(palette),
|
||||
`${style} ${mode} preview uses the live code palette`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const mode of ["dark", "light"] as const) {
|
||||
ok(
|
||||
Object.values(codeReadabilityRatios(deriveCreationCodeReadabilityPalette(mode))).every((ratio) => ratio >= 4.5),
|
||||
`Creation ${mode} code palette reaches WCAG AA`,
|
||||
);
|
||||
}
|
||||
ok(
|
||||
baseReadabilityCSS.includes(':root[data-theme-style="graphite"]') &&
|
||||
baseReadabilityCSS.includes('.app--creation{--code-bg:'),
|
||||
"base stylesheet installs complete root and Creation code palettes",
|
||||
);
|
||||
|
||||
ok(isSafeBackgroundURL("/__reasonix_theme_asset/my-theme/abc/background.png"), "asset URL allowed");
|
||||
ok(isSafeBackgroundURL("data:image/png;base64,aaa"), "data URL allowed");
|
||||
ok(!isSafeBackgroundURL("https://evil.example/bg.png"), "remote URL rejected");
|
||||
@@ -177,8 +282,10 @@ applyThemePack(draft);
|
||||
ok(attrs.get("data-theme-pack") === "preview-pack", "sets data-theme-pack");
|
||||
ok(attrs.get("data-theme-has-bg") === "true", "marks background present");
|
||||
ok(styleProps.has("--theme-bg-image"), "sets background image var");
|
||||
ok((styleEl as { textContent: string }).textContent.includes("--accent:#ff0000"), "injects dark accent override");
|
||||
ok((styleEl as { textContent: string }).textContent.includes("--r:14px"), "applies round corners recipe");
|
||||
ok(styleText("reasonix-theme-pack-overlay").includes("--accent:#ff0000"), "injects dark accent override");
|
||||
ok(styleText("reasonix-theme-pack-overlay").includes("--code-bg:#101115"), "injects an opaque code readability island");
|
||||
ok(styleText("reasonix-theme-pack-overlay").includes("--hl-comment:"), "injects contrast-checked syntax roles");
|
||||
ok(styleText("reasonix-theme-pack-overlay").includes("--r:14px"), "applies round corners recipe");
|
||||
|
||||
const twoSceneDraft = draftPackView({
|
||||
...draft,
|
||||
@@ -190,6 +297,12 @@ ok(styleProps.get("--theme-bg-task-image")?.includes("background-task.png") ===
|
||||
ok(styleProps.get("--theme-bg-task-opacity") === "0.35", "sets independent task opacity");
|
||||
ok(styleProps.get("--theme-pane-card-pct") === "76%", "computes home card pane opacity");
|
||||
ok(styleProps.get("--theme-pane-task-card-pct") === "82%", "computes task card pane opacity");
|
||||
ok(styleProps.get("--theme-pane-session-hover-pct") === "76%", "computes home session-hover opacity tier");
|
||||
ok(styleProps.get("--theme-pane-child-pct") === "80%", "computes home child opacity tier");
|
||||
ok(styleProps.get("--theme-pane-interact-pct") === "90%", "computes home interaction opacity tier");
|
||||
ok(styleProps.get("--theme-pane-task-session-hover-pct") === "94%", "computes task session-hover opacity tier");
|
||||
ok(styleProps.get("--theme-pane-task-child-pct") === "98%", "computes task child opacity tier");
|
||||
ok(styleProps.get("--theme-pane-task-interact-pct") === "100%", "caps task interaction opacity tier");
|
||||
ok(attrs.get("data-theme-safe-area") === "right", "task background controls safe area");
|
||||
|
||||
// Older shells and partial mocks can expose the independent task scene without
|
||||
@@ -211,6 +324,18 @@ ok(attrs.get("data-theme-scene") === "home", "scene home on root");
|
||||
|
||||
// Preview cancel restores previous (null) pack
|
||||
clearThemePack();
|
||||
ok(
|
||||
[
|
||||
"--theme-pane-session-hover-pct",
|
||||
"--theme-pane-child-pct",
|
||||
"--theme-pane-interact-pct",
|
||||
"--theme-pane-task-session-hover-pct",
|
||||
"--theme-pane-task-child-pct",
|
||||
"--theme-pane-task-interact-pct",
|
||||
].every((property) => !styleProps.has(property)),
|
||||
"clearing a pack removes every extended pane opacity tier",
|
||||
);
|
||||
ok(styleText("reasonix-base-code-readability").includes("--code-add-bg:"), "applyTheme installs the base code readability stylesheet");
|
||||
beginThemePreview(draft);
|
||||
ok(attrs.get("data-theme-pack") === "preview-pack", "preview applies pack");
|
||||
cancelThemePreview();
|
||||
@@ -298,7 +423,6 @@ const activeAuroraExperience = {
|
||||
effectiveStyle: "aurora" as const,
|
||||
activeThemeId: aurora.id,
|
||||
activePack: aurora,
|
||||
safeMode: false,
|
||||
};
|
||||
applyExperienceToDOM(activeAuroraExperience);
|
||||
ok(configuredBaseStyleForSync(activeAuroraExperience) === null, "active pack effective style is not mirrored as configured base");
|
||||
@@ -310,7 +434,7 @@ ok(
|
||||
);
|
||||
|
||||
// Density recipe must land in overlay CSS and have stylesheet consumers.
|
||||
ok((styleEl as { textContent: string }).textContent.includes("--theme-density-pad") || packSource.includes("--theme-density-pad:6px"), "compact density vars defined in pack builder");
|
||||
ok(styleText("reasonix-theme-pack-overlay").includes("--theme-density-pad") || packSource.includes("--theme-density-pad:6px"), "compact density vars defined in pack builder");
|
||||
const compactDraft = draftPackView({
|
||||
id: "dense",
|
||||
name: "Dense",
|
||||
@@ -319,8 +443,8 @@ const compactDraft = draftPackView({
|
||||
recipes: { density: "compact", corners: "soft" },
|
||||
});
|
||||
applyThemePack(compactDraft);
|
||||
ok((styleEl as { textContent: string }).textContent.includes("--theme-density-pad:6px"), "compact density injected");
|
||||
ok((styleEl as { textContent: string }).textContent.includes("--theme-row-h:28px"), "compact row height injected");
|
||||
ok(styleText("reasonix-theme-pack-overlay").includes("--theme-density-pad:6px"), "compact density injected");
|
||||
ok(styleText("reasonix-theme-pack-overlay").includes("--theme-row-h:28px"), "compact row height injected");
|
||||
ok(stylesSource.includes("padding: var(--theme-density-pad"), "density pad consumed by cards");
|
||||
ok(stylesSource.includes("gap: var(--theme-density-gap"), "density gap consumed");
|
||||
ok(stylesSource.includes("--list-row-height: var(--theme-row-h)"), "density maps to list row height");
|
||||
@@ -337,6 +461,12 @@ const unguardedTransparencySelectors = transparencySlice
|
||||
.split("\n")
|
||||
.filter((line) => line.includes(":root[data-theme-pack]") && !line.includes('[data-theme-has-bg="true"]'));
|
||||
ok(unguardedTransparencySelectors.length === 0, "token-only packs keep opaque layout surfaces");
|
||||
ok(
|
||||
!transparencySlice.includes(".app:not(.app--creation) .code,") &&
|
||||
!transparencySlice.includes(".app:not(.app--creation) .diff,") &&
|
||||
transparencySlice.includes(".app:not(.app--creation) .md-code,"),
|
||||
"pane transparency keeps block code and diff opaque while preserving inline-code styling",
|
||||
);
|
||||
ok(stylesSource.includes("var(--theme-pane-card-pct, 88%)"), "home cards consume pane opacity tier");
|
||||
ok(stylesSource.includes("var(--theme-pane-task-card-pct, 88%)"), "task cards consume pane opacity tier");
|
||||
ok(stylesSource.includes("var(--tp-pane-card-pct, 88%)"), "preview cards consume the same pane opacity tier");
|
||||
@@ -423,6 +553,7 @@ ok(experienceSource.includes("ActivateBaseStyle") || experienceSource.includes("
|
||||
ok(experienceSource.includes("selectedThemeId") || gallerySource.includes("selected"), "selection is frontend state");
|
||||
ok(gallerySource.includes("loading=\"lazy\"") || gallerySource.includes('loading="lazy"'), "gallery thumbs lazy-load");
|
||||
ok(gallerySource.includes("ThemePreviewSurface") || gallerySource.includes("theme-preview-surface"), "isolated detail preview");
|
||||
ok(previewSurfaceSource.includes("theme-preview-surface__code-island"), "theme preview includes real code and diff samples");
|
||||
ok(gallerySource.includes('themePackKind(pack) === "base"') && gallerySource.includes('variant="thumbnail"'), "base gallery cards render semantic UI thumbnails");
|
||||
ok(gallerySource.includes('themePackKind(p) === "base"'), "immersive rail renders base-style thumbnails");
|
||||
for (const style of ["graphite", "aurora", "slate", "carbon", "nocturne", "amber"] as const) {
|
||||
@@ -546,6 +677,14 @@ ok(stylesSource.includes(".theme-gallery__detail-user-actions") && stylesSource.
|
||||
ok(stylesSource.includes(".theme-gallery__rail-section-head") && stylesSource.includes(".theme-gallery__rail-section-items"), "immersive rail groups have lightweight headings and item stacks");
|
||||
ok(stylesSource.includes(".theme-gallery__detail-status"), "active status has dedicated non-button styling");
|
||||
ok(stylesSource.includes(".theme-editor__setting-hint"), "content-area guidance has dedicated responsive styling");
|
||||
ok(stylesSource.includes("background: var(--code-bg, var(--bg-soft))"), "code and diff surfaces consume the opaque code background");
|
||||
ok(
|
||||
stylesSource.includes("--diff-row-bg: var(--code-add-bg") &&
|
||||
stylesSource.includes("--inline-diff-row-bg: var(--code-del-bg") &&
|
||||
stylesSource.includes("background: var(--tp-code-add-bg)") &&
|
||||
stylesSource.includes("background: var(--tp-code-del-bg)"),
|
||||
"live and preview diff rows consume the same pre-composited safe backgrounds",
|
||||
);
|
||||
ok(localeZh.includes('"settings.themeEditor.safeArea": "界面内容区域"') && localeZh.includes('"settings.themeEditor.safeAreaHint": "选择文字和卡片主要显示的位置;建议避开图片主体。"'), "Chinese content-area copy explains foreground placement");
|
||||
|
||||
// Pack overlay stays at :root — Workbench/Creation element-scoped auto-light
|
||||
|
||||
@@ -6,7 +6,10 @@ import { fileURLToPath } from "node:url";
|
||||
import { TEXT_SIZES } from "../lib/textSize";
|
||||
|
||||
const testDir = dirname(fileURLToPath(import.meta.url));
|
||||
const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
const styles = [
|
||||
readFileSync(resolve(testDir, "../styles.css"), "utf8"),
|
||||
readFileSync(resolve(testDir, "../components/CompactRatioSettings.css"), "utf8"),
|
||||
].join("\n").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -120,6 +123,11 @@ eq(finalDeclaration(".provider-template-card span", "-webkit-line-clamp"), "2",
|
||||
eq(finalDeclaration(".provider-model-draft__list", "grid-auto-rows"), "min-content", "provider model rows grow with their content");
|
||||
eq(finalDeclaration(".provider-model-draft__option", "min-height"), undefined, "provider model cards do not force undersized rows");
|
||||
eq(finalDeclaration(".provider-model-draft__option", "overflow"), "hidden", "provider model cards contain overflowing controls");
|
||||
eq(finalDeclaration(".compact-ratio-presets", "width"), "100%", "compaction presets use the full settings control width");
|
||||
eq(finalDeclaration(".compact-ratio-presets .set-seg__btn", "flex"), "1 1 0", "three compaction presets share the available width equally");
|
||||
eq(finalDeclaration(".compact-ratio-presets .set-seg__btn", "flex-direction"), "column", "compaction presets place percentage and strategy on separate lines");
|
||||
eq(finalDeclaration(".compact-ratio-presets .set-seg__btn", "min-height"), "44px", "two-line compaction presets keep a stable target height");
|
||||
eq(finalDeclaration(".compact-ratio-presets .set-seg__btn", "white-space"), "normal", "compaction labels do not depend on ellipsis for their meaning");
|
||||
|
||||
eq(finalDeclaration(".statusbar", "white-space"), "nowrap", "status bar keeps metrics on one row");
|
||||
eq(finalDeclaration(".statusbar", "overflow"), "hidden", "status bar clips instead of overflowing");
|
||||
@@ -349,5 +357,27 @@ ok(
|
||||
"narrow workbench layout hides side panels and keeps chat single-column",
|
||||
);
|
||||
|
||||
for (const selector of [
|
||||
".reasoning__head",
|
||||
".turn-collapse__reasoning-head",
|
||||
".process-card__head",
|
||||
".tool__difflabel",
|
||||
".msg-memory-citations",
|
||||
".msg-memory-citations__source",
|
||||
".msg-memory-citations__note",
|
||||
".msg-attachment__name",
|
||||
".msg-attachment__meta",
|
||||
".msg-pasted-head",
|
||||
".msg-pasted-expanded",
|
||||
".msg-edit__input",
|
||||
".msg-edit__btn",
|
||||
".msg__send-failed",
|
||||
":root[data-theme-style] .process-card__kind",
|
||||
':root[data-theme-style] .msg--assistant > .process-card[data-tone="violet"] .process-card__name',
|
||||
]) {
|
||||
const size = finalDeclaration(selector, "font-size");
|
||||
ok(size !== undefined && !/^[0-9.]+px$/.test(size), `${selector} font size follows the text-size scale`);
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -5,7 +5,7 @@ import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { __emitMockUpdater, type AppBindings } from "../lib/bridge";
|
||||
import { switchUpdaterChannel, UpdaterProvider, useUpdater } from "../lib/useUpdater";
|
||||
import { classifyUpdateError, UpdaterProvider, useUpdater } from "../lib/useUpdater";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -20,25 +20,24 @@ function ok(value: boolean, label: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function Consumer({ id, checking = false, channel = "" }: { id: string; checking?: boolean; channel?: string }) {
|
||||
function Consumer({ id, checking = false }: { id: string; checking?: boolean }) {
|
||||
const updater = useUpdater();
|
||||
return (
|
||||
<section>
|
||||
<output id={`${id}-status`}>{updater.status.kind}</output>
|
||||
<output id={`${id}-manual`}>
|
||||
{updater.status.kind === "error" && updater.status.manualHint ? "manual" : ""}
|
||||
{updater.status.kind === "error" ? updater.status.disposition : ""}
|
||||
</output>
|
||||
<output id={`${id}-received`}>
|
||||
{updater.status.kind === "downloading" ? updater.status.received : ""}
|
||||
</output>
|
||||
{checking && <button id={`${id}-check-update`} type="button" onClick={() => void updater.check(channel)}>Check</button>}
|
||||
<button id={`${id}-reset-stable`} type="button" onClick={() => updater.reset("stable")}>Reset stable</button>
|
||||
<button id={`${id}-reset-preview`} type="button" onClick={() => updater.reset("preview")}>Reset preview</button>
|
||||
{checking && <button id={`${id}-check-update`} type="button" onClick={() => void updater.check()}>Check</button>}
|
||||
<button id={`${id}-reset`} type="button" onClick={() => updater.reset()}>Reset</button>
|
||||
{updater.status.kind === "available" && (
|
||||
<button id={`${id}-download`} type="button" onClick={() => updater.download(updater.status.info)}>Download</button>
|
||||
<button id={`${id}-apply`} type="button" onClick={() => updater.apply(updater.status.info)}>Apply</button>
|
||||
)}
|
||||
{updater.status.kind === "downloaded" && (
|
||||
<button id={`${id}-install`} type="button" onClick={updater.install}>Install</button>
|
||||
{updater.status.kind === "error" && updater.status.info && (
|
||||
<button id={`${id}-retry`} type="button" onClick={() => updater.apply(updater.status.info!)}>Retry</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
@@ -62,13 +61,17 @@ await act(async () => {
|
||||
root.render(
|
||||
<UpdaterProvider>
|
||||
<Consumer id="banner" checking />
|
||||
<Consumer id="settings" checking channel="preview" />
|
||||
<Consumer id="settings" checking />
|
||||
</UpdaterProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
ok(document.getElementById("banner-status")?.textContent === "idle", "banner starts idle");
|
||||
ok(document.getElementById("settings-status")?.textContent === "idle", "settings starts idle");
|
||||
ok(classifyUpdateError("prepare update: a pending update already exists") === "recovery", "pending update errors require recovery fallback");
|
||||
ok(classifyUpdateError("prepare update: recover existing handoff backup: operation not permitted") === "recovery", "macOS backup permission errors require recovery fallback");
|
||||
ok(classifyUpdateError("update: manual update required") === "manual", "manual-only errors prefer the official download");
|
||||
ok(classifyUpdateError("connection reset by peer") === "retryable", "transient errors remain retryable");
|
||||
|
||||
await act(async () => {
|
||||
(document.getElementById("banner-check-update") as HTMLButtonElement).click();
|
||||
@@ -92,7 +95,7 @@ const debInfo = {
|
||||
downloadUrl: "https://example.invalid/download",
|
||||
assetSize: 42,
|
||||
};
|
||||
const installAttempts: Array<{
|
||||
const applyAttempts: Array<{
|
||||
requestId: string;
|
||||
version: string;
|
||||
channel: string;
|
||||
@@ -105,20 +108,10 @@ window.go = {
|
||||
App: {
|
||||
async CheckUpdate(channel: string) {
|
||||
checkedChannels.push(channel);
|
||||
return { ...debInfo, channel: channel === "preview" ? "preview" : "stable" };
|
||||
return { ...debInfo, channel: "stable" };
|
||||
},
|
||||
async DownloadUpdateRequest(channel: string, expectedVersion: string, requestId: string) {
|
||||
return {
|
||||
requestId,
|
||||
version: expectedVersion,
|
||||
channel: channel === "preview" ? "preview" : "stable",
|
||||
path: "/tmp/update.deb",
|
||||
size: 42,
|
||||
sha256: "abc",
|
||||
};
|
||||
},
|
||||
InstallUpdateRequest(channel: string, expectedVersion: string, requestId: string) {
|
||||
return new Promise<void>((resolve, reject) => installAttempts.push({
|
||||
ApplyUpdateRequest(channel: string, expectedVersion: string, requestId: string) {
|
||||
return new Promise<void>((resolve, reject) => applyAttempts.push({
|
||||
requestId,
|
||||
version: expectedVersion,
|
||||
channel,
|
||||
@@ -134,80 +127,89 @@ await act(async () => {
|
||||
(document.getElementById("settings-check-update") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(checkedChannels[0] === "preview", "settings check forwards the selected Preview channel");
|
||||
ok(checkedChannels[0] === "stable", "settings check uses the official release channel");
|
||||
ok(document.getElementById("banner-status")?.textContent === "available", "deb update becomes available");
|
||||
ok(document.getElementById("settings-status")?.textContent === "available", "deb availability is shared");
|
||||
|
||||
await act(async () => {
|
||||
(document.getElementById("banner-download") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
(document.getElementById("banner-apply") as HTMLButtonElement).click();
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "downloaded", "deb download completes");
|
||||
|
||||
await act(async () => {
|
||||
(document.getElementById("banner-install") as HTMLButtonElement).click();
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "authorizing", "deb install starts authorizing");
|
||||
ok(document.getElementById("banner-status")?.textContent === "authorizing", "deb apply starts authorizing");
|
||||
ok(document.getElementById("settings-status")?.textContent === "authorizing", "authorizing state is shared");
|
||||
|
||||
await act(async () => {
|
||||
__emitMockUpdater({
|
||||
requestId: installAttempts[0].requestId,
|
||||
version: installAttempts[0].version,
|
||||
channel: "preview",
|
||||
phase: "recovering",
|
||||
requestId: applyAttempts[0].requestId,
|
||||
version: applyAttempts[0].version,
|
||||
channel: "stable",
|
||||
phase: "downloading",
|
||||
received: 10,
|
||||
total: 42,
|
||||
});
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "downloading", "download phase is shared");
|
||||
ok(document.getElementById("banner-received")?.textContent === "10", "download progress is shared");
|
||||
|
||||
await act(async () => {
|
||||
__emitMockUpdater({
|
||||
requestId: applyAttempts[0].requestId,
|
||||
version: applyAttempts[0].version,
|
||||
channel: "stable",
|
||||
phase: "verifying",
|
||||
received: 42,
|
||||
total: 42,
|
||||
});
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "recovering", "stale update recovery is shared");
|
||||
ok(document.getElementById("settings-status")?.textContent === "recovering", "settings observes stale update recovery");
|
||||
ok(document.getElementById("banner-status")?.textContent === "verifying", "verify phase is shared");
|
||||
|
||||
await act(async () => {
|
||||
__emitMockUpdater({
|
||||
requestId: installAttempts[0].requestId,
|
||||
version: installAttempts[0].version,
|
||||
channel: "preview",
|
||||
requestId: applyAttempts[0].requestId,
|
||||
version: applyAttempts[0].version,
|
||||
channel: "stable",
|
||||
phase: "installing",
|
||||
received: 42,
|
||||
total: 42,
|
||||
});
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "installing", "helper phase advances to installing");
|
||||
ok(document.getElementById("banner-status")?.textContent === "installing", "install phase advances");
|
||||
|
||||
await act(async () => {
|
||||
__emitMockUpdater({
|
||||
requestId: installAttempts[0].requestId,
|
||||
version: installAttempts[0].version,
|
||||
channel: "preview",
|
||||
phase: "downloaded",
|
||||
received: 42,
|
||||
total: 42,
|
||||
});
|
||||
installAttempts[0]?.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "downloaded", "authorization cancellation returns to downloaded");
|
||||
|
||||
await act(async () => {
|
||||
(document.getElementById("banner-install") as HTMLButtonElement).click();
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "authorizing", "retry re-enters authorizing");
|
||||
|
||||
await act(async () => {
|
||||
installAttempts[1]?.reject(new Error("update: manual update required: system update helper is unavailable"));
|
||||
applyAttempts[0]?.reject(new Error("update: manual update required: system update helper is unavailable"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "error", "manual reclassification leaves busy state");
|
||||
ok(document.getElementById("banner-manual")?.textContent === "manual", "manual reclassification offers download fallback");
|
||||
ok(document.getElementById("settings-status")?.textContent === "error", "manual fallback error is shared");
|
||||
ok(!!document.getElementById("banner-retry"), "error state exposes retry");
|
||||
|
||||
let resolveSavedCheck!: (value: typeof debInfo) => void;
|
||||
let resolvePreviewCheck!: (value: typeof debInfo) => void;
|
||||
window.go.main.App.CheckUpdate = (selected: string) =>
|
||||
// Retry releases the mutex and can start a new apply immediately.
|
||||
await act(async () => {
|
||||
(document.getElementById("banner-retry") as HTMLButtonElement).click();
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "authorizing", "retry re-enters applying");
|
||||
await act(async () => {
|
||||
applyAttempts[1]?.resolve();
|
||||
__emitMockUpdater({
|
||||
requestId: applyAttempts[1].requestId,
|
||||
version: applyAttempts[1].version,
|
||||
channel: "stable",
|
||||
phase: "relaunching",
|
||||
received: 0,
|
||||
total: 0,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "relaunching", "relaunching phase is shared");
|
||||
|
||||
let resolveFirstCheck!: (value: typeof debInfo) => void;
|
||||
let resolveSecondCheck!: (value: typeof debInfo) => void;
|
||||
let checkCalls = 0;
|
||||
window.go.main.App.CheckUpdate = () =>
|
||||
new Promise<typeof debInfo>((resolve) => {
|
||||
if (selected === "preview") resolvePreviewCheck = resolve;
|
||||
else resolveSavedCheck = resolve;
|
||||
checkCalls += 1;
|
||||
if (checkCalls === 1) resolveFirstCheck = resolve;
|
||||
else resolveSecondCheck = resolve;
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -215,141 +217,126 @@ await act(async () => {
|
||||
(document.getElementById("settings-check-update") as HTMLButtonElement).click();
|
||||
});
|
||||
await act(async () => {
|
||||
resolvePreviewCheck({ ...debInfo, channel: "preview", latest: "v1.2.0" });
|
||||
resolveSecondCheck({ ...debInfo, channel: "stable", latest: "v1.2.0" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "available", "newest channel check publishes its result");
|
||||
ok(document.getElementById("banner-status")?.textContent === "available", "newest official check publishes its result");
|
||||
await act(async () => {
|
||||
resolveSavedCheck({ ...debInfo, available: false, channel: "stable", latest: "v1.0.0" });
|
||||
resolveFirstCheck({ ...debInfo, available: false, channel: "stable", latest: "v1.0.0" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "available", "stale channel check cannot overwrite the newest result");
|
||||
ok(document.getElementById("banner-status")?.textContent === "available", "stale official check cannot overwrite the newest result");
|
||||
|
||||
let oldDownloadRequestId = "";
|
||||
let oldDownloadVersion = "";
|
||||
let resolveOldDownload!: (value: { requestId: string; version: string; channel: string; path: string; size: number; sha256: string }) => void;
|
||||
window.go.main.App.DownloadUpdateRequest = (_channel: string, expectedVersion: string, requestId: string) =>
|
||||
new Promise((resolve) => {
|
||||
oldDownloadRequestId = requestId;
|
||||
oldDownloadVersion = expectedVersion;
|
||||
resolveOldDownload = resolve;
|
||||
let oldApplyRequestId = "";
|
||||
let oldApplyVersion = "";
|
||||
let resolveOldApply!: () => void;
|
||||
window.go.main.App.ApplyUpdateRequest = (_channel: string, expectedVersion: string, requestId: string) =>
|
||||
new Promise<void>((resolve) => {
|
||||
oldApplyRequestId = requestId;
|
||||
oldApplyVersion = expectedVersion;
|
||||
resolveOldApply = resolve;
|
||||
});
|
||||
await act(async () => {
|
||||
(document.getElementById("banner-download") as HTMLButtonElement).click();
|
||||
(document.getElementById("banner-apply") as HTMLButtonElement).click();
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "downloading", "Preview download starts");
|
||||
ok(document.getElementById("banner-status")?.textContent === "authorizing", "official apply starts");
|
||||
await act(async () => {
|
||||
__emitMockUpdater({
|
||||
requestId: oldDownloadRequestId,
|
||||
version: `${oldDownloadVersion}-wrong`,
|
||||
channel: "preview",
|
||||
phase: "downloaded",
|
||||
requestId: oldApplyRequestId,
|
||||
version: `${oldApplyVersion}-wrong`,
|
||||
channel: "stable",
|
||||
phase: "installing",
|
||||
received: 42,
|
||||
total: 42,
|
||||
});
|
||||
});
|
||||
ok(
|
||||
document.getElementById("banner-status")?.textContent === "downloading",
|
||||
"same-request wrong-version progress cannot complete the active Preview download",
|
||||
document.getElementById("banner-status")?.textContent === "authorizing",
|
||||
"same-request wrong-version progress cannot advance the active apply",
|
||||
);
|
||||
await act(async () => {
|
||||
__emitMockUpdater({
|
||||
requestId: oldDownloadRequestId,
|
||||
version: oldDownloadVersion,
|
||||
channel: "stable",
|
||||
requestId: oldApplyRequestId,
|
||||
version: oldApplyVersion,
|
||||
channel: "preview",
|
||||
phase: "downloading",
|
||||
received: 41,
|
||||
total: 42,
|
||||
});
|
||||
});
|
||||
ok(document.getElementById("banner-received")?.textContent === "0", "wrong-channel progress cannot update the active Preview download");
|
||||
ok(document.getElementById("banner-received")?.textContent === "", "retired-channel progress cannot update the active official apply");
|
||||
await act(async () => {
|
||||
(document.getElementById("banner-reset-preview") as HTMLButtonElement).click();
|
||||
(document.getElementById("banner-reset") as HTMLButtonElement).click();
|
||||
__emitMockUpdater({
|
||||
requestId: oldDownloadRequestId,
|
||||
version: oldDownloadVersion,
|
||||
channel: "preview",
|
||||
phase: "downloaded",
|
||||
received: 42,
|
||||
total: 42,
|
||||
});
|
||||
resolveOldDownload({
|
||||
requestId: oldDownloadRequestId,
|
||||
version: oldDownloadVersion,
|
||||
channel: "preview",
|
||||
path: "/tmp/old.deb",
|
||||
size: 42,
|
||||
sha256: "abc",
|
||||
requestId: oldApplyRequestId,
|
||||
version: oldApplyVersion,
|
||||
channel: "stable",
|
||||
phase: "error",
|
||||
received: 0,
|
||||
total: 0,
|
||||
err: "old apply failed",
|
||||
});
|
||||
resolveOldApply();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "idle", "same-channel superseded progress and Promise completion stay ignored");
|
||||
|
||||
window.go.main.App.CheckUpdate = async (selected: string) => ({
|
||||
window.go.main.App.CheckUpdate = async () => ({
|
||||
...debInfo,
|
||||
channel: selected === "preview" ? "preview" : "stable",
|
||||
latest: selected === "preview" ? "v1.2.0" : "v1.1.0",
|
||||
});
|
||||
window.go.main.App.DownloadUpdateRequest = async (selected: string, _expectedVersion: string, requestId: string) => ({
|
||||
requestId,
|
||||
version: "v1.2.0-preview.99",
|
||||
channel: selected === "preview" ? "preview" : "stable",
|
||||
path: "/tmp/new.deb",
|
||||
size: 42,
|
||||
sha256: "abc",
|
||||
channel: "stable",
|
||||
latest: "v1.2.0",
|
||||
});
|
||||
window.go.main.App.ApplyUpdateRequest = async () => {
|
||||
throw new Error("should not reach when version mismatches from progress only");
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
(document.getElementById("settings-check-update") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
await act(async () => {
|
||||
(document.getElementById("banner-download") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "available", "download result for a different version is rejected");
|
||||
ok(document.getElementById("banner-status")?.textContent === "available", "official update is available again");
|
||||
|
||||
window.go.main.App.DownloadUpdateRequest = async (selected: string, expectedVersion: string, requestId: string) => ({
|
||||
requestId,
|
||||
version: expectedVersion,
|
||||
channel: selected === "preview" ? "preview" : "stable",
|
||||
path: "/tmp/new.deb",
|
||||
size: 42,
|
||||
sha256: "abc",
|
||||
});
|
||||
window.go.main.App.ApplyUpdateRequest = async (_channel: string, _expectedVersion: string, requestId: string) => {
|
||||
// Hang until cancelled by reset so we can prove supersession.
|
||||
return new Promise<void>((_resolve, reject) => {
|
||||
applyAttempts.push({
|
||||
requestId,
|
||||
version: _expectedVersion,
|
||||
channel: _channel,
|
||||
resolve: () => {},
|
||||
reject,
|
||||
});
|
||||
});
|
||||
};
|
||||
await act(async () => {
|
||||
(document.getElementById("banner-download") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
(document.getElementById("banner-apply") as HTMLButtonElement).click();
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "downloaded", "Preview update is ready for install");
|
||||
const staleApply = applyAttempts[applyAttempts.length - 1];
|
||||
ok(document.getElementById("banner-status")?.textContent === "authorizing", "official apply starts");
|
||||
await act(async () => {
|
||||
(document.getElementById("banner-install") as HTMLButtonElement).click();
|
||||
});
|
||||
const staleInstall = installAttempts[installAttempts.length - 1];
|
||||
ok(document.getElementById("banner-status")?.textContent === "authorizing", "Preview install starts");
|
||||
await act(async () => {
|
||||
(document.getElementById("banner-reset-stable") as HTMLButtonElement).click();
|
||||
(document.getElementById("banner-reset") as HTMLButtonElement).click();
|
||||
__emitMockUpdater({
|
||||
requestId: staleInstall.requestId,
|
||||
version: staleInstall.version,
|
||||
channel: "preview",
|
||||
requestId: staleApply.requestId,
|
||||
version: staleApply.version,
|
||||
channel: "stable",
|
||||
phase: "error",
|
||||
received: 0,
|
||||
total: 0,
|
||||
err: "old install failed",
|
||||
});
|
||||
staleInstall.reject(new Error("old Preview install failed"));
|
||||
staleApply.reject(new Error("old official apply failed"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "idle", "superseded install progress and rejection stay ignored");
|
||||
ok(document.getElementById("banner-status")?.textContent === "idle", "superseded apply progress and rejection stay ignored");
|
||||
|
||||
window.go.main.App.CheckUpdate = async () => ({ ...debInfo, channel: "stable" });
|
||||
window.go.main.App.CheckUpdate = async () => ({ ...debInfo, channel: "preview" });
|
||||
await act(async () => {
|
||||
(document.getElementById("settings-check-update") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(document.getElementById("banner-status")?.textContent === "error", "wrong-channel check response leaves the checking state");
|
||||
|
||||
window.go.main.App.CheckUpdate = async () => ({ ...debInfo, channel: "preview", latest: "v1.3.0" });
|
||||
window.go.main.App.CheckUpdate = async () => ({ ...debInfo, channel: "stable", latest: "v1.3.0" });
|
||||
await act(async () => {
|
||||
(document.getElementById("settings-check-update") as HTMLButtonElement).click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
@@ -358,43 +345,5 @@ ok(document.getElementById("banner-status")?.textContent === "available", "check
|
||||
|
||||
delete window.go;
|
||||
|
||||
const channelTransitions: string[] = [];
|
||||
let releaseSave!: (saved: boolean) => void;
|
||||
const pendingSwitch = switchUpdaterChannel(
|
||||
"preview",
|
||||
(channel) => {
|
||||
channelTransitions.push(`invalidate:${channel}`);
|
||||
},
|
||||
(channel) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
channelTransitions.push(`save:${channel}`);
|
||||
releaseSave = resolve;
|
||||
}),
|
||||
async (channel) => {
|
||||
channelTransitions.push(`check:${channel}`);
|
||||
},
|
||||
);
|
||||
ok(channelTransitions.join(",") === "invalidate:preview,save:preview", "channel switch invalidates old updater state before persistence resolves");
|
||||
releaseSave(true);
|
||||
await pendingSwitch;
|
||||
ok(channelTransitions.join(",") === "invalidate:preview,save:preview,check:preview", "successful channel persistence checks the target pointer");
|
||||
|
||||
await switchUpdaterChannel(
|
||||
"stable",
|
||||
(channel) => {
|
||||
channelTransitions.push(`invalidate:${channel}`);
|
||||
},
|
||||
async (channel) => {
|
||||
channelTransitions.push(`failed:${channel}`);
|
||||
return false;
|
||||
},
|
||||
async (channel) => {
|
||||
channelTransitions.push(`unexpected:${channel}`);
|
||||
},
|
||||
);
|
||||
ok(!channelTransitions.includes("unexpected:stable"), "failed channel persistence does not check an unselected channel");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,28 @@
|
||||
// Run: tsx src/__tests__/usage-stats-format.test.ts
|
||||
|
||||
import { formatUsageTokens } from "../lib/usageStatsFormat";
|
||||
import { en } from "../locales/en";
|
||||
import { zhTW } from "../locales/zh-TW";
|
||||
import { zh } from "../locales/zh";
|
||||
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const english = formatUsageTokens(10_000, "en");
|
||||
const chinese = formatUsageTokens(10_000, "zh");
|
||||
|
||||
ok(!english.includes("万") && !english.includes("亿"), "English token totals do not use Chinese units");
|
||||
ok(chinese !== english, "token totals follow the selected desktop locale");
|
||||
ok(en["settings.modelTab.stats"] === "Usage stats", "English stats tab has a dedicated label");
|
||||
ok(zh["settings.modelTab.stats"] === "用量统计", "Simplified Chinese stats tab says 用量统计");
|
||||
ok(zhTW["settings.modelTab.stats"] === "用量統計", "Traditional Chinese stats tab says 用量統計");
|
||||
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,170 @@
|
||||
// Run: tsx src/__tests__/usage-stats-panel.test.tsx
|
||||
|
||||
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 { UsageStatsRange, UsageStatsRequest } from "../lib/types";
|
||||
|
||||
registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
if (specifier.endsWith(".css")) {
|
||||
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
},
|
||||
});
|
||||
|
||||
let failed = 0;
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) process.stdout.write(` PASS ${label}\n`);
|
||||
else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function emptyStats(overrides: Partial<UsageStatsRange> = {}): UsageStatsRange {
|
||||
return {
|
||||
from: "", to: "", tokens: 1, requests: 1, turns: 1,
|
||||
cacheHit: 0, cacheMiss: 1, activeDays: 1,
|
||||
topModel: "deepseek/model", topProvider: "deepseek",
|
||||
daily: [], models: [], providers: [], ...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function day(offset: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + offset);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
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;
|
||||
Object.defineProperty(globalThis.HTMLElement.prototype, "clientWidth", { configurable: true, get: () => 720 });
|
||||
globalThis.Event = dom.window.Event;
|
||||
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);
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver;
|
||||
|
||||
let rejectCLIHeat: ((error: Error) => void) | undefined;
|
||||
let rejectCLIMain: ((error: Error) => void) | undefined;
|
||||
let resolveDesktopMain: ((stats: UsageStatsRange) => void) | undefined;
|
||||
const usageStats = (req: UsageStatsRequest): Promise<UsageStatsRange> => {
|
||||
if (req.range !== "custom") {
|
||||
if (req.source === "cli") {
|
||||
return new Promise((_, reject) => { rejectCLIMain = reject; });
|
||||
}
|
||||
if (req.source === "desktop") {
|
||||
return new Promise((resolve) => { resolveDesktopMain = resolve; });
|
||||
}
|
||||
return Promise.resolve(emptyStats({
|
||||
tokens: 21,
|
||||
daily: Array.from({ length: 400 }, (_, index) => ({
|
||||
day: day(index - 399),
|
||||
total: 1,
|
||||
byModel: { "deepseek/model": 1 },
|
||||
byProvider: { deepseek: 1 },
|
||||
requests: 1,
|
||||
turns: 1,
|
||||
cacheHit: 0,
|
||||
cacheMiss: 1,
|
||||
})),
|
||||
models: Array.from({ length: 21 }, (_, index) => ({
|
||||
model: `deepseek/model-${index + 1}`,
|
||||
tokens: 1,
|
||||
percent: 100 / 21,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
if (req.source === "cli") {
|
||||
return new Promise((_, reject) => { rejectCLIHeat = reject; });
|
||||
}
|
||||
return Promise.resolve(emptyStats({
|
||||
from: day(-279),
|
||||
to: day(0),
|
||||
daily: [{ day: day(0), total: 100, byModel: { "deepseek/model": 100 }, byProvider: { deepseek: 100 }, requests: 1, turns: 1, cacheHit: 10, cacheMiss: 10 }],
|
||||
}));
|
||||
};
|
||||
(window as unknown as { go: unknown }).go = { main: { App: { UsageStats: usageStats } } };
|
||||
|
||||
const [{ LocaleProvider }, { UsageStatsPanel }] = await Promise.all([
|
||||
import("../lib/i18n"),
|
||||
import("../components/UsageStatsPanel"),
|
||||
]);
|
||||
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
await act(async () => {
|
||||
root.render(<LocaleProvider><UsageStatsPanel /></LocaleProvider>);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
for (let i = 0; i < 20 && rootEl.querySelectorAll("rect.usage-stats__heat-cell--5").length === 0; i += 1) {
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
|
||||
}
|
||||
ok(rootEl.querySelectorAll("rect.usage-stats__heat-cell--5").length === 1, "initial source heat data is rendered");
|
||||
ok(rootEl.textContent?.includes("Completed turns") === true && !rootEl.textContent?.includes("Sessions"), "completed turns are not mislabeled as sessions");
|
||||
ok(rootEl.querySelectorAll("rect.usage-stats__bar-hit").length === 180, "long custom trends render at most 180 interactive columns");
|
||||
ok(rootEl.textContent?.includes("Showing the latest 180 days") === true, "bounded trends disclose the visible window");
|
||||
const modelSegments = rootEl.querySelectorAll("circle.usage-stats__donut-seg");
|
||||
const primaryColour = modelSegments[0]?.getAttribute("stroke") ?? "";
|
||||
ok(primaryColour.includes("var(--accent)") && primaryColour.includes("var(--fg)"), "model colours adapt to the active theme");
|
||||
const overflowColour = modelSegments[20]?.getAttribute("stroke") ?? "";
|
||||
ok(overflowColour.includes("var(--accent)") && overflowColour.includes("var(--fg)"), "overflow model colours also adapt to the active theme");
|
||||
|
||||
const cliButton = [...rootEl.querySelectorAll("button")].find((button) => button.textContent === "CLI");
|
||||
if (!cliButton) throw new Error("missing CLI source button");
|
||||
await act(async () => {
|
||||
cliButton.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(rootEl.querySelectorAll("rect.usage-stats__heat-cell--5").length === 0, "source switch clears previous heat data while loading");
|
||||
|
||||
await act(async () => {
|
||||
rejectCLIHeat?.(new Error("expected test failure"));
|
||||
rejectCLIMain?.(new Error("expected main test failure"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(rootEl.querySelectorAll("rect.usage-stats__heat-cell--5").length === 0, "failed current-source fetch cannot revive previous heat data");
|
||||
ok(rootEl.querySelectorAll("circle.usage-stats__donut-seg").length === 0, "failed current-source fetch cannot retain previous main stats");
|
||||
|
||||
const desktopButton = [...rootEl.querySelectorAll("button")].find((button) => button.textContent === "Desktop");
|
||||
if (!desktopButton) throw new Error("missing Desktop source button");
|
||||
await act(async () => {
|
||||
desktopButton.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
const customButton = [...rootEl.querySelectorAll("button")].find((button) => button.textContent === "Custom");
|
||||
if (!customButton) throw new Error("missing Custom range button");
|
||||
await act(async () => {
|
||||
customButton.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
await act(async () => {
|
||||
resolveDesktopMain?.(emptyStats({ tokens: 777 }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
ok(!rootEl.textContent?.includes("777"), "incomplete custom range invalidates an older main response");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -93,6 +93,21 @@ function usage(source: string): WireUsage {
|
||||
console.log("\nuse controller meta");
|
||||
|
||||
{
|
||||
eq(
|
||||
modelSwitchNoticeText("active work is still running; running=false; pending_prompt=false; background_jobs=2; finish or cancel the current turn, answer pending prompts, and stop background jobs before changing model"),
|
||||
"The model cannot change while 2 background jobs are running. Open Background jobs in the status bar to stop them.",
|
||||
"model busy guard names the background-job blocker",
|
||||
);
|
||||
eq(
|
||||
effortSwitchNoticeText("active work is still running; running=true; pending_prompt=false; background_jobs=0; finish or cancel the current turn, answer pending prompts, and stop background jobs before changing effort"),
|
||||
"Reasoning effort cannot change while the current answer is running. Stop it first.",
|
||||
"effort busy guard names the running-answer blocker",
|
||||
);
|
||||
eq(
|
||||
tokenModeSwitchNoticeText("active work is still running; running=true; pending_prompt=true; background_jobs=0; finish or cancel the current turn, answer pending prompts, and stop background jobs before changing token mode"),
|
||||
"Work mode cannot change while a prompt is waiting for your response. Handle it first.",
|
||||
"work mode busy guard prioritizes the pending prompt blocker",
|
||||
);
|
||||
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.",
|
||||
@@ -245,6 +260,21 @@ console.log("\nuse controller meta");
|
||||
"Guidance was not applied because the turn ended before it could be processed. Send it again if it is still needed:\nuse plan B",
|
||||
"unapplied steer keeps the user's guidance while localizing the warning",
|
||||
);
|
||||
eq(
|
||||
localizedNoticeText("reworded recovery copy", "session_recovery_forked"),
|
||||
"The session changed on disk, so the unsaved local transcript was kept as a conflict copy.",
|
||||
"session recovery fork localization uses its stable notice code",
|
||||
);
|
||||
eq(
|
||||
localizedNoticeText("reworded covered adoption", "session_recovery_adopted_covered"),
|
||||
"The session changed on disk, so Reasonix adopted the newer transcript; the local changes were already covered.",
|
||||
"covered session adoption localization uses its stable notice code",
|
||||
);
|
||||
eq(
|
||||
localizedNoticeText("reworded depth cap", "session_recovery_depth_cap"),
|
||||
"Repeated save conflicts were detected, so the current conflict copy was saved in place.",
|
||||
"session recovery depth-cap localization uses its stable notice code",
|
||||
);
|
||||
eq(
|
||||
localizedNoticeText("Tool round limit reached; asking the assistant to summarize progress.", "unknown_future_code"),
|
||||
"Tool round limit reached; asking the assistant to summarize progress.",
|
||||
@@ -260,7 +290,7 @@ console.log("\nuse controller meta");
|
||||
{
|
||||
let s = reducer(initialState, {
|
||||
type: "event",
|
||||
e: { kind: "notice", level: "warn", text: "session conflicts kept recurring; kept the transcript on the current recovery branch" },
|
||||
e: { kind: "notice", level: "warn", code: "session_recovery_depth_cap", text: "reworded recovery maintenance" },
|
||||
});
|
||||
s = reducer(s, {
|
||||
type: "event",
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// Run: tsx src/__tests__/virtual-menu-identity.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { SlashMenu } from "../components/SlashMenu";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import type { CommandInfo } 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 rect(height: number): DOMRect {
|
||||
return {
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 600,
|
||||
bottom: height,
|
||||
width: 600,
|
||||
height,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
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;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver;
|
||||
dom.window.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
|
||||
configurable: true,
|
||||
value(this: HTMLElement) {
|
||||
if (this.classList.contains("slashmenu")) return rect(360);
|
||||
if (this.classList.contains("slashmenu__row")) {
|
||||
return rect(this.firstElementChild?.classList.contains("slashmenu__group") ? 26 : 34);
|
||||
}
|
||||
return rect(0);
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return 600;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
if (this.classList.contains("slashmenu")) return 360;
|
||||
if (this.classList.contains("slashmenu__row")) {
|
||||
return this.firstElementChild?.classList.contains("slashmenu__group") ? 26 : 34;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
|
||||
const initialItems: CommandInfo[] = [
|
||||
{ name: "documentation", description: "Documentation", kind: "skill" },
|
||||
{ name: "docx", description: "Word documents", kind: "skill" },
|
||||
{ name: "lark-doc", description: "Lark documents", kind: "skill" },
|
||||
{ name: "grill-with-docs", description: "Review with docs", kind: "skill" },
|
||||
{ name: "tencent-docs", description: "Tencent documents", kind: "skill" },
|
||||
{ name: "docs", description: "Search bundled documentation", kind: "builtin", group: "integrations" },
|
||||
];
|
||||
|
||||
const filteredItems = [initialItems[0]!, initialItems[4]!, initialItems[5]!];
|
||||
|
||||
function Menu({ items }: { items: CommandInfo[] }) {
|
||||
return (
|
||||
<LocaleProvider>
|
||||
<SlashMenu items={items} activeIndex={-1} onPick={() => {}} onHover={() => {}} />
|
||||
</LocaleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function rowPositions(): Array<{ label: string; position: number; transform: string }> {
|
||||
return Array.from(document.querySelectorAll<HTMLElement>(".slashmenu__row")).map((row) => {
|
||||
const match = row.style.transform.match(/translate3d\(0(?:px)?, ([\d.-]+)px, 0(?:px)?\)/);
|
||||
const name = row.querySelector(".slashmenu__name")?.textContent;
|
||||
return {
|
||||
label: name ?? "group",
|
||||
position: match ? Number(match[1]) : Number.NaN,
|
||||
transform: row.style.transform,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
console.log("\nvirtual menu item identity");
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
if (!rootElement) throw new Error("missing root");
|
||||
const root = createRoot(rootElement);
|
||||
|
||||
await act(async () => {
|
||||
root.render(<Menu items={initialItems} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
const initialDocsRow = Array.from(document.querySelectorAll<HTMLElement>(".slashmenu__row"))
|
||||
.find((row) => row.querySelector(".slashmenu__name")?.textContent === "/docs");
|
||||
await act(async () => {
|
||||
root.render(<Menu items={filteredItems} />);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
const positions = rowPositions();
|
||||
ok(
|
||||
JSON.stringify(positions) === JSON.stringify([
|
||||
{ label: "group", position: 0, transform: "translate3d(0, 0px, 0)" },
|
||||
{ label: "/documentation", position: 26, transform: "translate3d(0, 26px, 0)" },
|
||||
{ label: "/tencent-docs", position: 60, transform: "translate3d(0, 60px, 0)" },
|
||||
{ label: "group", position: 94, transform: "translate3d(0, 94px, 0)" },
|
||||
{ label: "/docs", position: 120, transform: "translate3d(0, 120px, 0)" },
|
||||
]),
|
||||
`filtered rows keep contiguous semantic positions: got ${JSON.stringify(positions)}`,
|
||||
);
|
||||
const filteredDocsRow = Array.from(document.querySelectorAll<HTMLElement>(".slashmenu__row"))
|
||||
.find((row) => row.querySelector(".slashmenu__name")?.textContent === "/docs");
|
||||
ok(initialDocsRow === filteredDocsRow, "/docs keeps the same semantic DOM row while its filtered index changes");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,58 @@
|
||||
// Run: pnpm test:window-state
|
||||
|
||||
import { createWindowStateSaver, type WindowStateSnapshot } from "../lib/windowState";
|
||||
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`);
|
||||
if (!value) failed += 1;
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((done) => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
let nativeState = { width: 900, height: 600, x: 10, y: 20, maximised: false };
|
||||
let sizeReads = 0;
|
||||
const firstPersist = deferred();
|
||||
const persisted: WindowStateSnapshot[] = [];
|
||||
const save = createWindowStateSaver(
|
||||
{
|
||||
async WindowGetSize() {
|
||||
sizeReads += 1;
|
||||
return { w: nativeState.width, h: nativeState.height };
|
||||
},
|
||||
async WindowGetPosition() {
|
||||
return { x: nativeState.x, y: nativeState.y };
|
||||
},
|
||||
async WindowIsMaximised() {
|
||||
return nativeState.maximised;
|
||||
},
|
||||
},
|
||||
async (state) => {
|
||||
persisted.push(state);
|
||||
if (persisted.length === 1) await firstPersist.promise;
|
||||
},
|
||||
);
|
||||
|
||||
const oldSave = save();
|
||||
while (persisted.length === 0) await Promise.resolve();
|
||||
|
||||
nativeState = { width: 1280, height: 800, x: 40, y: 50, maximised: true };
|
||||
const newSave = save();
|
||||
await Promise.resolve();
|
||||
ok(sizeReads === 1, "a newer request waits for the in-flight capture and persistence");
|
||||
|
||||
firstPersist.resolve();
|
||||
await Promise.all([oldSave, newSave]);
|
||||
ok(persisted.length === 2, "both distinct observations are persisted");
|
||||
ok(persisted[0].width === 900 && persisted[1].width === 1280, "the newest observation is written last");
|
||||
ok(persisted[1].maximised, "the newest maximised state is preserved");
|
||||
|
||||
await save();
|
||||
ok(persisted.length === 2, "an unchanged observation is deduplicated after persistence succeeds");
|
||||
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,49 @@
|
||||
// Run: tsx src/__tests__/workspace-change-status.test.ts
|
||||
|
||||
import { workspaceGitStatusLabel, workspaceGitStatusLabelKey } from "../lib/workspaceChanges";
|
||||
import { en } from "../locales/en";
|
||||
import { zh } from "../locales/zh";
|
||||
import { zhTW } from "../locales/zh-TW";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function eq<T>(actual: T, expected: T, label: string) {
|
||||
if (Object.is(actual, expected)) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n expected: ${String(expected)}\n actual: ${String(actual)}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const t = ((key: string) => (en as Record<string, string>)[key] ?? key) as never;
|
||||
|
||||
console.log("\nworkspace git status labels");
|
||||
|
||||
eq(workspaceGitStatusLabel("??", t), "Untracked", "?? reads as untracked instead of leaking porcelain");
|
||||
eq(workspaceGitStatusLabel(" M", t), "Modified", "unstaged modification");
|
||||
eq(workspaceGitStatusLabel("A ", t), "Added", "staged addition");
|
||||
eq(workspaceGitStatusLabel("D ", t), "Deleted", "deletion");
|
||||
eq(workspaceGitStatusLabel("R ", t), "Renamed", "rename");
|
||||
eq(workspaceGitStatusLabel("UU", t), "Conflict", "unmerged conflict");
|
||||
eq(workspaceGitStatusLabel("MM", t), "Modified", "staged and unstaged modification");
|
||||
|
||||
eq(workspaceGitStatusLabel(undefined, t), "", "missing status renders nothing");
|
||||
eq(workspaceGitStatusLabel(" ", t), "", "blank status renders nothing");
|
||||
eq(workspaceGitStatusLabel("!!", t), "!!", "an unmapped code passes through rather than vanishing");
|
||||
|
||||
eq(workspaceGitStatusLabelKey("DD"), "workspace.gitStatusUnmerged", "DD is a merge conflict, not a plain deletion");
|
||||
eq(workspaceGitStatusLabelKey("AA"), "workspace.gitStatusUnmerged", "AA is a merge conflict, not a plain addition");
|
||||
eq(workspaceGitStatusLabelKey("UD"), "workspace.gitStatusUnmerged", "UD is a merge conflict");
|
||||
|
||||
for (const [name, dict] of [["zh", zh], ["zh-TW", zhTW]] as const) {
|
||||
const missing = Object.keys(en)
|
||||
.filter((key) => key.startsWith("workspace.gitStatus"))
|
||||
.filter((key) => !(key in (dict as Record<string, string>)));
|
||||
eq(missing.length, 0, `${name} translates every git status label (${missing.join(", ")})`);
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -201,6 +201,23 @@ async function renderFilesWorkspace(methods: Partial<AppBindings>, props: Partia
|
||||
|
||||
console.log("\nworkspace changes git errors");
|
||||
|
||||
{
|
||||
const { dom, root, rerender } = await renderFilesWorkspace({}, { open: false });
|
||||
let threw = false;
|
||||
try {
|
||||
await rerender({ open: true });
|
||||
} catch (error) {
|
||||
threw = true;
|
||||
process.stdout.write(` ERROR ${String(error)}\n`);
|
||||
}
|
||||
ok(!threw, "workspace panel can open after a closed render without changing hook order");
|
||||
ok(document.querySelector(".workspace-panel") !== null, "workspace panel renders after the closed-to-open transition");
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const { dom, root } = await renderWorkspace({ files: [], gitAvailable: false });
|
||||
await waitFor("git unavailable warning", () => document.body.textContent?.includes("Git status is unavailable for this workspace.") === true);
|
||||
|
||||
@@ -228,10 +228,13 @@ eq(
|
||||
true,
|
||||
"workbench sidebar does not reserve the docked status bar twice",
|
||||
);
|
||||
const workspaceDockTabsSource = appSource.match(/<div className="workbench-dock__tabs"[\s\S]*?<div className="workbench-dock__body">/)?.[0] ?? "";
|
||||
eq(
|
||||
/workbench-dock__tab[\s\S]*?terminalPanelOpen/.test(appSource),
|
||||
workspaceDockTabsSource.length > 0
|
||||
&& !/rightDock\.terminal|terminalPanelOpen|toggleTerminalPanel/.test(workspaceDockTabsSource)
|
||||
&& /className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"[\s\S]*?aria-label=\{t\("rightDock\.terminal"\)\}[\s\S]*?onClick=\{toggleTerminalPanel\}/.test(appSource),
|
||||
true,
|
||||
"terminal tab in workspace dock toggles the independent terminal panel, not rightDockMode",
|
||||
"workspace dock omits the terminal view while the topic bar keeps the terminal drawer action",
|
||||
);
|
||||
eq(
|
||||
/\.topicbar \{\s*position: relative;\s*z-index: var\(--z-inline-sticky\);/.test(stylesSource)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Check, Copy, Images, LockKeyhole, Minus, Plus, RotateCcw } from "lucide
|
||||
import { app } from "../lib/bridge";
|
||||
import { useT, type DictKey } from "../lib/i18n";
|
||||
import { THEME_STYLES, type Theme, type ThemeStyle, isThemeStyle } from "../lib/theme";
|
||||
import type { TerminalThemePreference } from "../lib/terminalTheme";
|
||||
import type { ConversationWidth } from "../lib/conversationWidth";
|
||||
import { TEXT_SIZES, type TextSize } from "../lib/textSize";
|
||||
import { type FontFamily, type MonoFontFamily } from "../lib/fontFamily";
|
||||
@@ -85,6 +86,7 @@ function monoFontFamilyLabel(font: MonoFontFamily, t: ReturnType<typeof useT>):
|
||||
export function AppearanceOverview({
|
||||
theme,
|
||||
themeStyle,
|
||||
terminalTheme,
|
||||
conversationWidth,
|
||||
textSize,
|
||||
showDisplayZoom,
|
||||
@@ -95,6 +97,7 @@ export function AppearanceOverview({
|
||||
customMonoFontName,
|
||||
onTheme,
|
||||
onThemeStyle,
|
||||
onTerminalTheme,
|
||||
onConversationWidth,
|
||||
onTextSize,
|
||||
onRestartZoom,
|
||||
@@ -105,6 +108,7 @@ export function AppearanceOverview({
|
||||
}: {
|
||||
theme: Theme;
|
||||
themeStyle: ThemeStyle;
|
||||
terminalTheme: TerminalThemePreference;
|
||||
conversationWidth: ConversationWidth;
|
||||
textSize: TextSize;
|
||||
showDisplayZoom: boolean;
|
||||
@@ -115,6 +119,7 @@ export function AppearanceOverview({
|
||||
customMonoFontName: string;
|
||||
onTheme: (t: Theme) => void;
|
||||
onThemeStyle: (style: ThemeStyle) => void;
|
||||
onTerminalTheme: (theme: TerminalThemePreference) => void;
|
||||
onConversationWidth: (width: ConversationWidth) => void;
|
||||
onTextSize: (size: TextSize) => void;
|
||||
onRestartZoom: (zoom: ZoomLevel) => Promise<void>;
|
||||
@@ -362,6 +367,32 @@ export function AppearanceOverview({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="appearance-overview__row">
|
||||
<div id="appearance-terminal-theme-label" className="appearance-overview__row-label">{t("settings.terminalTheme")}</div>
|
||||
<div
|
||||
className="set-seg appearance-overview__segmented appearance-overview__segmented--theme"
|
||||
role="radiogroup"
|
||||
aria-labelledby="appearance-terminal-theme-label"
|
||||
>
|
||||
{(["auto", "light", "dark"] as TerminalThemePreference[]).map((opt) => (
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={terminalTheme === opt}
|
||||
className={`set-seg__btn${terminalTheme === opt ? " set-seg__btn--on" : ""}`}
|
||||
onClick={() => onTerminalTheme(opt)}
|
||||
>
|
||||
{opt === "auto"
|
||||
? t("settings.terminalThemeAuto")
|
||||
: opt === "light"
|
||||
? t("settings.terminalThemeLight")
|
||||
: t("settings.terminalThemeDark")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="appearance-overview__row">
|
||||
<div id="appearance-base-style-label" className="appearance-overview__row-label">{t("settings.themeGallery.baseStyle")}</div>
|
||||
<div className="appearance-overview__control-stack">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import gsap from "gsap";
|
||||
import { useT, type Translator } from "../lib/i18n";
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
DecisionConfirmBar,
|
||||
PromptAction,
|
||||
PromptBadge,
|
||||
PromptDescriptionToggle,
|
||||
PromptHeaderAction,
|
||||
PromptShelf,
|
||||
} from "./PromptShelf";
|
||||
@@ -292,12 +293,15 @@ export function ApprovalModal({
|
||||
// Immediate Plan/Auto decisions have no hidden selection. Ordinary tool
|
||||
// approvals retain select-then-confirm and default to Allow once.
|
||||
const [selectedIndex, setSelectedIndex] = useState(() => (isPlanApproval || isRecoveryApproval ? -1 : 0));
|
||||
const [expandedDescriptionId, setExpandedDescriptionId] = useState<string | null>(null);
|
||||
const [descriptionTruncated, setDescriptionTruncated] = useState(false);
|
||||
const [revisionOpen, setRevisionOpen] = useState(false);
|
||||
const [revisionText, setRevisionText] = useState("");
|
||||
const [recoveryGuidanceOpen, setRecoveryGuidanceOpen] = useState(false);
|
||||
const [recoveryGuidanceText, setRecoveryGuidanceText] = useState("");
|
||||
const [grantSimilarForTask, setGrantSimilarForTask] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const instanceId = useId();
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const shelfRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
@@ -475,6 +479,10 @@ export function ApprovalModal({
|
||||
const selectedIndexRef = useRef(selectedIndex);
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
const selectedAction = toolActions[Math.min(selectedIndex, actionCount - 1)] ?? toolActions[0];
|
||||
const selectedDescriptionId = !isPlanApproval && !isRecoveryApproval && selectedIndex >= 0
|
||||
? `${instanceId}-description-${selectedIndex}`
|
||||
: undefined;
|
||||
const descriptionExpanded = selectedDescriptionId !== undefined && expandedDescriptionId === selectedDescriptionId;
|
||||
|
||||
useEffect(() => {
|
||||
cardRef.current?.focus();
|
||||
@@ -489,6 +497,10 @@ export function ApprovalModal({
|
||||
closingRef.current = false;
|
||||
}, [approval.id, isPlanApproval, isRecoveryApproval, reason]);
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedDescriptionId(null);
|
||||
}, [approval.id]);
|
||||
|
||||
const confirmSelected = useCallback(() => {
|
||||
if (submitting || closingRef.current) return;
|
||||
if (isPlanApproval || isRecoveryApproval) return;
|
||||
@@ -798,6 +810,14 @@ export function ApprovalModal({
|
||||
keyLabel={action.key}
|
||||
label={action.label}
|
||||
description={action.desc}
|
||||
descriptionId={`${instanceId}-description-${index}`}
|
||||
descriptionDisclosure
|
||||
descriptionExpanded={!isPlanApproval && !isRecoveryApproval && selectedIndex === index
|
||||
? descriptionExpanded
|
||||
: undefined}
|
||||
onDescriptionOverflowChange={!isPlanApproval && !isRecoveryApproval && selectedIndex === index
|
||||
? setDescriptionTruncated
|
||||
: undefined}
|
||||
onClick={() => {
|
||||
activateAction(action, index);
|
||||
}}
|
||||
@@ -847,7 +867,14 @@ export function ApprovalModal({
|
||||
</>
|
||||
}
|
||||
note={
|
||||
isRecoveryApproval ? (
|
||||
!isPlanApproval && !isRecoveryApproval && selectedDescriptionId && descriptionTruncated ? (
|
||||
<PromptDescriptionToggle
|
||||
descriptionId={selectedDescriptionId}
|
||||
expanded={descriptionExpanded}
|
||||
onToggle={() => setExpandedDescriptionId((current) => current === selectedDescriptionId ? null : selectedDescriptionId)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
) : isRecoveryApproval ? (
|
||||
recoveryGuidanceOpen ? (
|
||||
<div className="recovery-guidance">
|
||||
<textarea
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useT } from "../lib/i18n";
|
||||
import type { QuestionAnswer, WireAsk, WireAskQuestion } from "../lib/types";
|
||||
import {
|
||||
DecisionConfirmBar,
|
||||
PromptAction,
|
||||
PromptDescriptionToggle,
|
||||
PromptHeaderAction,
|
||||
PromptShelf,
|
||||
} from "./PromptShelf";
|
||||
@@ -31,9 +32,12 @@ export function AskCard({
|
||||
// Extra decision rows after option labels: custom answer / skip chat.
|
||||
// selectedIndex indexes options, then custom, then skip.
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [expandedDescriptionId, setExpandedDescriptionId] = useState<string | null>(null);
|
||||
const [descriptionTruncated, setDescriptionTruncated] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const shelfRef = useRef<HTMLDivElement | null>(null);
|
||||
const customInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const instanceId = useId();
|
||||
|
||||
const questions = ask.questions;
|
||||
const q = questions[Math.min(active, questions.length - 1)];
|
||||
@@ -46,6 +50,13 @@ export function AskCard({
|
||||
const customRowIndex = optionCount;
|
||||
const skipRowIndex = optionCount + 1;
|
||||
const rowCount = optionCount + 2;
|
||||
const selectedOption = selectedIndex >= 0 && selectedIndex < optionCount
|
||||
? q?.options[selectedIndex]
|
||||
: undefined;
|
||||
const selectedDescriptionId = selectedOption?.description
|
||||
? `${instanceId}-description-${selectedIndex}`
|
||||
: undefined;
|
||||
const descriptionExpanded = selectedDescriptionId !== undefined && expandedDescriptionId === selectedDescriptionId;
|
||||
|
||||
useEffect(() => {
|
||||
shelfRef.current?.focus();
|
||||
@@ -62,6 +73,10 @@ export function AskCard({
|
||||
setSelectedIndex(0);
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedDescriptionId(null);
|
||||
}, [active, ask.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (customOpen) customInputRef.current?.focus();
|
||||
}, [customOpen]);
|
||||
@@ -276,9 +291,14 @@ export function AskCard({
|
||||
return (
|
||||
<PromptAction
|
||||
key={o.label}
|
||||
actionId={`${instanceId}-row-${index}`}
|
||||
keyLabel={q.options.length <= 9 ? String(index + 1) : ""}
|
||||
label={o.label}
|
||||
description={o.description}
|
||||
descriptionId={`${instanceId}-description-${index}`}
|
||||
descriptionDisclosure
|
||||
descriptionExpanded={selectedIndex === index && descriptionExpanded}
|
||||
onDescriptionOverflowChange={selectedIndex === index ? setDescriptionTruncated : undefined}
|
||||
onClick={() => selectRow(index)}
|
||||
// Single-select: cursor owns selection. Multi-select: selected
|
||||
// means checked; active is the keyboard cursor only.
|
||||
@@ -290,6 +310,7 @@ export function AskCard({
|
||||
);
|
||||
})}
|
||||
<PromptAction
|
||||
actionId={`${instanceId}-row-${customRowIndex}`}
|
||||
keyLabel=""
|
||||
label={t("ask.customAnswer")}
|
||||
description={t("ask.customAnswerDesc")}
|
||||
@@ -298,6 +319,7 @@ export function AskCard({
|
||||
disabled={submitting}
|
||||
/>
|
||||
<PromptAction
|
||||
actionId={`${instanceId}-row-${skipRowIndex}`}
|
||||
keyLabel=""
|
||||
label={t("ask.justChat")}
|
||||
description={t("ask.justChatDesc")}
|
||||
@@ -324,6 +346,16 @@ export function AskCard({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
note={
|
||||
selectedDescriptionId && descriptionTruncated ? (
|
||||
<PromptDescriptionToggle
|
||||
descriptionId={selectedDescriptionId}
|
||||
expanded={descriptionExpanded}
|
||||
onToggle={() => setExpandedDescriptionId((current) => current === selectedDescriptionId ? null : selectedDescriptionId)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
footer={
|
||||
<DecisionConfirmBar
|
||||
hint={t("decision.selectHint")}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useT } from "../lib/i18n";
|
||||
import {
|
||||
DecisionConfirmBar,
|
||||
PromptAction,
|
||||
PromptBadge,
|
||||
PromptDescriptionToggle,
|
||||
PromptShelf,
|
||||
} from "./PromptShelf";
|
||||
|
||||
@@ -18,7 +19,10 @@ export function ClearContextCard({
|
||||
const shelfRef = useRef<HTMLDivElement | null>(null);
|
||||
// Default safe choice: cancel.
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [expandedDescriptionId, setExpandedDescriptionId] = useState<string | null>(null);
|
||||
const [descriptionTruncated, setDescriptionTruncated] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const instanceId = useId();
|
||||
const selectedIndexRef = useRef(0);
|
||||
selectedIndexRef.current = selectedIndex;
|
||||
const submittingRef = useRef(false);
|
||||
@@ -42,6 +46,8 @@ export function ClearContextCard({
|
||||
tone: "danger" as const,
|
||||
},
|
||||
];
|
||||
const selectedDescriptionId = `${instanceId}-description-${selectedIndex}`;
|
||||
const descriptionExpanded = expandedDescriptionId === selectedDescriptionId;
|
||||
|
||||
useEffect(() => {
|
||||
shelfRef.current?.focus();
|
||||
@@ -111,6 +117,10 @@ export function ClearContextCard({
|
||||
keyLabel={action.key}
|
||||
label={action.label}
|
||||
description={action.desc}
|
||||
descriptionId={`${instanceId}-description-${index}`}
|
||||
descriptionDisclosure
|
||||
descriptionExpanded={selectedIndex === index && descriptionExpanded}
|
||||
onDescriptionOverflowChange={selectedIndex === index ? setDescriptionTruncated : undefined}
|
||||
onClick={() => {
|
||||
if (submitting) return;
|
||||
setSelectedIndex(index);
|
||||
@@ -122,6 +132,16 @@ export function ClearContextCard({
|
||||
))}
|
||||
</>
|
||||
}
|
||||
note={
|
||||
descriptionTruncated ? (
|
||||
<PromptDescriptionToggle
|
||||
descriptionId={selectedDescriptionId}
|
||||
expanded={descriptionExpanded}
|
||||
onToggle={() => setExpandedDescriptionId((current) => current === selectedDescriptionId ? null : selectedDescriptionId)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
footer={
|
||||
<DecisionConfirmBar
|
||||
hint={t("decision.selectHint")}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
.compact-ratio-controls,
|
||||
.compact-ratio-custom-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.compact-ratio-presets {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.compact-ratio-presets .set-seg__btn {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
padding: 6px 8px;
|
||||
text-transform: none;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.compact-ratio-preset__percent {
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.compact-ratio-preset__caption {
|
||||
font-size: var(--text-2xs);
|
||||
}
|
||||
|
||||
.compact-ratio-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.compact-ratio-current {
|
||||
color: var(--fg);
|
||||
font-size: var(--font-control-small);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.compact-ratio-custom-toggle[aria-expanded="true"] {
|
||||
background: var(--bg-soft);
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.compact-ratio-impact,
|
||||
.compact-ratio-custom__hint,
|
||||
.compact-ratio-custom__suffix {
|
||||
color: var(--fg-dim);
|
||||
font-size: var(--text-2xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.compact-ratio-custom-panel {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.compact-ratio-custom {
|
||||
justify-content: flex-start;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.compact-ratio-custom .mem-input {
|
||||
flex: 0 0 88px;
|
||||
}
|
||||
|
||||
.compact-ratio-custom__hint--invalid {
|
||||
color: var(--danger);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type ComposerInvocation,
|
||||
type StructuredInvocationSubmit,
|
||||
} from "../lib/invocationDisplay";
|
||||
import { formatTokens } from "../lib/format";
|
||||
import { clearLayoutSize, loadOptionalLayoutSize, saveLayoutSize } from "../lib/layoutPreferences";
|
||||
import { createRafResizeUpdater } from "../lib/resizeDrag";
|
||||
import { observeComposerMenuViewport } from "../lib/composerMenuViewport";
|
||||
@@ -375,11 +376,6 @@ function loadComposerHeight(): number | null {
|
||||
return loadOptionalLayoutSize("composerHeight", clampComposerHeight);
|
||||
}
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function fmtElapsed(ms: number): string {
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
@@ -572,6 +568,7 @@ export function Composer({
|
||||
guidanceConsumedText,
|
||||
guidanceQueuePreviewItems,
|
||||
showContextWindowRing = false,
|
||||
heroMode = false,
|
||||
context,
|
||||
turnCost,
|
||||
currency,
|
||||
@@ -643,6 +640,9 @@ export function Composer({
|
||||
guidanceConsumedText?: string;
|
||||
guidanceQueuePreviewItems?: readonly string[];
|
||||
showContextWindowRing?: boolean;
|
||||
// Creation empty-session hero: slim centered composer under the welcome
|
||||
// headline (hides task/profile/approval chrome; keeps model + effort).
|
||||
heroMode?: boolean;
|
||||
context?: ContextInfo;
|
||||
turnCost?: number;
|
||||
currency?: string;
|
||||
@@ -740,6 +740,10 @@ export function Composer({
|
||||
const intentCloseTimerRef = useRef<number | null>(null);
|
||||
const profileCloseTimerRef = useRef<number | null>(null);
|
||||
const moreCloseTimerRef = useRef<number | null>(null);
|
||||
// Creation chrome: hover-open task/profile menus (same pattern as ContextWindowRing).
|
||||
const intentHoverTimerRef = useRef<number | null>(null);
|
||||
const profileHoverTimerRef = useRef<number | null>(null);
|
||||
const creationChrome = showContextWindowRing;
|
||||
const wasRunningByDraftRef = useRef<Record<string, boolean>>({ [draftKey]: running });
|
||||
const composingRef = useRef(false);
|
||||
const lastCompositionEndAt = useRef(0);
|
||||
@@ -1410,7 +1414,10 @@ export function Composer({
|
||||
],
|
||||
[includePastChatsItem, atMatches],
|
||||
);
|
||||
|
||||
const atMenuItemKey = useCallback(
|
||||
(item: AtMenuItem) => item.kind === "pastChats" ? "past:chats" : (item.entry.isDir ? "d:" : "f:") + (item.entry.path || item.entry.name),
|
||||
[],
|
||||
);
|
||||
|
||||
// --- which menu (if any) is open --- (slash command names win; then slash
|
||||
// arguments; then @-refs — they're rarely valid at once)
|
||||
@@ -1852,8 +1859,29 @@ export function Composer({
|
||||
intentCloseTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const clearProfileCloseTimer = useCallback(() => {
|
||||
if (profileCloseTimerRef.current === null) return;
|
||||
window.clearTimeout(profileCloseTimerRef.current);
|
||||
profileCloseTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Hover timers only touch refs — no useCallback cross-deps (avoids TDZ on HMR).
|
||||
const clearHoverTimer = (timerRef: { current: number | null }) => {
|
||||
if (timerRef.current == null) return;
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
};
|
||||
|
||||
const openIntentMenu = useCallback(() => {
|
||||
clearIntentCloseTimer();
|
||||
clearHoverTimer(intentHoverTimerRef);
|
||||
clearHoverTimer(profileHoverTimerRef);
|
||||
if (profileCloseTimerRef.current != null) {
|
||||
window.clearTimeout(profileCloseTimerRef.current);
|
||||
profileCloseTimerRef.current = null;
|
||||
}
|
||||
setProfileMenuOpen(false);
|
||||
setProfileMenuClosing(false);
|
||||
setContentMenuOpen(false);
|
||||
setDirectPastChats(false);
|
||||
setDismissed(true);
|
||||
@@ -1863,6 +1891,7 @@ export function Composer({
|
||||
|
||||
const closeIntentMenu = useCallback((afterClose?: () => void) => {
|
||||
clearIntentCloseTimer();
|
||||
clearHoverTimer(intentHoverTimerRef);
|
||||
setIntentMenuClosing(true);
|
||||
window.requestAnimationFrame(() => setIntentMenuOpen(false));
|
||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
@@ -1873,16 +1902,16 @@ export function Composer({
|
||||
}, reduceMotion ? 0 : ANCHORED_POPOVER_CLOSE_MS);
|
||||
}, [clearIntentCloseTimer]);
|
||||
|
||||
useEffect(() => () => clearIntentCloseTimer(), [clearIntentCloseTimer]);
|
||||
|
||||
const clearProfileCloseTimer = useCallback(() => {
|
||||
if (profileCloseTimerRef.current === null) return;
|
||||
window.clearTimeout(profileCloseTimerRef.current);
|
||||
profileCloseTimerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const openProfileMenu = useCallback(() => {
|
||||
clearProfileCloseTimer();
|
||||
clearHoverTimer(profileHoverTimerRef);
|
||||
clearHoverTimer(intentHoverTimerRef);
|
||||
if (intentCloseTimerRef.current != null) {
|
||||
window.clearTimeout(intentCloseTimerRef.current);
|
||||
intentCloseTimerRef.current = null;
|
||||
}
|
||||
setIntentMenuOpen(false);
|
||||
setIntentMenuClosing(false);
|
||||
setContentMenuOpen(false);
|
||||
setDirectPastChats(false);
|
||||
setDismissed(true);
|
||||
@@ -1892,6 +1921,7 @@ export function Composer({
|
||||
|
||||
const closeProfileMenu = useCallback((afterClose?: () => void) => {
|
||||
clearProfileCloseTimer();
|
||||
clearHoverTimer(profileHoverTimerRef);
|
||||
setProfileMenuClosing(true);
|
||||
window.requestAnimationFrame(() => setProfileMenuOpen(false));
|
||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
@@ -1902,7 +1932,60 @@ export function Composer({
|
||||
}, reduceMotion ? 0 : ANCHORED_POPOVER_CLOSE_MS);
|
||||
}, [clearProfileCloseTimer]);
|
||||
|
||||
useEffect(() => () => clearProfileCloseTimer(), [clearProfileCloseTimer]);
|
||||
useEffect(() => () => {
|
||||
clearIntentCloseTimer();
|
||||
clearHoverTimer(intentHoverTimerRef);
|
||||
clearProfileCloseTimer();
|
||||
clearHoverTimer(profileHoverTimerRef);
|
||||
}, [clearIntentCloseTimer, clearProfileCloseTimer]);
|
||||
|
||||
const onIntentHoverEnter = useCallback(() => {
|
||||
if (!creationChrome || disabled || running) return;
|
||||
clearHoverTimer(intentHoverTimerRef);
|
||||
intentHoverTimerRef.current = window.setTimeout(() => {
|
||||
intentHoverTimerRef.current = null;
|
||||
openIntentMenu();
|
||||
}, 120);
|
||||
}, [creationChrome, disabled, openIntentMenu, running]);
|
||||
|
||||
const onIntentHoverLeave = useCallback(() => {
|
||||
if (!creationChrome) return;
|
||||
clearHoverTimer(intentHoverTimerRef);
|
||||
if (!intentMenuOpen && !intentMenuClosing) return;
|
||||
intentHoverTimerRef.current = window.setTimeout(() => {
|
||||
intentHoverTimerRef.current = null;
|
||||
closeIntentMenu();
|
||||
}, 140);
|
||||
}, [closeIntentMenu, creationChrome, intentMenuClosing, intentMenuOpen]);
|
||||
|
||||
const onIntentPopoverEnter = useCallback(() => {
|
||||
if (!creationChrome) return;
|
||||
clearHoverTimer(intentHoverTimerRef);
|
||||
}, [creationChrome]);
|
||||
|
||||
const onProfileHoverEnter = useCallback(() => {
|
||||
if (!creationChrome || disabled || running) return;
|
||||
clearHoverTimer(profileHoverTimerRef);
|
||||
profileHoverTimerRef.current = window.setTimeout(() => {
|
||||
profileHoverTimerRef.current = null;
|
||||
openProfileMenu();
|
||||
}, 120);
|
||||
}, [creationChrome, disabled, openProfileMenu, running]);
|
||||
|
||||
const onProfileHoverLeave = useCallback(() => {
|
||||
if (!creationChrome) return;
|
||||
clearHoverTimer(profileHoverTimerRef);
|
||||
if (!profileMenuOpen && !profileMenuClosing) return;
|
||||
profileHoverTimerRef.current = window.setTimeout(() => {
|
||||
profileHoverTimerRef.current = null;
|
||||
closeProfileMenu();
|
||||
}, 140);
|
||||
}, [closeProfileMenu, creationChrome, profileMenuClosing, profileMenuOpen]);
|
||||
|
||||
const onProfilePopoverEnter = useCallback(() => {
|
||||
if (!creationChrome) return;
|
||||
clearHoverTimer(profileHoverTimerRef);
|
||||
}, [creationChrome]);
|
||||
|
||||
const clearMoreCloseTimer = useCallback(() => {
|
||||
if (moreCloseTimerRef.current === null) return;
|
||||
@@ -2759,6 +2842,26 @@ export function Composer({
|
||||
setTextareaAutoOverflow(false);
|
||||
return;
|
||||
}
|
||||
// Creation empty hero starts single-line but must grow so multi-line drafts
|
||||
// stay readable before send (review: fixed 20px + overflow:hidden clipped).
|
||||
if (heroMode) {
|
||||
const node = taRef.current;
|
||||
if (!node) {
|
||||
setTextareaAutoHeight(20);
|
||||
setTextareaAutoOverflow(false);
|
||||
return;
|
||||
}
|
||||
const previousHeight = node.style.height;
|
||||
node.style.height = "auto";
|
||||
const scrollHeight = node.scrollHeight || 20;
|
||||
const maxHeight = 96;
|
||||
const nextHeight = Math.min(Math.max(scrollHeight, 20), maxHeight);
|
||||
const nextOverflow = scrollHeight > maxHeight + 1;
|
||||
node.style.height = previousHeight;
|
||||
setTextareaAutoHeight((current) => (current === nextHeight ? current : nextHeight));
|
||||
setTextareaAutoOverflow((current) => (current === nextOverflow ? current : nextOverflow));
|
||||
return;
|
||||
}
|
||||
const richHeight = invocationsRef.current.length > 0 ? richInputRef.current?.scrollHeight() : 0;
|
||||
const node = taRef.current;
|
||||
if (!richHeight && !node) return;
|
||||
@@ -2771,7 +2874,7 @@ export function Composer({
|
||||
if (node && previousHeight !== undefined) node.style.height = previousHeight;
|
||||
setTextareaAutoHeight((current) => (current === nextHeight ? current : nextHeight));
|
||||
setTextareaAutoOverflow((current) => (current === nextOverflow ? current : nextOverflow));
|
||||
}, [composerHeight, invocations.length]);
|
||||
}, [composerHeight, heroMode, invocations.length]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measureTextareaAutoHeight();
|
||||
@@ -3555,7 +3658,7 @@ export function Composer({
|
||||
const words = SPINNER_WORDS[locale];
|
||||
const word = words[Math.floor(elapsedMs / 3000) % words.length];
|
||||
const liveTokens = (turnTokens ?? 0) + Math.round((turnArgChars ?? 0) / 4);
|
||||
const tok = liveTokens > 0 ? ` · ↓ ${fmtTokens(liveTokens)} ${t("status.tokens")}` : "";
|
||||
const tok = liveTokens > 0 ? ` · ↓ ${formatTokens(liveTokens)} ${t("status.tokens")}` : "";
|
||||
return `${word}… ${fmtElapsed(elapsedMs)}${tok}`;
|
||||
})()
|
||||
: null;
|
||||
@@ -3649,7 +3752,11 @@ export function Composer({
|
||||
return (
|
||||
<div
|
||||
ref={composerWrapRef}
|
||||
className={`composer-wrap${decisionPending ? " composer-wrap--decision-pending" : ""}`}
|
||||
className={[
|
||||
"composer-wrap",
|
||||
decisionPending ? "composer-wrap--decision-pending" : "",
|
||||
heroMode ? "composer-wrap--hero" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
style={{ "--wails-drop-target": "drop" } as CSSProperties}
|
||||
onDropCapture={onFileDropCapture}
|
||||
>
|
||||
@@ -3712,7 +3819,7 @@ export function Composer({
|
||||
</button>
|
||||
</div>
|
||||
</AnchoredPopover>
|
||||
<AnchoredPopover
|
||||
{!heroMode && <AnchoredPopover
|
||||
open={intentMenuOpen}
|
||||
closing={intentMenuClosing}
|
||||
anchorRef={intentMenuAnchorRef}
|
||||
@@ -3720,7 +3827,13 @@ export function Composer({
|
||||
className="composer-access-menu composer-intent-menu"
|
||||
align="start"
|
||||
>
|
||||
<div className="composer-access-menu__section" role="menu" aria-label={t("composer.intentMenuTitle")}>
|
||||
<div
|
||||
className="composer-access-menu__section"
|
||||
role="menu"
|
||||
aria-label={t("composer.intentMenuTitle")}
|
||||
onMouseEnter={creationChrome ? onIntentPopoverEnter : undefined}
|
||||
onMouseLeave={creationChrome ? onIntentHoverLeave : undefined}
|
||||
>
|
||||
<div className="composer-access-menu__label">{t("composer.intentMenuTitle")}</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -3768,7 +3881,7 @@ export function Composer({
|
||||
</span>
|
||||
{goalModeOn && <Check className="composer-intent-menu__check" size={16} aria-hidden="true" />}
|
||||
</button>
|
||||
{goalModeOn && activeGoal && (
|
||||
{goalModeOn && activeGoal && (
|
||||
<button
|
||||
type="button"
|
||||
className="composer-intent-menu__stop"
|
||||
@@ -3779,8 +3892,8 @@ export function Composer({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</AnchoredPopover>
|
||||
<AnchoredPopover
|
||||
</AnchoredPopover>}
|
||||
{!heroMode && <AnchoredPopover
|
||||
open={profileMenuOpen}
|
||||
closing={profileMenuClosing}
|
||||
anchorRef={profileMenuAnchorRef}
|
||||
@@ -3788,7 +3901,13 @@ export function Composer({
|
||||
className="composer-access-menu composer-profile-menu"
|
||||
align="start"
|
||||
>
|
||||
<div className="composer-access-menu__section" role="menu" aria-label={t("composer.runtimeProfileTitle")}>
|
||||
<div
|
||||
className="composer-access-menu__section"
|
||||
role="menu"
|
||||
aria-label={t("composer.runtimeProfileTitle")}
|
||||
onMouseEnter={creationChrome ? onProfilePopoverEnter : undefined}
|
||||
onMouseLeave={creationChrome ? onProfileHoverLeave : undefined}
|
||||
>
|
||||
<div className="composer-access-menu__label">{t("composer.runtimeProfileTitle")}</div>
|
||||
{([
|
||||
["economy", Gauge, "composer.runtimeProfileEconomy", "composer.runtimeProfileEconomyDesc"],
|
||||
@@ -3814,7 +3933,7 @@ export function Composer({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AnchoredPopover>
|
||||
</AnchoredPopover>}
|
||||
<AnchoredPopover
|
||||
open={moreMenuOpen && !disabled && !running}
|
||||
closing={moreMenuClosing}
|
||||
@@ -3963,7 +4082,7 @@ export function Composer({
|
||||
<VirtualMenu
|
||||
items={atMenuItems}
|
||||
activeIndex={active}
|
||||
itemKey={(it) => (it.kind === "pastChats" ? "past:chats" : (it.entry.isDir ? "d:" : "f:") + (it.entry.path || it.entry.name))}
|
||||
itemKey={atMenuItemKey}
|
||||
renderItem={(it, i) =>
|
||||
it.kind === "pastChats" ? (
|
||||
<button
|
||||
@@ -4358,110 +4477,122 @@ export function Composer({
|
||||
/>
|
||||
<div className={composerMetaClass}>
|
||||
<div className="composer-meta__params">
|
||||
<div className="composer-meta__control composer-meta__control--content">
|
||||
<Tooltip label={t("composer.contentMenuTitle")} disabled={contentMenuOpen}>
|
||||
<button
|
||||
ref={contentMenuAnchorRef}
|
||||
type="button"
|
||||
className={`composer-content-trigger${contentMenuOpen ? " composer-content-trigger--open" : ""}`}
|
||||
onClick={() => (contentMenuOpen ? setContentMenuOpen(false) : openContentMenu())}
|
||||
disabled={disabled || readOnly || running}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={contentMenuOpen}
|
||||
aria-label={t("composer.contentMenuTitle")}
|
||||
>
|
||||
<Plus size={17} strokeWidth={1.8} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="composer-meta__control composer-meta__control--intent">
|
||||
<Tooltip label={taskModeTooltipLabel} disabled={intentMenuOpen || intentMenuClosing}>
|
||||
<button
|
||||
ref={intentMenuAnchorRef}
|
||||
type="button"
|
||||
className={`composer-task-mode-trigger${intentMenuOpen || intentMenuClosing ? " composer-task-mode-trigger--open" : ""}`}
|
||||
onClick={() => (intentMenuOpen || intentMenuClosing ? closeIntentMenu() : openIntentMenu())}
|
||||
disabled={disabled || running}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={intentMenuOpen && !intentMenuClosing}
|
||||
aria-label={taskModeTriggerLabel}
|
||||
title={intentMenuOpen || intentMenuClosing ? undefined : taskModeTriggerLabel}
|
||||
>
|
||||
<TaskModeIcon size={14} aria-hidden="true" />
|
||||
<span className="composer-task-mode-trigger__value">{t(taskModeShortKey)}</span>
|
||||
<ChevronsUpDown size={11} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="composer-meta__control composer-meta__control--profile">
|
||||
<Tooltip label={runtimeProfileTooltipLabel} disabled={profileMenuOpen || profileMenuClosing}>
|
||||
<button
|
||||
ref={profileMenuAnchorRef}
|
||||
type="button"
|
||||
data-profile={tokenMode}
|
||||
className={`composer-profile-trigger${profileMenuOpen || profileMenuClosing ? " composer-profile-trigger--open" : ""}`}
|
||||
onClick={() => (profileMenuOpen || profileMenuClosing ? closeProfileMenu() : openProfileMenu())}
|
||||
disabled={disabled || running}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={profileMenuOpen && !profileMenuClosing}
|
||||
aria-label={runtimeProfileTriggerLabel}
|
||||
title={profileMenuOpen || profileMenuClosing ? undefined : runtimeProfileTriggerLabel}
|
||||
>
|
||||
<RuntimeProfileIcon size={14} strokeWidth={1.75} aria-hidden="true" />
|
||||
<span className="composer-profile-trigger__label">
|
||||
<span className="composer-profile-trigger__value">{t(runtimeProfileShortKey)}</span>
|
||||
</span>
|
||||
<ChevronsUpDown size={11} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</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", { shortcut: yoloComboLabel })}
|
||||
>
|
||||
<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={approvalBarDisabled}
|
||||
aria-pressed={toolApprovalMode === "ask"}
|
||||
title={t("composer.accessAskTitle")}
|
||||
>
|
||||
<Shield size={14} />
|
||||
<span>{t("composer.modeAsk")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`composer-modebar__item composer-modebar__item--auto${toolApprovalMode === "auto" ? " composer-modebar__item--active" : ""}`}
|
||||
onClick={() => chooseApprovalMode("auto")}
|
||||
disabled={approvalBarDisabled}
|
||||
aria-pressed={toolApprovalMode === "auto"}
|
||||
title={t("composer.accessAutoTitle")}
|
||||
>
|
||||
<ShieldCheck size={14} />
|
||||
<span>{t("composer.modeNormal")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`composer-modebar__item composer-modebar__item--yolo${toolApprovalMode === "yolo" ? " composer-modebar__item--active" : ""}`}
|
||||
onClick={() => chooseApprovalMode("yolo")}
|
||||
disabled={approvalBarDisabled}
|
||||
aria-pressed={toolApprovalMode === "yolo"}
|
||||
title={t("composer.accessYoloTitle", { shortcut: yoloComboLabel })}
|
||||
>
|
||||
<ShieldAlert size={14} />
|
||||
<span>{t("composer.modeYolo")}</span>
|
||||
</button>
|
||||
{!heroMode && (
|
||||
<div className="composer-meta__control composer-meta__control--content">
|
||||
<Tooltip label={t("composer.contentMenuTitle")} disabled={contentMenuOpen}>
|
||||
<button
|
||||
ref={contentMenuAnchorRef}
|
||||
type="button"
|
||||
className={`composer-content-trigger${contentMenuOpen ? " composer-content-trigger--open" : ""}`}
|
||||
onClick={() => (contentMenuOpen ? setContentMenuOpen(false) : openContentMenu())}
|
||||
disabled={disabled || readOnly || running}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={contentMenuOpen}
|
||||
aria-label={t("composer.contentMenuTitle")}
|
||||
>
|
||||
<Plus size={17} strokeWidth={1.8} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<span className="composer-meta__divider" aria-hidden="true" />
|
||||
)}
|
||||
{!heroMode && (
|
||||
<div className="composer-meta__control composer-meta__control--intent">
|
||||
<Tooltip label={taskModeTooltipLabel} disabled={intentMenuOpen || intentMenuClosing || creationChrome}>
|
||||
<button
|
||||
ref={intentMenuAnchorRef}
|
||||
type="button"
|
||||
className={`composer-task-mode-trigger${intentMenuOpen || intentMenuClosing ? " composer-task-mode-trigger--open" : ""}`}
|
||||
onClick={() => (intentMenuOpen || intentMenuClosing ? closeIntentMenu() : openIntentMenu())}
|
||||
onMouseEnter={creationChrome ? onIntentHoverEnter : undefined}
|
||||
onMouseLeave={creationChrome ? onIntentHoverLeave : undefined}
|
||||
disabled={disabled || running}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={intentMenuOpen && !intentMenuClosing}
|
||||
aria-label={taskModeTriggerLabel}
|
||||
title={intentMenuOpen || intentMenuClosing || creationChrome ? undefined : taskModeTriggerLabel}
|
||||
>
|
||||
<TaskModeIcon size={14} aria-hidden="true" />
|
||||
<span className="composer-task-mode-trigger__value">{t(taskModeShortKey)}</span>
|
||||
<ChevronsUpDown size={11} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{!heroMode && (
|
||||
<div className="composer-meta__control composer-meta__control--profile">
|
||||
<Tooltip label={runtimeProfileTooltipLabel} disabled={profileMenuOpen || profileMenuClosing || creationChrome}>
|
||||
<button
|
||||
ref={profileMenuAnchorRef}
|
||||
type="button"
|
||||
data-profile={tokenMode}
|
||||
className={`composer-profile-trigger${profileMenuOpen || profileMenuClosing ? " composer-profile-trigger--open" : ""}`}
|
||||
onClick={() => (profileMenuOpen || profileMenuClosing ? closeProfileMenu() : openProfileMenu())}
|
||||
onMouseEnter={creationChrome ? onProfileHoverEnter : undefined}
|
||||
onMouseLeave={creationChrome ? onProfileHoverLeave : undefined}
|
||||
disabled={disabled || running}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={profileMenuOpen && !profileMenuClosing}
|
||||
aria-label={runtimeProfileTriggerLabel}
|
||||
title={profileMenuOpen || profileMenuClosing || creationChrome ? undefined : runtimeProfileTriggerLabel}
|
||||
>
|
||||
<RuntimeProfileIcon size={14} strokeWidth={1.75} aria-hidden="true" />
|
||||
<span className="composer-profile-trigger__label">
|
||||
<span className="composer-profile-trigger__value">{t(runtimeProfileShortKey)}</span>
|
||||
</span>
|
||||
<ChevronsUpDown size={11} aria-hidden="true" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{!heroMode && (
|
||||
<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", { shortcut: yoloComboLabel })}
|
||||
>
|
||||
<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={approvalBarDisabled}
|
||||
aria-pressed={toolApprovalMode === "ask"}
|
||||
title={t("composer.accessAskTitle")}
|
||||
>
|
||||
<Shield size={14} />
|
||||
<span>{t("composer.modeAsk")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`composer-modebar__item composer-modebar__item--auto${toolApprovalMode === "auto" ? " composer-modebar__item--active" : ""}`}
|
||||
onClick={() => chooseApprovalMode("auto")}
|
||||
disabled={approvalBarDisabled}
|
||||
aria-pressed={toolApprovalMode === "auto"}
|
||||
title={t("composer.accessAutoTitle")}
|
||||
>
|
||||
<ShieldCheck size={14} />
|
||||
<span>{t("composer.modeNormal")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`composer-modebar__item composer-modebar__item--yolo${toolApprovalMode === "yolo" ? " composer-modebar__item--active" : ""}`}
|
||||
onClick={() => chooseApprovalMode("yolo")}
|
||||
disabled={approvalBarDisabled}
|
||||
aria-pressed={toolApprovalMode === "yolo"}
|
||||
title={t("composer.accessYoloTitle", { shortcut: yoloComboLabel })}
|
||||
>
|
||||
<ShieldAlert size={14} />
|
||||
<span>{t("composer.modeYolo")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!heroMode && <span className="composer-meta__divider" aria-hidden="true" />}
|
||||
<div className="composer-meta__control composer-meta__control--model">
|
||||
{/*
|
||||
Creation-only: showContextWindowRing is wired to sidebarCreation
|
||||
@@ -4471,7 +4602,7 @@ export function Composer({
|
||||
ever surface this ring in another layout, its font sizes already
|
||||
scale via --font-scale (see .context-ring-popover in styles.css).
|
||||
*/}
|
||||
{showContextWindowRing && (
|
||||
{!heroMode && showContextWindowRing && (
|
||||
<ContextWindowRing
|
||||
enabled={showContextWindowRing}
|
||||
context={context}
|
||||
@@ -4485,12 +4616,12 @@ export function Composer({
|
||||
)}
|
||||
<ModelSwitcher label={modelLabel} tabId={tabId} onPick={onSwitchModel} />
|
||||
</div>
|
||||
{hasEffort && (
|
||||
{!heroMode && hasEffort && (
|
||||
<div className="composer-meta__control composer-meta__control--effort">
|
||||
<EffortSwitcher effort={effort} disabled={running} onPick={onSetEffort} />
|
||||
</div>
|
||||
)}
|
||||
{hasEffort && (
|
||||
{!heroMode && hasEffort && (
|
||||
<div className="composer-meta__control composer-meta__control--more">
|
||||
<Tooltip label={compactEffortTitle} disabled={moreMenuOpen || moreMenuClosing}>
|
||||
<button
|
||||
|
||||
@@ -5,6 +5,7 @@ import { asArray } from "../lib/array";
|
||||
import { app } from "../lib/bridge";
|
||||
import { useI18n, type Locale, type Translator } from "../lib/i18n";
|
||||
import { formatMoneyLocalized } from "../lib/money";
|
||||
import { formatTokens, formatOptionalTokens } from "../lib/format";
|
||||
import type { DictKey } from "../locales/en";
|
||||
import type { BalanceInfo, ContextInfo, ContextPanelInfo, UsageSourceStats, WireUsage } from "../lib/types";
|
||||
|
||||
@@ -27,10 +28,6 @@ interface ContextPanelProps {
|
||||
usageSeq?: number;
|
||||
}
|
||||
|
||||
function fmtFullTokens(n: number): string {
|
||||
if (n <= 0) return "0";
|
||||
return String(Math.round(n));
|
||||
}
|
||||
|
||||
function fmtDuration(ms: number, t: Translator): string {
|
||||
if (ms <= 0) return "-";
|
||||
@@ -41,10 +38,6 @@ function fmtDuration(ms: number, t: Translator): string {
|
||||
return t("context.durationMinutesSeconds", { minutes, seconds });
|
||||
}
|
||||
|
||||
function fmtOptionalTokens(tokens?: number): string {
|
||||
if (typeof tokens !== "number" || tokens <= 0) return "-";
|
||||
return tokens.toLocaleString();
|
||||
}
|
||||
|
||||
interface MetricTokenDisplay {
|
||||
display: string;
|
||||
@@ -274,6 +267,7 @@ export interface ContextSourceRow {
|
||||
cost: number;
|
||||
currency?: string;
|
||||
requests: number;
|
||||
estimated: boolean;
|
||||
}
|
||||
|
||||
export function contextSourceRows(info: ContextPanelInfo | null, sessionCurrency?: string): ContextSourceRow[] {
|
||||
@@ -305,6 +299,7 @@ export function contextSourceRows(info: ContextPanelInfo | null, sessionCurrency
|
||||
cost: sourceCost(stats),
|
||||
currency: stats.sessionCurrency || sessionCurrency || info?.sessionCurrency,
|
||||
requests: stats.requestCount ?? 0,
|
||||
estimated: stats.estimated === true,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -411,12 +406,15 @@ export function ContextPanel({
|
||||
const requestCount = info?.requestCount && info.requestCount > 0 ? info.requestCount : derivedRequestCount;
|
||||
const windowStatus = contextWindowStatus(usagePct, compactPct);
|
||||
const balanceLabel = balance?.available && balance.display ? balance.display : "-";
|
||||
const turnCostLabel = formatMoneyLocalized(turnCost, sessionCurrency, { locale, empty: "dash" });
|
||||
const sessionCostLabel = formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" });
|
||||
const turnEstimated = usage?.estimated === true || info?.estimated === true;
|
||||
const sessionEstimated = info?.sessionEstimated === true || context?.estimated === true;
|
||||
const markEstimated = (value: string, estimated: boolean) => estimated && value !== "-" ? `≈${value}` : value;
|
||||
const turnCostLabel = markEstimated(formatMoneyLocalized(turnCost, sessionCurrency, { locale, empty: "dash" }), turnEstimated);
|
||||
const sessionCostLabel = markEstimated(formatMoneyLocalized(cost.amount, cost.currency, { locale, empty: "dash" }), sessionEstimated);
|
||||
const totalTokensTitle = totalTokensMetric.exact === "-" ? "-" : t("context.tokensValue", { value: totalTokensMetric.exact });
|
||||
const usedLabel = fmtFullTokens(usedTokens);
|
||||
const windowLabel = fmtFullTokens(windowTokens);
|
||||
const compactRemainingLabel = tokensUntilCompact > 0 ? fmtFullTokens(tokensUntilCompact) : "0";
|
||||
const usedLabel = formatTokens(usedTokens);
|
||||
const windowLabel = formatTokens(windowTokens);
|
||||
const compactRemainingLabel = tokensUntilCompact > 0 ? formatTokens(tokensUntilCompact) : "0";
|
||||
const compactMarkerPct = Math.max(0, Math.min(100, compactPct));
|
||||
const usageMarkerPct = Math.max(6, Math.min(94, usagePct));
|
||||
const compactLabelPct = Math.max(6, Math.min(94, compactMarkerPct));
|
||||
@@ -438,7 +436,7 @@ export function ContextPanel({
|
||||
const totalMetric = formatMetricTokens(sourceTokenTotal(row), locale);
|
||||
const cacheReported = row.cacheHitTokens + row.cacheMissTokens > 0;
|
||||
const cacheRate = cacheReported ? formatCacheHitRate(row.cacheHitTokens, row.cacheMissTokens) : t("context.cacheNotReported");
|
||||
const costLabel = formatMoneyLocalized(row.cost, row.currency, { locale, empty: "dash" });
|
||||
const costLabel = markEstimated(formatMoneyLocalized(row.cost, row.currency, { locale, empty: "dash" }), row.estimated);
|
||||
return (
|
||||
<div className="context-panel__source-row" key={row.source}>
|
||||
<div className="context-panel__source-head">
|
||||
@@ -528,13 +526,13 @@ export function ContextPanel({
|
||||
<MiniStat label={t("context.sessionCost")} value={sessionCostLabel} />
|
||||
<MiniStat label={t("context.time")} value={fmtDuration(elapsed, t)} />
|
||||
<MiniStat label={t("context.requests")} value={requestCount > 0 ? String(requestCount) : "-"} />
|
||||
<MiniStat label={t("context.sessionTokensShort")} value={totalTokensMetric.display} title={totalTokensTitle} wide />
|
||||
<MiniStat label={t("context.sessionTokensShort")} value={markEstimated(totalTokensMetric.display, sessionEstimated)} title={totalTokensTitle} wide />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="context-panel__creation-grid" aria-label={t("context.overview")}>
|
||||
<MetricCard label={t("status.cacheLabel")} value={fmtUsageCacheRate(usage)} tone="accent" />
|
||||
<MetricCard label={t("status.turnTokensLabel")} value={fmtOptionalTokens(turnTokens)} />
|
||||
<MetricCard label={t("status.turnTokensLabel")} value={formatOptionalTokens(turnTokens)} />
|
||||
<MetricCard label={t("status.turnCostLabel")} value={turnCostLabel} />
|
||||
<MetricCard label={t("status.balanceLabel")} value={balanceLabel} tone="accent" />
|
||||
</section>
|
||||
|
||||
@@ -25,6 +25,10 @@ export function dirEntryMenuLabel(entry: DirEntry): string {
|
||||
return entry.displayName || entry.name;
|
||||
}
|
||||
|
||||
function fileReferenceItemKey(entry: DirEntry): string {
|
||||
return (entry.isDir ? "d:" : "f:") + (entry.path || entry.name);
|
||||
}
|
||||
|
||||
export function activeFileReferenceToken(text: string): { raw: string; dir: string; frag: string } | null {
|
||||
const queryText = text.replace(/[\r\n]+$/u, "");
|
||||
const match = activeRefTokenRe.exec(queryText);
|
||||
@@ -205,7 +209,7 @@ export function FileReferenceMenu({
|
||||
return (
|
||||
<div className="slashmenu" role="listbox">
|
||||
{items.map((entry, index) => (
|
||||
<div key={(entry.isDir ? "d:" : "f:") + (entry.path || entry.name)}>
|
||||
<div key={fileReferenceItemKey(entry)}>
|
||||
{renderEntry(entry, index)}
|
||||
</div>
|
||||
))}
|
||||
@@ -217,7 +221,7 @@ export function FileReferenceMenu({
|
||||
<VirtualMenu
|
||||
items={items}
|
||||
activeIndex={activeIndex}
|
||||
itemKey={(entry) => (entry.isDir ? "d:" : "f:") + (entry.path || entry.name)}
|
||||
itemKey={fileReferenceItemKey}
|
||||
renderItem={renderEntry}
|
||||
/>
|
||||
);
|
||||
|
||||
Loaded 100 of 596 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user