mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dbabcbf71 | ||
|
|
8d61174ca0 | ||
|
|
da0ed97148 | ||
|
|
5421b86ed3 | ||
|
|
3c8695b470 | ||
|
|
dd2485645a | ||
|
|
1eb620fb71 | ||
|
|
81b255a538 | ||
|
|
f8a5df2669 | ||
|
|
b777bde623 | ||
|
|
a879773823 | ||
|
|
fccddf170d | ||
|
|
cbba10ae8c | ||
|
|
a649c72188 | ||
|
|
eaafef675b | ||
|
|
3db246f5fc | ||
|
|
bbd4e65c69 | ||
|
|
164cfef2dd | ||
|
|
2ce60e12f0 | ||
|
|
136bd5fcd1 | ||
|
|
78395619f1 | ||
|
|
2c04fece87 | ||
|
|
38028e0f86 | ||
|
|
aaaf521163 | ||
|
|
02c1732e68 | ||
|
|
219aadd409 | ||
|
|
9fb4dd8fb0 | ||
|
|
0004d81e52 | ||
|
|
8b6b63c92c | ||
|
|
4115cc702e | ||
|
|
28b3300cc5 | ||
|
|
369f55cbf5 | ||
|
|
b0b7cfa34c | ||
|
|
77eab9e8e2 | ||
|
|
f3452e3a82 | ||
|
|
a311dd464e | ||
|
|
9e32035f32 | ||
|
|
b124e2a5fa | ||
|
|
7dcfb7fa46 | ||
|
|
9a165ea6dc | ||
|
|
5a7fd42d6a | ||
|
|
7da1197efc | ||
|
|
9aad6d8b4c | ||
|
|
3da4a00263 | ||
|
|
a9cd939c1f | ||
|
|
ddf6ca6820 | ||
|
|
c2d5560aac | ||
|
|
51a3ed710c | ||
|
|
dd7625ca58 | ||
|
|
20134210be | ||
|
|
f41512ed00 | ||
|
|
df6b737fba | ||
|
|
d0572a4f5a | ||
|
|
ee72c66af6 | ||
|
|
a0df39a7a1 | ||
|
|
43dc1778d2 | ||
|
|
923370645c | ||
|
|
aad51d1970 | ||
|
|
3575809b3e | ||
|
|
b63d4151a2 | ||
|
|
9b6437fe06 | ||
|
|
9eab98f5e7 | ||
|
|
425c59b2c5 | ||
|
|
00b14555bd | ||
|
|
c92312d04d | ||
|
|
c9e9da581c | ||
|
|
2b6128ff27 | ||
|
|
2370aa5aad | ||
|
|
1a1ccbef33 | ||
|
|
7cefa9c194 | ||
|
|
522001b7be | ||
|
|
213b8fa2db | ||
|
|
8580ea084b | ||
|
|
14ed15ba5e | ||
|
|
a42067d245 | ||
|
|
de2c07c46f | ||
|
|
1ad0716a37 | ||
|
|
e6b520c39b | ||
|
|
f733345587 | ||
|
|
4f1637e05f | ||
|
|
8f7e4a0748 | ||
|
|
3770cb3856 | ||
|
|
2835c52d2b | ||
|
|
64638bccd1 | ||
|
|
ae31f7388e | ||
|
|
ade8df6c1f | ||
|
|
420718b717 | ||
|
|
4e8dc79184 | ||
|
|
97f5a265de | ||
|
|
b76822bc19 | ||
|
|
c2974487dd | ||
|
|
1d75becebc | ||
|
|
d39c0ff888 | ||
|
|
24dc82f313 | ||
|
|
b73f1174b2 | ||
|
|
d62e151fcb | ||
|
|
d5c4e196a6 | ||
|
|
e8e7af5996 | ||
|
|
7e45e66110 | ||
|
|
bf979b7831 | ||
|
|
1405c2a043 | ||
|
|
ca4fbe80aa | ||
|
|
9c3579256d | ||
|
|
5d88b179b2 | ||
|
|
1132215f4a | ||
|
|
d9ba90ed04 | ||
|
|
4a09d5546b | ||
|
|
4a5f86a0d1 | ||
|
|
555b96966c | ||
|
|
790c6be08e | ||
|
|
52f465e4f5 | ||
|
|
3d129f0d6f | ||
|
|
e66c50c76b | ||
|
|
b474d12fdd | ||
|
|
b66fdb30f6 | ||
|
|
092ab722cc | ||
|
|
390f996103 | ||
|
|
46dd766262 | ||
|
|
4cf81e89fe | ||
|
|
63582bc7c8 | ||
|
|
54c73168c3 | ||
|
|
36c1faba14 | ||
|
|
1808ff6d63 | ||
|
|
9c3123c7a3 | ||
|
|
aecf4e6960 | ||
|
|
d4e11410cb | ||
|
|
2f7809c10d | ||
|
|
d2cb0aa894 | ||
|
|
3cb73b3bf4 | ||
|
|
ec866730e0 | ||
|
|
3065db7a49 | ||
|
|
ae2e9053f5 | ||
|
|
28e37a1c0a | ||
|
|
3822d577eb | ||
|
|
88751263bf | ||
|
|
c2d10fb7e0 | ||
|
|
053136c488 | ||
|
|
49939444ff | ||
|
|
3a946c048d | ||
|
|
e30096d678 | ||
|
|
54f4898b2c | ||
|
|
03254360ee | ||
|
|
35fc8b746a | ||
|
|
befb5e258c | ||
|
|
8e9f7cfed0 | ||
|
|
96297a2146 | ||
|
|
48b340a877 | ||
|
|
d67f2835ac | ||
|
|
6512f0db40 | ||
|
|
a0ac937c65 | ||
|
|
15a1f262fb | ||
|
|
7574f8b4c9 | ||
|
|
d945284cec | ||
|
|
c55c6bee78 | ||
|
|
2a3855f16a | ||
|
|
6d0c756228 | ||
|
|
dd9b441f99 | ||
|
|
acf3bfdcbc | ||
|
|
96b296cd90 | ||
|
|
b18f84ee22 | ||
|
|
29c8349c3a | ||
|
|
51fe2e9255 | ||
|
|
8cf5b64418 | ||
|
|
463840813f | ||
|
|
c04f7dd247 | ||
|
|
a44319982f | ||
|
|
a8bea84e68 | ||
|
|
f457bbbd61 | ||
|
|
3007c94084 | ||
|
|
c09dbf56b9 | ||
|
|
26984605be | ||
|
|
85759d074c | ||
|
|
e47c32200c | ||
|
|
861a7b8d12 | ||
|
|
32107d7bb6 | ||
|
|
d5c9f2d739 | ||
|
|
1a74c7450f | ||
|
|
947ce21896 | ||
|
|
872ad88677 | ||
|
|
4512a84cbb | ||
|
|
9424d402fb | ||
|
|
2dfa1ca44c | ||
|
|
e5795e5e0f | ||
|
|
86db9b4df3 | ||
|
|
8829515d5e | ||
|
|
8b2504fb6b | ||
|
|
42503a4d36 | ||
|
|
598ecbf1b2 | ||
|
|
d730d8c8f8 |
No files matched your search
@@ -10,9 +10,7 @@ on:
|
||||
# conservative: when in doubt, run.
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- 'site/**'
|
||||
- 'release-notes/**'
|
||||
- 'workers/**'
|
||||
- 'benchmarks/**'
|
||||
- 'npm/**'
|
||||
- '*.md'
|
||||
|
||||
@@ -1299,7 +1299,7 @@ jobs:
|
||||
if: ${{ !cancelled() && needs.desktop-prepare.result == 'success' && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.native != 'false') }}
|
||||
runs-on: windows-latest
|
||||
# Independent runners prevent valid groups from consuming each other's
|
||||
# step deadline. Go retains its default per-package ten-minute timeout.
|
||||
# step deadline. Each group runs with go -timeout=25m (driver testArgs).
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1636,16 +1636,13 @@ jobs:
|
||||
-ignore 'label "windows-11-arm" is unknown' \
|
||||
-ignore 'label "macos-15-intel" is unknown' \
|
||||
.github/workflows/release-stable.yml \
|
||||
.github/workflows/prepare-release-notes.yml \
|
||||
.github/workflows/release-candidate.yml \
|
||||
.github/workflows/release-candidate-verify.yml \
|
||||
.github/workflows/release-promote.yml \
|
||||
.github/workflows/release-verify.yml \
|
||||
.github/workflows/release-site-recovery.yml \
|
||||
.github/workflows/release.yml \
|
||||
.github/workflows/release-npm.yml \
|
||||
.github/workflows/release-desktop.yml \
|
||||
.github/workflows/pages.yml \
|
||||
.github/workflows/apple-notary-log.yml \
|
||||
.github/workflows/macos-signing-check.yml \
|
||||
.github/workflows/release-verify-issues.yml \
|
||||
@@ -1655,6 +1652,7 @@ jobs:
|
||||
scripts/verify-release-artifact-archive.test.mjs \
|
||||
scripts/build-release-cli-candidate.test.mjs scripts/publish-homebrew-cask.test.mjs \
|
||||
scripts/desktop-release-artifacts.test.mjs scripts/release-publication-ledger.test.mjs \
|
||||
scripts/release-cli-freeze.test.mjs \
|
||||
scripts/ci-paths.test.mjs scripts/ci-workflow.test.mjs scripts/macos-go-tests.test.mjs \
|
||||
scripts/desktop-windows-go-tests.test.mjs \
|
||||
npm/publish.test.mjs
|
||||
@@ -1669,14 +1667,14 @@ jobs:
|
||||
node scripts/check-single-release-public-contract.mjs
|
||||
|
||||
# `lint` is a protected check name. Keep it as the fail-closed aggregate so
|
||||
# Required check for the root-module and site jobs the per-OS `test` legs do
|
||||
# required checks for root-module jobs the per-OS `test` legs do
|
||||
# not cover. windows-control and sdk gate internally through RUN_STEPS and so
|
||||
# always report a result: `success` here means "did not fail", not "validated
|
||||
# this diff" — the same trade-off the required `test` legs already make. Do
|
||||
# not add govulncheck: it sets continue-on-error, so needs.*.result is
|
||||
# `success` even when its steps fail and the assertion would be a tautology.
|
||||
root:
|
||||
needs: [changes, windows-control, windows-isolated, sdk, site, coverage]
|
||||
needs: [changes, windows-control, windows-isolated, sdk, coverage]
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -1684,12 +1682,10 @@ jobs:
|
||||
env:
|
||||
CHANGES_RESULT: ${{ needs.changes.result }}
|
||||
CODE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.code != 'false' }}
|
||||
SITE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.site != 'false' }}
|
||||
COVERAGE_REQUIRED: ${{ github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true' }}
|
||||
CONTROL_RESULT: ${{ needs.windows-control.result }}
|
||||
ISOLATED_RESULT: ${{ needs.windows-isolated.result }}
|
||||
SDK_RESULT: ${{ needs.sdk.result }}
|
||||
SITE_RESULT: ${{ needs.site.result }}
|
||||
COVERAGE_RESULT: ${{ needs.coverage.result }}
|
||||
run: |
|
||||
test "$CHANGES_RESULT" = success
|
||||
@@ -1697,7 +1693,6 @@ jobs:
|
||||
test "$CONTROL_RESULT" = success
|
||||
test "$SDK_RESULT" = success
|
||||
test "$ISOLATED_RESULT" = "$(expected "$CODE_REQUIRED")"
|
||||
test "$SITE_RESULT" = "$(expected "$SITE_REQUIRED")"
|
||||
test "$COVERAGE_RESULT" = "$(expected "$COVERAGE_REQUIRED")"
|
||||
|
||||
# frontend unit coverage can run once in desktop-frontend without weakening
|
||||
@@ -1731,31 +1726,6 @@ jobs:
|
||||
test "$FRONTEND_RESULT" = skipped
|
||||
fi
|
||||
|
||||
# The site/ auth client has security-sensitive redirect-validation logic
|
||||
# (safeNext) covered by node:test unit tests. Those tests use only Node
|
||||
# builtins, so no `npm install` is needed — run them directly on every PR so
|
||||
# a regression in redirect validation fails the build instead of shipping.
|
||||
# The notes_only term matches every other gated job here. Without it a
|
||||
# release-notes-only push runs this job while the root aggregate expects the
|
||||
# house-style skip, which would leave main-v2 permanently red. A pure
|
||||
# release-notes/** diff cannot touch site/, so skipping it loses no coverage.
|
||||
site:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.site != 'false') }}
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: site
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: test
|
||||
run: npm test
|
||||
|
||||
govulncheck:
|
||||
needs: changes
|
||||
if: (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true')
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
name: Deploy accounts worker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-accounts-worker
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: workers/accounts/pnpm-lock.yaml
|
||||
- name: Deploy
|
||||
working-directory: workers/accounts
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm test
|
||||
pnpm typecheck
|
||||
npx wrangler deploy
|
||||
- name: Sync RESEND_API_KEY to the worker
|
||||
working-directory: workers/accounts
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||
run: |
|
||||
if [ -n "$RESEND_API_KEY" ]; then
|
||||
printf '%s' "$RESEND_API_KEY" | npx wrangler secret put RESEND_API_KEY
|
||||
else
|
||||
echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)."
|
||||
fi
|
||||
@@ -1,168 +0,0 @@
|
||||
name: Deploy crash worker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
firebase_data_action:
|
||||
description: Firebase crash history operation (migration actions do not deploy the Worker)
|
||||
required: true
|
||||
default: none
|
||||
type: choice
|
||||
options:
|
||||
- none
|
||||
- dry-run
|
||||
- apply
|
||||
- verify-only
|
||||
confirmation:
|
||||
description: Enter APPLY_FIREBASE_CRASH_DATA when selecting apply
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-crash-worker
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.event_name == 'push' || inputs.firebase_data_action == 'none'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: workers/crash-report/package-lock.json
|
||||
- name: Validate
|
||||
working-directory: workers/crash-report
|
||||
run: |
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
- name: Apply and verify diagnostics v2 D1 migration
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:diagnostics-v2
|
||||
- name: Apply and verify Firebase crash D1 migration
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:firebase-crash
|
||||
- name: Apply dashboard performance indexes
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:dashboard-indexes
|
||||
- name: Apply registry performance indexes and install rollup
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npm run migrate:registry-indexes
|
||||
- name: Sync Firebase crash secrets
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
|
||||
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
|
||||
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
|
||||
run: |
|
||||
configured=0
|
||||
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
|
||||
if [ "$configured" -ne 0 ] && [ "$configured" -ne 3 ]; then
|
||||
echo "Firebase crash secrets must be either all configured or all absent."
|
||||
exit 1
|
||||
fi
|
||||
node -e 'process.stdout.write(JSON.stringify({FIREBASE_DATABASE_URL:process.env.FIREBASE_DATABASE_URL||null,FIREBASE_CLIENT_EMAIL:process.env.FIREBASE_CLIENT_EMAIL||null,FIREBASE_PRIVATE_KEY:process.env.FIREBASE_PRIVATE_KEY||null}))' | npx wrangler secret bulk
|
||||
- name: Deploy
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
npx wrangler deploy
|
||||
# Mirrors the ALERT_WEBHOOK repo secret into the worker so the ingest
|
||||
# sentinel can push alerts (see runIngestSentinel). Managed here so
|
||||
# nobody needs local Cloudflare credentials; when the repo secret is
|
||||
# unset the sentinel stays log-only.
|
||||
- name: Sync alert webhook secret
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
ALERT_WEBHOOK: ${{ secrets.ALERT_WEBHOOK }}
|
||||
run: |
|
||||
if [ -z "$ALERT_WEBHOOK" ]; then
|
||||
echo "ALERT_WEBHOOK repo secret not set; removing any existing worker secret."
|
||||
printf '{"ALERT_WEBHOOK":null}' | npx wrangler secret bulk
|
||||
exit 0
|
||||
fi
|
||||
printf '%s' "$ALERT_WEBHOOK" | npx wrangler secret put ALERT_WEBHOOK
|
||||
|
||||
migrate-firebase-data:
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.firebase_data_action != 'none'
|
||||
runs-on: ubuntu-latest
|
||||
environment: canary
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- name: Require the protected production branch
|
||||
run: |
|
||||
if [ "$GITHUB_REF" != "refs/heads/main-v2" ]; then
|
||||
echo "Firebase crash data migration must run from main-v2."
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: workers/crash-report/package-lock.json
|
||||
- name: Validate
|
||||
working-directory: workers/crash-report
|
||||
run: |
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
- name: Run Firebase crash history operation
|
||||
working-directory: workers/crash-report
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
|
||||
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
|
||||
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
|
||||
FIREBASE_DATA_ACTION: ${{ inputs.firebase_data_action }}
|
||||
FIREBASE_DATA_CONFIRMATION: ${{ inputs.confirmation }}
|
||||
run: |
|
||||
configured=0
|
||||
[ -n "$CLOUDFLARE_API_TOKEN" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
|
||||
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
|
||||
if [ "$configured" -ne 4 ]; then
|
||||
echo "Cloudflare and Firebase migration secrets must all be configured."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$FIREBASE_DATA_ACTION" in
|
||||
dry-run)
|
||||
npm run migrate:firebase-data
|
||||
;;
|
||||
apply)
|
||||
if [ "$FIREBASE_DATA_CONFIRMATION" != "APPLY_FIREBASE_CRASH_DATA" ]; then
|
||||
echo "Apply requires the exact confirmation APPLY_FIREBASE_CRASH_DATA."
|
||||
exit 1
|
||||
fi
|
||||
npm run migrate:firebase-data -- --apply
|
||||
npm run migrate:firebase-data -- --verify-only
|
||||
;;
|
||||
verify-only)
|
||||
npm run migrate:firebase-data -- --verify-only
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported Firebase crash data action."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,36 +0,0 @@
|
||||
name: Deploy forum worker
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-forum-worker
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: workers/forum/pnpm-lock.yaml
|
||||
- name: Deploy
|
||||
working-directory: workers/forum
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm test
|
||||
pnpm typecheck
|
||||
npx wrangler d1 execute reasonix-forum --remote --file=migrate-performance-indexes.sql
|
||||
npx wrangler deploy
|
||||
@@ -1,76 +0,0 @@
|
||||
name: Deploy site
|
||||
run-name: Deploy site ${{ inputs.release_version && format('v{0}', inputs.release_version) || github.sha }}${{ inputs.release_request && format(' [{0}]', inputs.release_request) || '' }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_version:
|
||||
description: "Optional release version that owns this deployment"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
release_request:
|
||||
description: "Release workflow correlation ID (optional)"
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.ref == 'refs/heads/main-v2' && github.ref_protected
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: site/package-lock.json
|
||||
- name: Build
|
||||
working-directory: site
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
|
||||
with:
|
||||
path: site/dist
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deploy.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: inputs.release_version != ''
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
if: inputs.release_version != ''
|
||||
with:
|
||||
node-version: "22"
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
if: inputs.release_version != ''
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- name: Recheck release ownership immediately before deployment
|
||||
if: inputs.release_version != ''
|
||||
env:
|
||||
VERSION: ${{ inputs.release_version }}
|
||||
run: test "$(bash scripts/observe-release-site.sh "$VERSION" recover)" = true
|
||||
- id: deploy
|
||||
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5
|
||||
@@ -1,106 +0,0 @@
|
||||
name: Prepare release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Official version without a tag prefix (for example 1.20.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
draft:
|
||||
name: generate reviewable bilingual draft
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Select the review branch
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
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 [[ ! "$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"
|
||||
source_sha="$(git merge-base origin/main-v2 HEAD)"
|
||||
else
|
||||
git checkout -b "$branch"
|
||||
source_sha="$(git rev-parse HEAD)"
|
||||
fi
|
||||
git merge-base --is-ancestor "$source_sha" origin/main-v2
|
||||
echo "RELEASE_NOTES_BRANCH=$branch" >> "$GITHUB_ENV"
|
||||
echo "RELEASE_NOTES_SOURCE_SHA=$source_sha" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Generate and validate release notes
|
||||
env:
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
DEEPSEEK_MODEL: deepseek-v4-pro
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The first preparation fixes the product range. Reruns update the
|
||||
# draft without silently absorbing unrelated main-v2 merges.
|
||||
node scripts/generate-release-notes.mjs --version "$VERSION" --to "$RELEASE_NOTES_SOURCE_SHA"
|
||||
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
|
||||
{
|
||||
echo "## Review draft for v${VERSION#v}"
|
||||
echo
|
||||
cat /tmp/release-notes.md
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Open or update the review PR
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git add release-notes/releases.json
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "docs(release): prepare v${VERSION#v} notes" \
|
||||
-m "Summary:
|
||||
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.
|
||||
|
||||
Verification:
|
||||
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing."
|
||||
fi
|
||||
git push -u origin "$RELEASE_NOTES_BRANCH"
|
||||
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; 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
|
||||
@@ -194,6 +194,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -210,6 +212,7 @@ jobs:
|
||||
env:
|
||||
DOCS_BUILD_VERSION: v${{ needs.resolve.outputs.version }}
|
||||
DOCS_SOURCE_REVISION: ${{ github.sha }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
node scripts/release-notes.mjs validate
|
||||
node scripts/release-notes.mjs render --version "$DOCS_BUILD_VERSION" --output /tmp/release-notes.md
|
||||
@@ -259,6 +262,8 @@ jobs:
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/release-desktop.yml
|
||||
with:
|
||||
channel: stable
|
||||
@@ -285,6 +290,8 @@ jobs:
|
||||
attestations: write
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -338,6 +345,16 @@ jobs:
|
||||
test -s payload/evidence/windows-amd64.json
|
||||
test -s payload/evidence/windows-arm64.json
|
||||
test -s payload/evidence/macos-universal-intel.json
|
||||
# Rendered once and sealed: promotion publishes these bytes and never
|
||||
# reads GitHub again, so a later rename or outage cannot change them.
|
||||
- name: Render reviewed notes into the sealed payload
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
|
||||
git worktree add --detach "$worktree" "${{ github.sha }}"
|
||||
node "$worktree/scripts/release-notes.mjs" render --version "v${{ needs.resolve.outputs.version }}" --output payload/evidence/release-notes.md
|
||||
git worktree remove "$worktree"
|
||||
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||
with:
|
||||
subject-path: payload/**/*
|
||||
@@ -359,13 +376,6 @@ jobs:
|
||||
if-no-files-found: error
|
||||
overwrite: true
|
||||
retention-days: 90
|
||||
- name: Render reviewed notes from the immutable product
|
||||
run: |
|
||||
git show "${{ github.sha }}:release-notes/releases.json" > /tmp/releases.json
|
||||
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
|
||||
git worktree add --detach "$worktree" "${{ github.sha }}"
|
||||
node "$worktree/scripts/release-notes.mjs" render --version "v${{ needs.resolve.outputs.version }}" --output /tmp/release-notes.md
|
||||
git worktree remove "$worktree"
|
||||
- name: Seal candidate record
|
||||
env:
|
||||
RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
@@ -385,7 +395,7 @@ jobs:
|
||||
RELEASE_ACCEPTANCE_JSON: >-
|
||||
[{"kind":"windows-amd64","status":"passed","evidencePath":"evidence/windows-amd64.json"},{"kind":"windows-arm64","status":"passed","evidencePath":"evidence/windows-arm64.json"},{"kind":"macos-universal-intel","status":"passed","evidencePath":"evidence/macos-universal-intel.json"}]
|
||||
run: |
|
||||
RELEASE_RENDERED_NOTES_SHA256="$(sha256sum /tmp/release-notes.md | awk '{print $1}')"
|
||||
RELEASE_RENDERED_NOTES_SHA256="$(sha256sum payload/evidence/release-notes.md | awk '{print $1}')"
|
||||
RELEASE_CREATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
RELEASE_EXPIRES_AT="$(date -u -d '+30 days' +%Y-%m-%dT%H:%M:%SZ)"
|
||||
export RELEASE_RENDERED_NOTES_SHA256 RELEASE_CREATED_AT RELEASE_EXPIRES_AT
|
||||
|
||||
@@ -1084,6 +1084,8 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
# Approved orchestrators have already passed the matching GitHub environment.
|
||||
# Direct prereleases and manual Stable recovery pass release-gate above. The
|
||||
# Certum signing completes before either platform bundle reaches publication.
|
||||
@@ -1200,6 +1202,8 @@ jobs:
|
||||
# GitHub release as usual.
|
||||
- name: Render reviewed release notes
|
||||
if: ${{ !inputs.orchestrated && needs.resolve.outputs.channel != 'preview' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node scripts/release-notes.mjs render --version "${{ needs.resolve.outputs.notes_version }}" --output /tmp/release-notes.md
|
||||
|
||||
- name: Revalidate approved release ref
|
||||
@@ -1292,7 +1296,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # gh release download + compatibility manifest upload
|
||||
actions: write # dispatch pages.yml to re-bake the site version
|
||||
# Stable keeps GitHub as a fallback when R2 is unavailable. Preview has no
|
||||
# GitHub release, so it must fail closed before attempting publication.
|
||||
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.publish.result == 'success' && github.repository_owner == 'esengine' }}
|
||||
@@ -1818,11 +1821,3 @@ jobs:
|
||||
else
|
||||
echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes."
|
||||
fi
|
||||
|
||||
# Stable release moved R2 latest/ — rebuild the site so its build-time baked
|
||||
# version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO).
|
||||
- name: Refresh site to the new version
|
||||
if: env.HAS_R2 == 'true' && needs.resolve.outputs.channel != 'preview' && needs.resolve.outputs.prerelease != 'true' && steps.mirror_r2.outputs.pointer_moved == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: gh workflow run pages.yml --ref main-v2
|
||||
@@ -4,16 +4,9 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "release-notes/**"
|
||||
- "scripts/generate-release-notes.mjs"
|
||||
- "scripts/release-notes*.mjs"
|
||||
- "scripts/release-credits*.mjs"
|
||||
- "scripts/release-event.mjs"
|
||||
- "site/src/pages/changelog/**"
|
||||
- "site/src/components/ChangelogPage.astro"
|
||||
- "site/src/data/releases.ts"
|
||||
- "site/src/styles/changelog.css"
|
||||
- "desktop/frontend/src/App.tsx"
|
||||
- "desktop/frontend/src/components/SettingsPanel.tsx"
|
||||
- "desktop/frontend/src/components/UpdateBanner.tsx"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -27,4 +20,4 @@ jobs:
|
||||
with:
|
||||
node-version: "22"
|
||||
- run: node scripts/release-notes.mjs validate
|
||||
- run: node --test scripts/release-notes.test.mjs
|
||||
- run: node --test scripts/release-notes.test.mjs scripts/release-credits.test.mjs
|
||||
@@ -221,6 +221,7 @@ jobs:
|
||||
if: ${{ inputs.candidate_artifact_name == '' }}
|
||||
env:
|
||||
VERSION_ARG: ${{ steps.ver.outputs.arg }}
|
||||
NPM_STABLE_DIST_TAG: ${{ vars.CLI_PUBLISH_FROZEN == 'true' && 'legacy-v1' || 'latest' }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: node npm/build.mjs "$VERSION_ARG" --publish
|
||||
- name: Publish or recover prepared npm packages
|
||||
@@ -228,10 +229,11 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ inputs.base_version }}
|
||||
APPROVED_SHA: ${{ inputs.approved_sha }}
|
||||
NPM_STABLE_DIST_TAG: ${{ vars.CLI_PUBLISH_FROZEN == 'true' && 'legacy-v1' || 'latest' }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: node npm/publish-candidate.mjs "$RUNNER_TEMP/release-candidate/npm" "$VERSION" "$APPROVED_SHA"
|
||||
- name: Align legacy aliases with the official release
|
||||
if: ${{ inputs.channel == 'stable' }}
|
||||
if: ${{ inputs.channel == 'stable' && vars.CLI_PUBLISH_FROZEN != 'true' }}
|
||||
env:
|
||||
VERSION: ${{ inputs.base_version }}
|
||||
APPROVED_SHA: ${{ inputs.approved_sha }}
|
||||
|
||||
@@ -23,6 +23,8 @@ permissions:
|
||||
actions: write
|
||||
attestations: read
|
||||
contents: write
|
||||
issues: read # granted to the nested release workflows, which can render notes
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
@@ -146,12 +148,19 @@ jobs:
|
||||
echo "- Payload files: \`$(jq '.files | length' record/record.json)\`"
|
||||
echo "- Native acceptance: \`$(jq -r '[.acceptance[] | select(.status == "passed") | .kind] | join(", ")' record/record.json)\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Render and bind reviewed release notes
|
||||
# A candidate sealed with its notes publishes those bytes without touching
|
||||
# the network. One sealed before that renders offline from its own source.
|
||||
- name: Bind reviewed release notes
|
||||
run: |
|
||||
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
|
||||
git worktree add --detach "$worktree" "${{ steps.record.outputs.source_sha }}"
|
||||
node "$worktree/scripts/release-notes.mjs" render --version "v${{ steps.record.outputs.version }}" --output release-notes.md
|
||||
git worktree remove "$worktree"
|
||||
sealed="$(jq -r '.notes.renderedPath // empty' record/record.json)"
|
||||
if [ -n "$sealed" ]; then
|
||||
cp "payload/$sealed" release-notes.md
|
||||
else
|
||||
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
|
||||
git worktree add --detach "$worktree" "${{ steps.record.outputs.source_sha }}"
|
||||
node "$worktree/scripts/release-notes.mjs" render --version "v${{ steps.record.outputs.version }}" --output release-notes.md
|
||||
git worktree remove "$worktree"
|
||||
fi
|
||||
test "$(sha256sum release-notes.md | awk '{print $1}')" = "$(jq -r .notes.renderedSha256 record/record.json)"
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
@@ -306,7 +315,6 @@ jobs:
|
||||
if: ${{ always() && !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Require every publisher
|
||||
@@ -326,7 +334,7 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- name: Reconcile public artifacts, owned Pages deployment, and durable ledger
|
||||
- name: Reconcile public artifacts, release event, and durable ledger
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
@@ -334,7 +342,8 @@ jobs:
|
||||
RELEASE_OPERATION: ${{ inputs.operation }}
|
||||
RELEASE_EXPECTED_SHA: ${{ needs.preflight.outputs.source_sha }}
|
||||
RELEASE_LEDGER_OUTPUT: /tmp/publication-ledger.json
|
||||
run: bash scripts/sync-release-site.sh
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
|
||||
run: bash scripts/reconcile-release-publication.sh
|
||||
- name: Upload publication ledger
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
name: Recover release site
|
||||
run-name: Recover site v${{ inputs.version }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Already-published stable version; no builds or package publication"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: stable-release-publication
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
if: github.repository == 'esengine/DeepSeek-Reasonix' && github.ref == 'refs/heads/main-v2' && github.ref_protected
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
source_sha: ${{ steps.identity.outputs.source_sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- name: Validate executable contracts and public access before approval
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
bash scripts/validate-release-control-plane.sh
|
||||
bash scripts/check-release-public-access.sh "$VERSION"
|
||||
- name: Bind existing public tags to protected history
|
||||
id: identity
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
|
||||
source_sha="$(git rev-parse "refs/tags/v$VERSION^{commit}")"
|
||||
git merge-base --is-ancestor "$source_sha" origin/main-v2
|
||||
bash scripts/release-candidate-tags.sh check recover "$VERSION" "$source_sha"
|
||||
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
|
||||
- name: Re-read public package identities before approval
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
RELEASE_OPERATION: recover
|
||||
RELEASE_EXPECTED_SHA: ${{ steps.identity.outputs.source_sha }}
|
||||
CLI_TAG: v${{ inputs.version }}
|
||||
DESKTOP_TAG: desktop-v${{ inputs.version }}
|
||||
RELEASE_LEDGER_OUTPUT: ${{ runner.temp }}/site-recovery-preflight.json
|
||||
run: bash scripts/verify-stable-release-artifacts.sh
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: site-recovery-preflight-${{ inputs.version }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/site-recovery-preflight.json
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
|
||||
sync:
|
||||
needs: preflight
|
||||
environment: release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: false
|
||||
- name: Recover only the website and verify public state again
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_REPOSITORY: ${{ github.repository }}
|
||||
RELEASE_VERSION: ${{ inputs.version }}
|
||||
RELEASE_OPERATION: recover
|
||||
RELEASE_EXPECTED_SHA: ${{ needs.preflight.outputs.source_sha }}
|
||||
RELEASE_SITE_RECOVERY_ONLY: "true"
|
||||
RELEASE_LEDGER_OUTPUT: ${{ runner.temp }}/publication-ledger.json
|
||||
run: bash scripts/sync-release-site.sh
|
||||
- name: Preserve successful or incomplete recovery evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: release-site-recovery-ledger-${{ inputs.version }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/publication-ledger.json
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
@@ -55,6 +55,8 @@ concurrency:
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
@@ -63,6 +65,8 @@ jobs:
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
outputs:
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
cli_tag: ${{ steps.release.outputs.cli_tag }}
|
||||
@@ -111,6 +115,8 @@ jobs:
|
||||
bash scripts/validate-stable-candidate.sh "$RELEASE_VERSION" "$RELEASE_SHA"
|
||||
bash scripts/verify-release-push-ci.sh "$RELEASE_SHA"
|
||||
- name: Validate reviewed release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
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,
|
||||
# which can predate its reviewed release-note entry. Carry the exact file
|
||||
@@ -226,7 +232,6 @@ jobs:
|
||||
if: ${{ always() && !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Require every publisher to succeed
|
||||
@@ -273,6 +278,7 @@ jobs:
|
||||
CLI_TAG: ${{ needs.authorize.outputs.cli_tag }}
|
||||
DESKTOP_TAG: ${{ needs.authorize.outputs.desktop_tag }}
|
||||
DESKTOP_MANUAL_ONLY: ${{ inputs.desktop_manual_only || false }}
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
|
||||
run: bash scripts/verify-stable-release-artifacts.sh
|
||||
- name: Publish exact Stable release record
|
||||
env:
|
||||
@@ -303,7 +309,3 @@ jobs:
|
||||
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
|
||||
@@ -35,5 +35,6 @@ jobs:
|
||||
RELEASE_OPERATION: recover
|
||||
CLI_TAG: v${{ inputs.version }}
|
||||
DESKTOP_TAG: desktop-v${{ inputs.version }}
|
||||
VERIFY_HOMEPAGE: "true"
|
||||
VERIFY_PUBLIC_POINTERS: "true"
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
|
||||
run: bash scripts/verify-stable-release-artifacts.sh
|
||||
@@ -212,6 +212,10 @@ jobs:
|
||||
needs: [resolve, cache-guard]
|
||||
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
issues: read # release-notes credits read PR and issue authors
|
||||
pull-requests: read
|
||||
# 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.
|
||||
@@ -274,6 +278,7 @@ jobs:
|
||||
if: ${{ !inputs.orchestrated }}
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.resolve.outputs.notes_version }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node scripts/release-notes.mjs render --version "$RELEASE_TAG" --output /tmp/release-notes.md
|
||||
- name: Revalidate approved release ref
|
||||
env:
|
||||
@@ -328,10 +333,10 @@ jobs:
|
||||
if: ${{ steps.publication.outputs.decision == 'publish' && inputs.candidate_artifact_name == '' }}
|
||||
with:
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
args: release --clean${{ vars.CLI_PUBLISH_FROZEN == 'true' && ' --skip=homebrew' || '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HOMEBREW_TAP_TOKEN: ${{ needs.resolve.outputs.channel == 'stable' && secrets.HOMEBREW_TAP_TOKEN || '' }}
|
||||
HOMEBREW_TAP_TOKEN: ${{ needs.resolve.outputs.channel == 'stable' && vars.CLI_PUBLISH_FROZEN != 'true' && secrets.HOMEBREW_TAP_TOKEN || '' }}
|
||||
# workflow_dispatch recovery runs have a branch-shaped GITHUB_REF even
|
||||
# though checkout is on the release tag. Pin GoReleaser explicitly, and
|
||||
# avoid ambiguity from the three release tags sharing one commit.
|
||||
@@ -354,7 +359,7 @@ jobs:
|
||||
--title "Reasonix CLI $TAG" --notes-file /tmp/release-notes.md --latest=false
|
||||
|
||||
- name: Publish prepared Homebrew cask
|
||||
if: ${{ inputs.candidate_artifact_name != '' && needs.resolve.outputs.channel == 'stable' }}
|
||||
if: ${{ inputs.candidate_artifact_name != '' && needs.resolve.outputs.channel == 'stable' && vars.CLI_PUBLISH_FROZEN != 'true' }}
|
||||
env:
|
||||
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
run: node release-control/scripts/publish-homebrew-cask.mjs "$RUNNER_TEMP/release-candidate/cli/reasonix.rb"
|
||||
@@ -370,6 +375,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
NOTES_TAG: ${{ needs.resolve.outputs.notes_version }}
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
@@ -473,6 +479,11 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${CLI_PUBLISH_FROZEN:-}" = "true" ]; then
|
||||
echo "CLI publication is frozen; the $channel pointer stays where it is and $TAG keeps only its immutable record"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
current_tag=""
|
||||
pointer_error="$(mktemp)"
|
||||
if aws s3 cp "s3://${R2_BUCKET}/cli/${channel}/latest.json" /tmp/cli-release.pointer.json \
|
||||
@@ -607,6 +618,7 @@ jobs:
|
||||
- name: Check npm latest dist-tag freshness
|
||||
env:
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$TAG" in
|
||||
@@ -620,6 +632,10 @@ jobs:
|
||||
echo "::error::the npm-v$version tag was never pushed — the npm channel is being left behind and 'npm update -g' users will be downgraded to the old 'latest'. Push it: git tag npm-v$version ${TAG} && git push origin npm-v$version (or 'npm dist-tag add reasonix@$version latest' for an already-published version)."
|
||||
exit 1
|
||||
fi
|
||||
if [ "${CLI_PUBLISH_FROZEN:-}" = "true" ]; then
|
||||
echo "CLI publication is frozen; npm latest belongs to another line, only the npm-v$version tag is required"
|
||||
exit 0
|
||||
fi
|
||||
for attempt in 1 2 3 4 5 6; do
|
||||
got="$(npm view reasonix dist-tags.latest 2>/dev/null || true)"
|
||||
if [ -n "$got" ]; then
|
||||
|
||||
@@ -52,14 +52,16 @@ lint-cross:
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
# The desktop suite needs ~25m locally, past go's default 10m test alarm
|
||||
# (the TaggedHistory migrations alone are ~389s), so these targets pin a wider one.
|
||||
desktop-test:
|
||||
cd desktop && go test .
|
||||
cd desktop && go test -timeout=25m .
|
||||
|
||||
desktop-test-short:
|
||||
cd desktop && go test -short .
|
||||
cd desktop && go test -short -timeout=25m .
|
||||
|
||||
desktop-test-times:
|
||||
cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
|
||||
cd desktop && go test -count=1 -timeout=25m -json . | python3 ../scripts/desktop-test-times.py
|
||||
|
||||
sdk-test:
|
||||
cd sdk/go && go test ./...
|
||||
|
||||
@@ -100,8 +100,7 @@ for the latest desktop build.
|
||||
| Windows | Installer `.exe` or portable `.zip` | x64 / ARM64 |
|
||||
| Linux | `.deb` or `.tar.gz` | x64 |
|
||||
|
||||
Windows installers are code-signed through [SignPath.io](https://signpath.io/)
|
||||
with a free certificate provided by the [SignPath Foundation](https://signpath.org/).
|
||||
Windows installers are code-signed with a Certum code-signing certificate.
|
||||
|
||||
### Path C: VS Code extension
|
||||
|
||||
|
||||
+1
-2
@@ -92,8 +92,7 @@ brew install esengine/reasonix/reasonix # macOS
|
||||
| Windows | 安装器 `.exe` 或便携 `.zip` | x64 / ARM64 |
|
||||
| Linux | `.deb` 或 `.tar.gz` | x64 |
|
||||
|
||||
Windows 安装器通过 [SignPath.io](https://signpath.io/) 完成代码签名,证书由
|
||||
[SignPath 基金会](https://signpath.org/) 免费提供。
|
||||
Windows 安装器使用 Certum 代码签名证书签名。
|
||||
|
||||
### 路径 C:VS Code 扩展
|
||||
|
||||
|
||||
+53
-6
@@ -56,9 +56,9 @@ Supported boundaries include:
|
||||
- Sandbox behavior for built-in shell execution where the platform supports it.
|
||||
- Secret handling for provider keys, bot credentials, OAuth tokens, plugin
|
||||
headers, and credential-store fallback files.
|
||||
- HTTP `serve` protections for the unauthenticated local server, including
|
||||
localhost binding assumptions, JSON-only state-changing requests, and CORS
|
||||
restrictions.
|
||||
- HTTP `serve` protections, including the launch token every state-changing
|
||||
request requires when authentication is off, localhost binding assumptions,
|
||||
JSON-only state-changing requests, and CORS restrictions.
|
||||
- Desktop and bot session isolation, including per-workspace session metadata
|
||||
and configured bot allowlists.
|
||||
- Updater, install, and release verification paths.
|
||||
@@ -67,10 +67,10 @@ The following are normally treated as trusted local/operator-controlled inputs
|
||||
unless another bug lets an untrusted actor supply them:
|
||||
|
||||
- CLI arguments and text typed directly by the local user.
|
||||
- Project configuration files intentionally loaded from the current workspace.
|
||||
- The user's own configuration under the Reasonix home directory.
|
||||
- Explicit `@path` references supplied by the local user to attach local files.
|
||||
- MCP servers, language servers, hooks, and slash commands installed or enabled
|
||||
by the local user.
|
||||
- MCP servers, language servers, hooks, and slash commands installed, enabled,
|
||||
or approved by the local user.
|
||||
- Provider base URLs and model names configured by the local user.
|
||||
|
||||
The following can be security issues when reachable by an untrusted actor or
|
||||
@@ -89,6 +89,53 @@ when they bypass the intended boundary:
|
||||
- Trusting unverified update artifacts, plugin definitions, or downloaded
|
||||
binaries.
|
||||
|
||||
## Project Configuration
|
||||
|
||||
Files that arrive with a workspace are untrusted input: `reasonix.toml`,
|
||||
`.reasonix/settings.json`, `.mcp.json` and the project `.env`. Cloning a
|
||||
repository is not a decision to trust its author, so these files may narrow
|
||||
what the user configured and never widen it.
|
||||
|
||||
| Setting | What a workspace file may do |
|
||||
| --- | --- |
|
||||
| `sandbox.bash` | Move `off` to `enforce`, never back |
|
||||
| `sandbox.network` | Move `true` to `false`, never back |
|
||||
| `sandbox.forbid_read` | Add to the user's list |
|
||||
| `sandbox.allow_write`, `sandbox.workspace_root` | Apply only when they resolve inside the workspace, symlinks followed; `${VAR}` in any sandbox path expands from the process environment only |
|
||||
| `permissions.deny`, `permissions.ask` | Add to the user's rules |
|
||||
| `permissions.mode`, `permissions.allow`, `permissions.allow_dynamic_bash` | Nothing |
|
||||
| `desktop.default_tool_approval_mode` | Nothing |
|
||||
| `[network]` proxy settings, `[bot]` | Nothing |
|
||||
| `[[providers]]` | Declare them; they stay out of use until approved, and model settings naming them fall back to the user's |
|
||||
|
||||
An "always allow" answer, or extra write access, granted in a workspace is
|
||||
stored under the Reasonix home, not in the workspace.
|
||||
|
||||
Programs a workspace names for the host to run stay off until the user
|
||||
approves them for that workspace:
|
||||
|
||||
- Hooks, language server commands, `tools.search.rg_path`,
|
||||
`tools.shell.path` and `[browser]` launch settings are approved with
|
||||
`reasonix trust`, or for hooks from the desktop hooks settings.
|
||||
- An approval is stored under the Reasonix home with a digest of the
|
||||
declaration and of the workspace files it names; any change needs approval
|
||||
again. Approvals and grants belong to one workspace folder.
|
||||
- Hooks and language servers are checked against their approval again before
|
||||
each start, and refused if a file they name changed, appeared or now
|
||||
resolves elsewhere.
|
||||
- A single program path (`tools.shell.path`, `tools.search.rg_path`,
|
||||
`browser.chrome_path`) may not point into the workspace, an `allow_write`
|
||||
root, a temporary directory or a toolchain cache the bash jail leaves
|
||||
writable, and may not climb with `..`; its approval covers the program
|
||||
file's content.
|
||||
- Without an interactive frontend they stay off.
|
||||
- MCP servers a workspace declares stay off until the user enables that
|
||||
declaration for that workspace.
|
||||
|
||||
A project value that is not applied is reported as a load warning with the
|
||||
reason. A way for workspace files to widen any of the above without the
|
||||
user's approval is a security issue.
|
||||
|
||||
## `@` File References
|
||||
|
||||
Reasonix supports `@path` references so users can include local files and images
|
||||
|
||||
@@ -20,6 +20,7 @@ func writeTaskVerify(t *testing.T, taskDir, script string) {
|
||||
}
|
||||
|
||||
func TestGradeCheckpointsFindsEarliestCorrectState(t *testing.T) {
|
||||
requireRealBash(t)
|
||||
taskDir := filepath.Join(t.TempDir(), "task")
|
||||
writeTaskVerify(t, taskDir, "#!/usr/bin/env bash\ngrep -q done answer.txt\n")
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const corpusDir = "../../benchmarks/e2e"
|
||||
@@ -56,6 +58,24 @@ func gradeSeed(t *testing.T, work string) error {
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// requireRealBash skips hosts whose bash cannot actually run commands. On
|
||||
// Windows exec.LookPath("bash") happily resolves to the System32 WSL launcher,
|
||||
// so a LookPath guard passes while every grade fails inside a distro that may
|
||||
// not even carry /bin/bash. The graders are POSIX fixtures; probing mirrors
|
||||
// what sandbox's own shell resolution does before trusting a bash. Timeout
|
||||
// bounded in case the stub blocks on an install prompt.
|
||||
func requireRealBash(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("bash"); err != nil {
|
||||
t.Skip("bash unavailable; the graders are POSIX shell fixtures")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "bash", "-c", "true").Run(); err != nil {
|
||||
t.Skipf("bash cannot run commands (%v); the graders need a working POSIX shell", err)
|
||||
}
|
||||
}
|
||||
|
||||
// forbiddenArtifact names, per task, a file whose mere existence is the
|
||||
// documented way to fake that task's missing piece. Probing it keeps the
|
||||
// absence checks honest; inferring intent from a missing manifest does not,
|
||||
@@ -157,10 +177,9 @@ func TestNoSolutionCorpusGradesTheInverseContract(t *testing.T) {
|
||||
// The graders are POSIX shell and python3 fixtures, and the suite they
|
||||
// belong to only ever runs on POSIX CI. Where either is missing this
|
||||
// checks the host, not the corpus.
|
||||
for _, bin := range []string{"bash", "python3"} {
|
||||
if _, err := exec.LookPath(bin); err != nil {
|
||||
t.Skipf("%s unavailable; the no-solution graders need a POSIX shell and python3", bin)
|
||||
}
|
||||
requireRealBash(t)
|
||||
if _, err := exec.LookPath("python3"); err != nil {
|
||||
t.Skip("python3 unavailable; the no-solution graders need a POSIX shell and python3")
|
||||
}
|
||||
tasks, err := loadTasks(corpusDir)
|
||||
if err != nil {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
// produced. For an exploration task the grader's "want X, got Y" line is the
|
||||
// entire finding — a bare false would leave every failure indistinguishable.
|
||||
func TestGradeVerboseKeepsWhatTheGraderSaid(t *testing.T) {
|
||||
requireRealBash(t)
|
||||
taskDir := t.TempDir()
|
||||
verify := "#!/usr/bin/env bash\necho \"answer.txt normalized to 'askrigg', want 'gorsefen'\" >&2\nexit 1\n"
|
||||
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte(verify), 0o755); err != nil {
|
||||
@@ -27,6 +28,7 @@ func TestGradeVerboseKeepsWhatTheGraderSaid(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGradeVerboseStaysQuietWhenTheTaskPasses(t *testing.T) {
|
||||
requireRealBash(t)
|
||||
taskDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(taskDir, "verify.sh"), []byte("#!/usr/bin/env bash\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -190,8 +190,8 @@ func migrateWithRelaunch(installRoot, activeVersion string, relaunch bool) error
|
||||
}
|
||||
}
|
||||
shellMembers, err := installlayout.ShellMembers(installRoot, runtime.GOOS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate shell: %w", err)
|
||||
if err != nil || len(shellMembers) == 0 {
|
||||
return refuseShellMissing(installRoot, activeVersion, relaunch, err)
|
||||
}
|
||||
for _, member := range shellMembers {
|
||||
members = append(members, member)
|
||||
@@ -431,25 +431,35 @@ func ensureLauncherEntry(installRoot string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func finalizeLegacyPendingUpdate(installRoot, activeVersion string) error {
|
||||
// readLegacyPendingUpdate returns the transaction an old file updater left for
|
||||
// this install and version, or nil when there is none.
|
||||
func readLegacyPendingUpdate(installRoot, activeVersion string) (*repair.UpdateTransaction, error) {
|
||||
tx, err := repair.ReadPendingUpdate()
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate: read legacy pending update: %w", err)
|
||||
return nil, 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)
|
||||
return nil, 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)
|
||||
return nil, 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")
|
||||
return nil, fmt.Errorf("migrate: pending update target escapes install root")
|
||||
}
|
||||
}
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
func finalizeLegacyPendingUpdate(installRoot, activeVersion string) error {
|
||||
tx, err := readLegacyPendingUpdate(installRoot, activeVersion)
|
||||
if err != nil || tx == nil {
|
||||
return err
|
||||
}
|
||||
id := repair.UpdateTransactionID(tx)
|
||||
if err := repair.MarkUpdateHealthyExact(activeVersion, tx.CreatedAt, id); err != nil {
|
||||
return fmt.Errorf("migrate: commit legacy pending update: %w", err)
|
||||
|
||||
@@ -11,6 +11,10 @@ import (
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// A legacy-updater child keeps the home its parent test chose.
|
||||
if mode := os.Getenv(legacyChildModeEnv); mode != "" {
|
||||
os.Exit(runLegacyChild(mode))
|
||||
}
|
||||
testenv.RunWithIsolatedUserState(m)
|
||||
}
|
||||
|
||||
@@ -69,6 +73,7 @@ func writeInstallerStaging(t *testing.T, root, label string, includeLauncher boo
|
||||
func TestMigrateFlatInstallToVersioned(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFlatUnit(t, root, "flat")
|
||||
writeShellTree(t, root, "flat")
|
||||
// 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)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/installlayout"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
// errShellMissing identifies a flat release unit whose desktop cannot start:
|
||||
// the desktop needs the Electron app/ tree beside it, and the 1.18–1.19.2 file
|
||||
// updaters publish only the three binaries they know by name.
|
||||
var errShellMissing = errors.New("migrate: flat release unit has no Electron desktop shell")
|
||||
|
||||
const migrateLogName = "legacy-migrate.log"
|
||||
|
||||
// refuseShellMissing commits nothing for an absent or incomplete shell: no
|
||||
// version directory, no current.json, no health mark and no flat cleanup.
|
||||
// Where an old updater's transaction is still pending it restores the release
|
||||
// unit that transaction replaced.
|
||||
func refuseShellMissing(installRoot, activeVersion string, relaunch bool, cause error) error {
|
||||
refusal := fmt.Errorf("%w: %s has no %s/ tree", errShellMissing, installRoot, installlayout.AppShellDirName)
|
||||
if cause != nil {
|
||||
refusal = fmt.Errorf("%w: %s/ tree is unusable: %w", errShellMissing, installlayout.AppShellDirName, cause)
|
||||
}
|
||||
result, rollbackErr := rollbackLegacyPendingUpdate(installRoot, activeVersion)
|
||||
switch {
|
||||
case rollbackErr != nil:
|
||||
refusal = fmt.Errorf("%w; restore the previous release: %w", refusal, rollbackErr)
|
||||
case result.RolledBack:
|
||||
refusal = fmt.Errorf("%w; restored %s", refusal, result.FromVersion)
|
||||
if relaunch {
|
||||
if err := startRestoredRelease(installRoot); err != nil {
|
||||
refusal = fmt.Errorf("%w; start the restored release: %w", refusal, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
logMigrateRefusal(installRoot, result.RolledBack, refusal)
|
||||
return refusal
|
||||
}
|
||||
|
||||
func rollbackLegacyPendingUpdate(installRoot, activeVersion string) (repair.UpdateRollbackResult, error) {
|
||||
// Windows cannot replace the running migrator image, and its installer
|
||||
// carries the shell into update mode, so this state is Unix-only.
|
||||
if runtime.GOOS == "windows" {
|
||||
return repair.UpdateRollbackResult{}, nil
|
||||
}
|
||||
tx, err := readLegacyPendingUpdate(installRoot, activeVersion)
|
||||
if err != nil || tx == nil {
|
||||
return repair.UpdateRollbackResult{}, err
|
||||
}
|
||||
result, err := repair.RollbackPendingUpdateExact(tx)
|
||||
if err == nil && result.MixedInstall {
|
||||
err = fmt.Errorf("rollback left a mix of two releases")
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// startRestoredRelease relaunches the way the legacy updater itself does:
|
||||
// through its Guard when the restored unit has one, else the desktop directly.
|
||||
func startRestoredRelease(installRoot string) error {
|
||||
if guard := optionalRegular(filepath.Join(installRoot, "reasonix-guard")); guard != "" {
|
||||
return startDetached(installRoot, guard, "launch", "--detach")
|
||||
}
|
||||
if desktop := optionalRegular(filepath.Join(installRoot, installlayout.DesktopBinaryName())); desktop != "" {
|
||||
return startDetached(installRoot, desktop)
|
||||
}
|
||||
return fmt.Errorf("restored release has no entry point")
|
||||
}
|
||||
|
||||
func startDetached(dir, path string, args ...string) error {
|
||||
cmd := exec.Command(path, args...)
|
||||
cmd.Dir = dir
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// logMigrateRefusal persists the refusal: a migrator relaunched by an old
|
||||
// updater has no terminal, so stderr alone reaches nobody.
|
||||
func logMigrateRefusal(installRoot string, rolledBack bool, refusal error) {
|
||||
home := config.ReasonixHomeDir()
|
||||
if home == "" {
|
||||
return
|
||||
}
|
||||
path := filepath.Join(home, "desktop-shell", "logs", migrateLogName)
|
||||
if os.MkdirAll(filepath.Dir(path), 0o700) != nil {
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
_, _ = fmt.Fprintf(f, "%s event=legacy_migrate_refused code=shell_missing version=%s rolled_back=%t root=%q detail=%q\n",
|
||||
time.Now().UTC().Format(time.RFC3339), version, rolledBack, installRoot, refusal.Error())
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/installlayout"
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
const (
|
||||
legacyChildModeEnv = "REASONIX_LEGACY_MIGRATOR_TEST_CHILD"
|
||||
legacyChildRootEnv = "REASONIX_LEGACY_MIGRATOR_TEST_ROOT"
|
||||
legacyFromVersion = "v1.19.2"
|
||||
legacyToVersion = "v1.39.4"
|
||||
childShellMissing = 3
|
||||
)
|
||||
|
||||
func writeShellTree(t *testing.T, root, label string) {
|
||||
t.Helper()
|
||||
for _, name := range installlayout.ShellRequiredNames(runtime.GOOS) {
|
||||
p := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(label+"-"+name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func snapshotFlat(t *testing.T, root string) map[string]string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, entry := range entries {
|
||||
if !entry.Type().IsRegular() {
|
||||
out[entry.Name()] = "<dir>"
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, entry.Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out[entry.Name()] = string(data)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertNothingCommitted(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
if _, err := os.Lstat(filepath.Join(root, installlayout.CurrentFileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("current.json written for a release unit without a shell: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(root, "versions")); !os.IsNotExist(err) {
|
||||
t.Fatalf("versions/ created for a release unit without a shell: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertRefusalLogged(t *testing.T, want ...string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(config.ReasonixHomeDir(), "desktop-shell", "logs", migrateLogName))
|
||||
if err != nil {
|
||||
t.Fatalf("refusal was not logged: %v", err)
|
||||
}
|
||||
for _, w := range append([]string{"code=shell_missing"}, want...) {
|
||||
if !strings.Contains(string(data), w) {
|
||||
t.Fatalf("refusal log %q lacks %q", data, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateRefusesFlatUnitWithoutShell(t *testing.T) {
|
||||
cases := map[string]func(t *testing.T, root string){
|
||||
"absent": func(*testing.T, string) {},
|
||||
"incomplete": func(t *testing.T, root string) {
|
||||
p := filepath.Join(root, installlayout.AppShellDirName, "resources", "app.asar")
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("asar"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
}
|
||||
for name, shell := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
root := t.TempDir()
|
||||
writeFlatUnit(t, root, "new")
|
||||
if err := os.WriteFile(filepath.Join(root, "reasonix-guard"), []byte("migrator"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shell(t, root)
|
||||
before := snapshotFlat(t, root)
|
||||
|
||||
err := migrateWithRelaunch(root, legacyToVersion, false)
|
||||
if !errors.Is(err, errShellMissing) {
|
||||
t.Fatalf("migrate = %v, want errShellMissing", err)
|
||||
}
|
||||
assertNothingCommitted(t, root)
|
||||
if after := snapshotFlat(t, root); fmt.Sprint(after) != fmt.Sprint(before) {
|
||||
t.Fatalf("install root changed on refusal:\nbefore %v\nafter %v", before, after)
|
||||
}
|
||||
assertRefusalLogged(t, "rolled_back=false")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// legacyUpdatedInstall reproduces what a 1.18–1.19.2 updater leaves behind: it
|
||||
// runs the updater's own transaction steps from inside the install, publishing
|
||||
// only desktop, CLI and the migrator under the Guard name.
|
||||
func legacyUpdatedInstall(t *testing.T, withShell bool) (root string, oldDesktop []byte) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("the file-by-file legacy updater is the Unix portable path")
|
||||
}
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldDesktop, err = os.ReadFile(self)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root = t.TempDir()
|
||||
for name, body := range map[string][]byte{
|
||||
"reasonix-desktop": oldDesktop,
|
||||
"reasonix-guard": []byte("old-guard"),
|
||||
"reasonix": []byte("old-cli"),
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(root, name), body, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if withShell {
|
||||
writeShellTree(t, root, "new")
|
||||
}
|
||||
if code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-desktop"), "legacy-update", root); code != 0 {
|
||||
t.Fatalf("legacy updater child exited %d: %s", code, out)
|
||||
}
|
||||
if got, _ := os.ReadFile(filepath.Join(root, "reasonix-desktop")); string(got) != "new-desktop" {
|
||||
t.Fatalf("legacy updater did not publish the new desktop: %q", got)
|
||||
}
|
||||
return root, oldDesktop
|
||||
}
|
||||
|
||||
func runLegacyChildProcess(t *testing.T, exe, mode, root string) (int, string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command(exe, "-test.run=^$")
|
||||
cmd.Env = append(os.Environ(), legacyChildModeEnv+"="+mode, legacyChildRootEnv+"="+root)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &out, &out
|
||||
err := cmd.Run()
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
return exit.ExitCode(), out.String()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("run %s child: %v", mode, err)
|
||||
}
|
||||
return 0, out.String()
|
||||
}
|
||||
|
||||
func TestLegacyUpdaterWithoutShellRestoresPreviousRelease(t *testing.T) {
|
||||
root, oldDesktop := legacyUpdatedInstall(t, false)
|
||||
code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-guard"), "migrate", root)
|
||||
if code != childShellMissing {
|
||||
t.Fatalf("migrator child exited %d, want errShellMissing: %s", code, out)
|
||||
}
|
||||
assertNothingCommitted(t, root)
|
||||
for name, want := range map[string][]byte{
|
||||
"reasonix-desktop": oldDesktop,
|
||||
"reasonix-guard": []byte("old-guard"),
|
||||
"reasonix": []byte("old-cli"),
|
||||
} {
|
||||
if got, err := os.ReadFile(filepath.Join(root, name)); err != nil || !bytes.Equal(got, want) {
|
||||
t.Fatalf("%s was not restored to the previous release (err %v)", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(root, installlayout.LauncherBinaryName())); !os.IsNotExist(err) {
|
||||
t.Fatalf("thin launcher installed beside a restored legacy release: %v", err)
|
||||
}
|
||||
if repair.PendingUpdateExists() {
|
||||
t.Fatal("legacy transaction still pending after rollback")
|
||||
}
|
||||
assertRefusalLogged(t, "rolled_back=true")
|
||||
}
|
||||
|
||||
func TestLegacyUpdaterWithShellStillCommitsTransaction(t *testing.T) {
|
||||
root, _ := legacyUpdatedInstall(t, true)
|
||||
if code, out := runLegacyChildProcess(t, filepath.Join(root, "reasonix-guard"), "migrate", root); code != 0 {
|
||||
t.Fatalf("migrator child exited %d: %s", code, out)
|
||||
}
|
||||
ptr, err := installlayout.ReadCurrent(root)
|
||||
if err != nil || ptr.ActiveVersion != legacyToVersion {
|
||||
t.Fatalf("pointer=%+v err=%v", ptr, err)
|
||||
}
|
||||
desktop, err := installlayout.ActiveDesktopPath(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := os.ReadFile(desktop); string(got) != "new-desktop" {
|
||||
t.Fatalf("active desktop = %q", got)
|
||||
}
|
||||
if !installlayout.HasActiveShell(root) {
|
||||
t.Fatal("active version lost its shell")
|
||||
}
|
||||
if repair.PendingUpdateExists() {
|
||||
t.Fatal("legacy transaction was not committed")
|
||||
}
|
||||
}
|
||||
|
||||
func runLegacyChild(mode string) int {
|
||||
root := os.Getenv(legacyChildRootEnv)
|
||||
var err error
|
||||
switch mode {
|
||||
case "legacy-update":
|
||||
err = applyLegacyUpdate(root)
|
||||
case "migrate":
|
||||
err = migrateWithRelaunch(root, legacyToVersion, false)
|
||||
if errors.Is(err, errShellMissing) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return childShellMissing
|
||||
}
|
||||
default:
|
||||
err = fmt.Errorf("unknown child mode %q", mode)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// applyLegacyUpdate follows applyLinux from v1.19.2 desktop/updater.go step
|
||||
// for step, with the running desktop as the updater.
|
||||
func applyLegacyUpdate(root string) error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
migrator, err := os.ReadFile(exe)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
desktop := filepath.Join(root, "reasonix-desktop")
|
||||
guard := filepath.Join(root, "reasonix-guard")
|
||||
cli := filepath.Join(root, "reasonix")
|
||||
tx, err := repair.PrepareFileUpdate(legacyFromVersion, legacyToVersion, desktop, guard, cli)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare: %w", err)
|
||||
}
|
||||
claimed, release, err := repair.ClaimPendingFileUpdateExact(tx.ToVersion, tx.CreatedAt, repair.UpdateTransactionID(tx), desktop, []string{desktop, guard, cli}, 2*time.Minute)
|
||||
if err != nil {
|
||||
return fmt.Errorf("claim: %w", err)
|
||||
}
|
||||
defer release()
|
||||
if err := repair.MarkUpdateApplyFailedExact(claimed, "Linux update publish did not complete"); err != nil {
|
||||
return err
|
||||
}
|
||||
var receipts []repair.FileUpdateInstallReceipt
|
||||
for _, member := range []struct {
|
||||
path string
|
||||
body []byte
|
||||
}{{cli, []byte("new-cli")}, {guard, migrator}, {desktop, []byte("new-desktop")}} {
|
||||
receipt, err := repair.PublishClaimedFileUpdateMemberExact(claimed, member.path, member.body, 0o700)
|
||||
if err != nil {
|
||||
return fmt.Errorf("publish %s: %w", filepath.Base(member.path), err)
|
||||
}
|
||||
receipts = append(receipts, receipt)
|
||||
}
|
||||
if _, err := repair.RecordClaimedFileUpdateInstalled(claimed, receipts...); err != nil {
|
||||
return fmt.Errorf("record installed: %w", err)
|
||||
}
|
||||
return repair.ClearUpdateApplyFailureExact(claimed)
|
||||
}
|
||||
+2
-2
@@ -193,8 +193,8 @@ not depend on homepage badge semantics. Self-update behavior by platform:
|
||||
|
||||
### Code signing — first launch
|
||||
|
||||
- **Windows** — stable builds carry an Authenticode signature (SignPath, approved
|
||||
per release; `release-desktop.yml` verifies every payload binary through
|
||||
- **Windows** — stable builds carry an Authenticode signature (Certum certificate;
|
||||
`release-desktop.yml` verifies every payload binary through
|
||||
`scripts/verify-windows-authenticode.ps1` and fails the release otherwise). A
|
||||
brand-new version can still show SmartScreen until the signature accumulates
|
||||
reputation: *More info → Run anyway*.
|
||||
|
||||
+16
-11
@@ -4820,7 +4820,7 @@ func migrateLegacyWorkspacesIntoProjects() {
|
||||
if len(legacy) == 0 {
|
||||
return
|
||||
}
|
||||
_ = updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
||||
_ = updateProjectsFilePreservingLegacyState(func(f *desktopProjectFile) (bool, error) {
|
||||
seen := make(map[string]bool, len(f.Projects)+len(legacy))
|
||||
for _, p := range f.Projects {
|
||||
seen[p.Root] = true
|
||||
@@ -6148,6 +6148,13 @@ func firstNonEmpty(values ...string) string {
|
||||
}
|
||||
|
||||
func (a *App) ContextUsageForTab(tabID string) ContextInfo {
|
||||
if a.isRemoteTab(tabID) {
|
||||
used, window, ok := a.remoteContextSnapshot(tabID)
|
||||
if ok {
|
||||
return ContextInfo{Used: used, Window: window}
|
||||
}
|
||||
return ContextInfo{}
|
||||
}
|
||||
read := a.captureContextRead(tabID)
|
||||
ctrl := read.ctrl
|
||||
var info ContextInfo
|
||||
@@ -7449,11 +7456,7 @@ func (a *App) mcpServersView() []ServerView {
|
||||
}
|
||||
|
||||
func mcpEntryEnabled(p config.PluginEntry, workspace string) bool {
|
||||
enabled, err := config.DefaultMCPActivationStore().IsEnabled(p, workspace)
|
||||
if err != nil {
|
||||
return p.ShouldAutoStart()
|
||||
}
|
||||
return enabled
|
||||
return config.MCPServerEnabled(p, workspace)
|
||||
}
|
||||
|
||||
func mcpRuntimeState(status string) string {
|
||||
@@ -8517,6 +8520,9 @@ func (a *App) ReconnectMCPServer(name string) error {
|
||||
if !found {
|
||||
return fmt.Errorf("no configured MCP server named %q", name)
|
||||
}
|
||||
if err := config.RecordExplicitStart(entry, root); err != nil {
|
||||
return err
|
||||
}
|
||||
controllers := a.mcpControllersSharingHost(host, name, ctrl)
|
||||
for i := range controllers {
|
||||
if controllers[i].ctrl == ctrl {
|
||||
@@ -8673,6 +8679,9 @@ func (a *App) SetMCPServerTier(name, tier string) error {
|
||||
}
|
||||
a.bumpExtensionGeneration()
|
||||
if tab != nil && ctrl != nil && !mcpConnected(ctrl, name) {
|
||||
if err := config.RecordExplicitStart(updated, root); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := ctrl.ConnectMCPServer(updated); err != nil {
|
||||
recordMCPFailure(ctrl, updated, err)
|
||||
return nil
|
||||
@@ -8707,7 +8716,7 @@ func (a *App) saveDesktopMCPServer(root string, entry config.PluginEntry) error
|
||||
if err := ensureMCPServerDirectlyWritable(root, entry.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := config.UpsertPluginInSourceForRoot(root, entry)
|
||||
_, err := config.UpsertPluginKeepingDecision(root, entry)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9871,10 +9880,6 @@ func previewMediaKind(path string) (kind string, mime string) {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func (a *App) activeWorkspaceBase() (string, error) {
|
||||
return workspaceBaseFromRoot(a.activeWorkspaceRoot())
|
||||
}
|
||||
|
||||
func (a *App) workspaceTargetForTab(tabID string) (string, control.SessionAPI, bool) {
|
||||
tabID = strings.TrimSpace(tabID)
|
||||
a.mu.RLock()
|
||||
|
||||
+42
-71
@@ -1103,6 +1103,7 @@ status_bar_items = ["cost", "balance"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
approveWorkspace(t, project)
|
||||
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if err := userCfg.SetDesktopLanguage("en"); err != nil {
|
||||
@@ -1345,54 +1346,26 @@ func TestSettingsShowsGlobalCredentialWithoutMutatingWorkspaceEnv(t *testing.T)
|
||||
t.Fatalf("settings provider missing from settings: %+v", got.Providers)
|
||||
}
|
||||
|
||||
func TestSettingsSeedsMissingUserConfigFromLegacyProjectConfig(t *testing.T) {
|
||||
// With no user config yet, Settings start from the defaults, not from the
|
||||
// checkout's reasonix.toml, and the first edit writes only the user's choice.
|
||||
func TestSettingsNeverSeedTheUserConfigFromTheProjectFile(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
default_model = "legacy-provider/legacy-model"
|
||||
|
||||
[desktop]
|
||||
language = "zh"
|
||||
layout_style = "workbench"
|
||||
theme = "light"
|
||||
theme_style = "glacier"
|
||||
close_behavior = "quit"
|
||||
status_bar_style = "text"
|
||||
status_bar_items = ["model", "cache", "balance"]
|
||||
`), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte("[desktop]\ntheme = \"light\"\n\n[permissions]\nmode = \"allow\"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
approveWorkspace(t, project)
|
||||
t.Chdir(project)
|
||||
app := NewApp()
|
||||
got := app.Settings()
|
||||
if got.ConfigPath != config.UserConfigPath() {
|
||||
t.Fatalf("Settings configPath = %q, want user config %q", got.ConfigPath, config.UserConfigPath())
|
||||
}
|
||||
if got.DefaultModel != "legacy-provider/legacy-model" || got.DesktopLanguage != "zh" || got.DesktopLayoutStyle != "workbench" || got.DesktopTheme != "light" || got.DesktopThemeStyle != "glacier" || got.CloseBehavior != "quit" || got.StatusBarStyle != "icon" {
|
||||
t.Fatalf("Settings did not seed from legacy project config: %+v", got)
|
||||
}
|
||||
if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(got.StatusBarItems, want) {
|
||||
t.Fatalf("Settings did not seed status bar items from legacy project config: got %v want %v", got.StatusBarItems, want)
|
||||
}
|
||||
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("Settings() should not write user config before an edit, stat err = %v", err)
|
||||
if got := app.Settings(); got.ConfigPath != config.UserConfigPath() || got.DesktopTheme == "light" {
|
||||
t.Fatalf("Settings = path %q theme %q, want the user config's defaults", got.ConfigPath, got.DesktopTheme)
|
||||
}
|
||||
if err := app.SetDesktopLanguage("en"); err != nil {
|
||||
t.Fatalf("SetDesktopLanguage: %v", err)
|
||||
}
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if userCfg.DesktopLanguage() != "en" || userCfg.DesktopLayoutStyle() != "workbench" || userCfg.DesktopTheme() != "light" || userCfg.DesktopThemeStyle() != "glacier" || userCfg.DesktopCloseBehavior() != "quit" || userCfg.DesktopStatusBarStyle() != "icon" {
|
||||
t.Fatalf("saved user config did not preserve seeded desktop prefs: lang:%q layout:%q theme:%q style:%q close:%q status:%q", userCfg.DesktopLanguage(), userCfg.DesktopLayoutStyle(), userCfg.DesktopTheme(), userCfg.DesktopThemeStyle(), userCfg.DesktopCloseBehavior(), userCfg.DesktopStatusBarStyle())
|
||||
}
|
||||
if want := []string{"model", "cache", "balance"}; !reflect.DeepEqual(userCfg.DesktopStatusBarItems(), want) {
|
||||
t.Fatalf("saved user config did not preserve seeded status bar items: got %v want %v", userCfg.DesktopStatusBarItems(), want)
|
||||
if userCfg.DesktopLanguage() != "en" || userCfg.DesktopTheme() == "light" || userCfg.Permissions.Mode == "allow" {
|
||||
t.Fatalf("user config took the checkout's values: theme %q mode %q", userCfg.DesktopTheme(), userCfg.Permissions.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3227,6 +3200,7 @@ api_key_env = "DEEPSEEK_API_KEY"
|
||||
if err := os.WriteFile(filepath.Join(projectRoot, "reasonix.toml"), []byte(projectConfig), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
approveWorkspace(t, projectRoot)
|
||||
|
||||
app := NewApp()
|
||||
tab := &WorkspaceTab{ID: "project", WorkspaceRoot: projectRoot, Ready: true}
|
||||
@@ -3954,6 +3928,7 @@ default_effort = "max"
|
||||
if err := os.WriteFile(filepath.Join(projectA, "reasonix.toml"), []byte(ownerConfig), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, projectA)
|
||||
staleConfig := `default_model = "stale/stale-model"
|
||||
[[providers]]
|
||||
name = "stale"
|
||||
@@ -3966,6 +3941,7 @@ reasoning_protocol = "none"
|
||||
if err := os.WriteFile(filepath.Join(projectB, "reasonix.toml"), []byte(staleConfig), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, projectB)
|
||||
|
||||
topicID := "topic_effort_owner"
|
||||
topicTitle := "Effort owner"
|
||||
@@ -6909,39 +6885,6 @@ func (r *appendingDesktopRunner) Run(_ context.Context, input string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
|
||||
[[plugins]]
|
||||
name = "playwright"
|
||||
command = "npx"
|
||||
args = ["-y", "@playwright/mcp"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
defer func() {
|
||||
if c := app.activeCtrl(); c != nil {
|
||||
c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
view := app.Capabilities()
|
||||
for _, s := range view.Servers {
|
||||
if s.Name == "playwright" {
|
||||
if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" {
|
||||
t.Fatalf("default MCP view = %+v, want deferred automatic idle", s)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
|
||||
}
|
||||
|
||||
func TestCapabilitiesIncludesInstalledPlugins(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
reasonixHome := config.ReasonixHomeDir()
|
||||
@@ -7006,6 +6949,8 @@ url = %q
|
||||
`, srv.URL), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForTest(t, dir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -7093,6 +7038,7 @@ args = ["-y", "@playwright/mcp"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -7116,6 +7062,7 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -7164,6 +7111,7 @@ url = "http://127.0.0.1:1/mcp"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
host := plugin.NewHost()
|
||||
defer host.Close()
|
||||
@@ -7202,6 +7150,7 @@ url = %q
|
||||
`, srv.URL), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -7352,6 +7301,7 @@ url = %q
|
||||
`, projectServer.URL), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
host := plugin.NewHost()
|
||||
t.Cleanup(host.Close)
|
||||
@@ -7505,6 +7455,7 @@ network = true
|
||||
`, exe, singleInstanceAddr, gateConfig), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
entry := config.PluginEntry{
|
||||
Name: "h", Command: exe, Args: helperArgs,
|
||||
@@ -8323,6 +8274,7 @@ args = ["serve"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -8434,6 +8386,8 @@ command = "reasonix-missing-mcp-binary"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -8961,6 +8915,8 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -8992,6 +8948,7 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9022,6 +8979,8 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
host := plugin.NewHost()
|
||||
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
|
||||
@@ -9056,6 +9015,8 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
host := plugin.NewHost()
|
||||
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
|
||||
@@ -9115,6 +9076,7 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9192,6 +9154,7 @@ args = ["-y", "@playwright/mcp"]
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9230,6 +9193,8 @@ tier = "background"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9280,6 +9245,8 @@ name = "codegraph"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(desktopFakeTool{name: "mcp__codegraph__connect"})
|
||||
@@ -9338,6 +9305,8 @@ tier = "lazy"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
@@ -9431,6 +9400,8 @@ tier = "eager"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
approveWorkspace(t, dir)
|
||||
enableProjectMCPForWorkspace(t, dir, "")
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
|
||||
|
||||
@@ -90,7 +90,12 @@ func TestRetryAuthenticationPublishesAuthoritativeTabRefresh(t *testing.T) {
|
||||
}
|
||||
for _, item := range app.ListTabs() {
|
||||
if item.ID == tab.ID && item.Authentication != nil && item.Authentication.Ready() {
|
||||
refreshed <- true
|
||||
// 非阻塞发送:该回调跑在共享的 asyncRuntimeEmitter.run 上,
|
||||
// 阻塞发送会把后续所有 runtime 事件永久卡死。
|
||||
select {
|
||||
case refreshed <- true:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -103,4 +108,23 @@ func TestRetryAuthenticationPublishesAuthoritativeTabRefresh(t *testing.T) {
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("retry did not publish a refresh of authoritative authentication state")
|
||||
}
|
||||
|
||||
// 回归:缓冲已满时再次触发同一事件,emitter 泵必须继续排空而不是停在
|
||||
// tabs.go 的 emit 调用点上(曾实证 chan send 卡死 7 分钟)。
|
||||
for range 3 {
|
||||
app.runtimeEvents.Emit(app.ctx, tabMetaRefreshEventChannel)
|
||||
}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
app.runtimeEvents.mu.Lock()
|
||||
busy := app.runtimeEvents.running
|
||||
app.runtimeEvents.mu.Unlock()
|
||||
if !busy {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("asyncRuntimeEmitter pump stayed blocked after repeated refresh events")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
+17
-2
@@ -64,6 +64,10 @@ type botBridgeHub struct {
|
||||
lastPersistSeq uint64
|
||||
|
||||
queue chan desktopBridgeNotification
|
||||
// closed 终止 run worker;queue 本身不能 close——observe 仍可能在
|
||||
// controller 事件 goroutine 上并发 enqueue,向已关闭 channel 发送会 panic。
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
type desktopPendingPrompt struct {
|
||||
@@ -135,11 +139,17 @@ func newBotBridgeHub(deps botBridgeDeps) *botBridgeHub {
|
||||
takeovers: make(map[string]bot.DesktopWatchRoute),
|
||||
takeoverTabs: make(map[string]string),
|
||||
queue: make(chan desktopBridgeNotification, botBridgeQueueSize),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
go h.run()
|
||||
return h
|
||||
}
|
||||
|
||||
// Close 停掉通知 worker goroutine。幂等;App 关停时调用。
|
||||
func (h *botBridgeHub) Close() {
|
||||
h.closeOnce.Do(func() { close(h.closed) })
|
||||
}
|
||||
|
||||
// observe 接收某个桌面会话的一条事件。在 controller 事件 goroutine 上运行,
|
||||
// 只做内存记账和入队,不做任何阻塞调用。
|
||||
func (h *botBridgeHub) observe(tabID string, e event.Event) {
|
||||
@@ -209,8 +219,13 @@ func (h *botBridgeHub) enqueue(n desktopBridgeNotification) {
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) run() {
|
||||
for n := range h.queue {
|
||||
h.deliver(n)
|
||||
for {
|
||||
select {
|
||||
case <-h.closed:
|
||||
return
|
||||
case n := <-h.queue:
|
||||
h.deliver(n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,12 @@ func (a *App) newBotBridge() *botBridgeHub {
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) stopBotBridge() {
|
||||
if a.botBridge != nil {
|
||||
a.botBridge.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// bridgeSessions 枚举所有 live 会话:可见 tab 用完整 TabMeta,后台 detached
|
||||
// 会话补一份轻量快照(controller 仍存活,审批/问答仍可路由)。
|
||||
func (a *App) bridgeSessions() []bot.DesktopSessionInfo {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -129,6 +130,28 @@ func testGroupRoute() bot.DesktopWatchRoute {
|
||||
return r
|
||||
}
|
||||
|
||||
// newBotBridgeHub 启动一个 run worker;Close 必须让它退出,否则每个 App 实例
|
||||
// 都泄漏一个 goroutine(转储曾观测到上千个)。enqueue 在 Close 后仍不得 panic:
|
||||
// observe 可能在 controller 事件 goroutine 上与关停并发。
|
||||
func TestBridgeCloseStopsRunGoroutine(t *testing.T) {
|
||||
before := runtime.NumGoroutine()
|
||||
env := newBridgeTestEnv(nil)
|
||||
env.hub.SetWatch(testWatchRoute(), true)
|
||||
env.hub.Close()
|
||||
env.hub.Close() // 幂等
|
||||
// 有订阅者时 observe 会真正 enqueue;Close 后不得 panic。
|
||||
env.hub.observe("tab-1", event.Event{Kind: event.TurnDone})
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if runtime.NumGoroutine() <= before {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("hub run goroutine still alive after Close: %d goroutines > baseline %d", runtime.NumGoroutine(), before)
|
||||
}
|
||||
|
||||
func TestBridgeTakeoverRejectsGroupChat(t *testing.T) {
|
||||
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一", Ready: true}})
|
||||
if _, err := env.hub.Takeover(testGroupRoute(), "tab-1"); err == nil {
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/botruntime"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/session"
|
||||
)
|
||||
|
||||
type BotRuntimeStatusView struct {
|
||||
@@ -123,7 +124,7 @@ func (a *App) refreshBotRuntime() {
|
||||
a.botBridge.seedWatchers(bridgeRoutesFromConfig(cfg.Bot.DesktopWatchers), watcherVersion)
|
||||
bridge = a.botBridge
|
||||
}
|
||||
_ = a.botRuntime.apply(a.bootContext(), cfg, globalTabWorkspaceRoot(), a.persistRemoteBotToolApprovalMode, bridge)
|
||||
_ = a.botRuntime.apply(a.bootContext(), cfg, globalTabWorkspaceRoot(), a.persistRemoteBotToolApprovalMode, bridge, a.historicalSessionService)
|
||||
}
|
||||
|
||||
func (a *App) loadDesktopBotConfig() (*config.Config, error) {
|
||||
@@ -151,7 +152,7 @@ func (a *App) BotRuntimeStatus() BotRuntimeStatusView {
|
||||
return a.botRuntime.snapshot()
|
||||
}
|
||||
|
||||
func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, workspaceRoot string, onToolApprovalModeChange func(bot.InboundMessage, string) error, bridge bot.DesktopBridge) error {
|
||||
func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, workspaceRoot string, onToolApprovalModeChange func(bot.InboundMessage, string) error, bridge bot.DesktopBridge, sessionServiceForRoot func(string) (*session.Service, error)) error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -234,6 +235,7 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
|
||||
OnSessionReady: botruntime.NewSessionRemembererWithWorkspace(logger, workspaceRoot),
|
||||
OnToolApprovalModeChange: onToolApprovalModeChange,
|
||||
Desktop: bridge,
|
||||
SessionServiceForRoot: sessionServiceForRoot,
|
||||
}
|
||||
bindings := botruntime.AdapterBindings(cfg, plan.Enabled, nil, logger)
|
||||
if len(bindings) == 0 {
|
||||
|
||||
+30
-155
@@ -265,19 +265,18 @@ func TestDesktopBotRuntimeConfigLoadsAllSavedCredentialsAfterRestart(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimeMigratesLegacyProjectBotSettings(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
userCfg := config.Default()
|
||||
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
|
||||
t.Fatalf("set desktop appearance: %v", err)
|
||||
}
|
||||
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save user config: %v", err)
|
||||
}
|
||||
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
// A checkout's [bot] never reaches the bot runtime or the user's config, with
|
||||
// or without a user config of its own.
|
||||
func TestDesktopBotRuntimeIgnoresProjectBotSettings(t *testing.T) {
|
||||
for _, withUser := range []bool{false, true} {
|
||||
isolateDesktopUserDirs(t)
|
||||
if withUser {
|
||||
if err := config.Default().SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save user config: %v", err)
|
||||
}
|
||||
}
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
[bot]
|
||||
enabled = true
|
||||
|
||||
@@ -289,154 +288,30 @@ feishu_users = ["ou-legacy"]
|
||||
id = "feishu-lark"
|
||||
provider = "feishu"
|
||||
domain = "lark"
|
||||
label = "Lark"
|
||||
enabled = true
|
||||
status = "connected"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
got, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
|
||||
}
|
||||
|
||||
// The bot-runtime load is a pure read: the merge above stays in memory and
|
||||
// the user config file is not rewritten.
|
||||
preWrite := config.LoadForEdit(config.UserConfigPath())
|
||||
if preWrite.Bot.Enabled || len(preWrite.Bot.Connections) != 0 {
|
||||
t.Fatalf("read path persisted bot config = %+v, want disk untouched until a locked write", preWrite.Bot)
|
||||
}
|
||||
|
||||
// The first locked write path performs the on-disk migration.
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
persisted := config.LoadForEdit(config.UserConfigPath())
|
||||
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
|
||||
}
|
||||
if persisted.DesktopTheme() != "dark" {
|
||||
t.Fatalf("desktop theme = %q, want preserved user preference", persisted.DesktopTheme())
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
t.Chdir(project)
|
||||
app := NewApp()
|
||||
got, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
if got.Bot.Enabled || len(got.Bot.Connections) != 0 {
|
||||
t.Fatalf("desktop bot config = %+v, want the checkout's [bot] ignored", got.Bot)
|
||||
}
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
if persisted := config.LoadForEdit(config.UserConfigPath()); persisted.Bot.Enabled || len(persisted.Bot.Connections) != 0 {
|
||||
t.Fatalf("user config took the checkout's [bot]: %+v", persisted.Bot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimePersistsLegacyProjectBotWhenUserConfigMissing(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
[desktop]
|
||||
theme = "dark"
|
||||
|
||||
[bot]
|
||||
enabled = true
|
||||
|
||||
[bot.allowlist]
|
||||
enabled = true
|
||||
feishu_users = ["ou-legacy"]
|
||||
|
||||
[[bot.connections]]
|
||||
id = "feishu-lark"
|
||||
provider = "feishu"
|
||||
domain = "lark"
|
||||
label = "Lark"
|
||||
enabled = true
|
||||
status = "connected"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
got, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
|
||||
}
|
||||
|
||||
// The bot-runtime load is a pure read: it serves the legacy config from
|
||||
// memory and must not create the user config file.
|
||||
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("read path must not create the user config, stat err = %v", err)
|
||||
}
|
||||
|
||||
// The first locked write path creates the user config with the migrated
|
||||
// bot settings (adopting the legacy config, ConfigVersion-bumped).
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
persisted := config.LoadForEdit(config.UserConfigPath())
|
||||
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSettingsBotMigrationPersistsOnlyBotBeforeFirstEdit(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
[desktop]
|
||||
theme = "dark"
|
||||
close_behavior = "quit"
|
||||
|
||||
[bot]
|
||||
enabled = true
|
||||
|
||||
[bot.allowlist]
|
||||
enabled = true
|
||||
feishu_users = ["ou-legacy"]
|
||||
|
||||
[[bot.connections]]
|
||||
id = "feishu-lark"
|
||||
provider = "feishu"
|
||||
domain = "lark"
|
||||
label = "Lark"
|
||||
enabled = true
|
||||
status = "connected"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
settings := NewApp().Settings()
|
||||
if !settings.Bot.Enabled || len(settings.Bot.Connections) != 1 || settings.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("settings bot = %+v, want migrated legacy Lark connection", settings.Bot)
|
||||
}
|
||||
if settings.DesktopTheme != "dark" || settings.CloseBehavior != "quit" {
|
||||
t.Fatalf("settings desktop prefs = theme:%q close:%q, want legacy seed visible before first edit", settings.DesktopTheme, settings.CloseBehavior)
|
||||
}
|
||||
|
||||
persisted := config.LoadForEdit(config.UserConfigPath())
|
||||
if persisted.DesktopTheme() == "dark" || persisted.DesktopCloseBehavior() == "quit" {
|
||||
t.Fatalf("persisted desktop prefs = theme:%q close:%q, want bot-only migration", persisted.DesktopTheme(), persisted.DesktopCloseBehavior())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimeMigrationDoesNotOverwriteUserBotSettings(t *testing.T) {
|
||||
func TestDesktopBotRuntimeKeepsUserBotSettingsBesideAProjectBot(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
userCfg := config.Default()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/session"
|
||||
)
|
||||
|
||||
const embeddedBotSessionPrefix = "bot-session:local:"
|
||||
|
||||
// Embedded bot conversations keep their existing project-local store. Resolve
|
||||
// the sidebar's stable session reference there before creating a surface; a
|
||||
// forged reference must not open a session belonging to another workspace.
|
||||
func (a *App) embeddedBotSessionPath(scope, workspaceRoot, route string) (string, error) {
|
||||
id := strings.TrimPrefix(route, embeddedBotSessionPrefix)
|
||||
if err := session.ValidateSessionID(id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
root := globalTabWorkspaceRoot()
|
||||
if scope == "project" {
|
||||
root = normalizeProjectRoot(workspaceRoot)
|
||||
if root == "" {
|
||||
return "", fmt.Errorf("workspaceRoot is required")
|
||||
}
|
||||
}
|
||||
storeRoot := session.RootForLegacyDir(config.ProjectSessionDir(root))
|
||||
service, err := a.historicalSessionService(storeRoot)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := service.Query().Stat(a.bootContext(), session.SessionRef{HostID: localDesktopHostID, SessionID: id})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
same, err := sameDesktopPathStrict(info.CWD, root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !same {
|
||||
return "", errSessionWorkspaceConflict
|
||||
}
|
||||
path := filepath.Join(storeRoot, id)
|
||||
if !nativeStoredDirectory(path) {
|
||||
return "", session.ErrSessionNotFound
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -46,7 +46,11 @@ type DeliveryWorktreeOpenResult = IsolatedWorktreeOpenResult
|
||||
// optional Git isolation path. A false result never disables writing itself;
|
||||
// the cross-platform workspace writer lease remains the no-Git fallback.
|
||||
func (a *App) IsolatedWorktreeAvailability(workspaceRoot string) worktree.Availability {
|
||||
return inspectDeliveryWorktree(a.bootContext(), workspaceRoot)
|
||||
repo, err := a.workspaceRepoForRoot(workspaceRoot)
|
||||
if err != nil {
|
||||
return worktree.Availability{Reason: err.Error()}
|
||||
}
|
||||
return inspectDeliveryWorktree(a.bootContext(), repo)
|
||||
}
|
||||
|
||||
// CreateIsolatedWorktree creates a durable branch-backed worktree and opens it
|
||||
@@ -61,7 +65,11 @@ func (a *App) CreateIsolatedWorktree(workspaceRoot string) (IsolatedWorktreeOpen
|
||||
return worktree.Result{}, err
|
||||
}
|
||||
defer releaseAdmission()
|
||||
return createDeliveryWorktree(a.bootContext(), workspaceRoot, config.DeliveryWorktreeDir())
|
||||
repo, err := a.workspaceRepoForRoot(workspaceRoot)
|
||||
if err != nil {
|
||||
return worktree.Result{}, err
|
||||
}
|
||||
return createDeliveryWorktree(a.bootContext(), repo, config.DeliveryWorktreeDir())
|
||||
}()
|
||||
if err != nil {
|
||||
return IsolatedWorktreeOpenResult{}, err
|
||||
|
||||
@@ -7,13 +7,15 @@ import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/gitcmd"
|
||||
"reasonix/internal/worktree"
|
||||
)
|
||||
|
||||
func TestDeliveryWorktreeAvailabilityDelegatesWithoutRequiringGit(t *testing.T) {
|
||||
original := inspectDeliveryWorktree
|
||||
t.Cleanup(func() { inspectDeliveryWorktree = original })
|
||||
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
|
||||
root := filepath.Base(repo.Dir)
|
||||
return worktree.Availability{Available: false, Reason: "Git is not installed", RepoRoot: root}
|
||||
}
|
||||
got := NewApp().DeliveryWorktreeAvailability("project")
|
||||
@@ -35,7 +37,8 @@ func TestCreateDeliveryWorktreeRegistersAndOpensManagedProject(t *testing.T) {
|
||||
|
||||
original := createDeliveryWorktree
|
||||
t.Cleanup(func() { createDeliveryWorktree = original })
|
||||
createDeliveryWorktree = func(_ context.Context, source, gotManaged string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(_ context.Context, sourceRepo gitcmd.Repo, gotManaged string) (worktree.Result, error) {
|
||||
source := filepath.Base(sourceRepo.Dir)
|
||||
if source != "source-project" {
|
||||
t.Fatalf("source = %q", source)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -23,8 +24,12 @@ func acquireDesktopProjectsFileLock() (func(), error) {
|
||||
|
||||
// updateProjectsFileCrossProcessLocked requires desktopProjectsFileMu and the
|
||||
// desktop-projects cross-process file lock.
|
||||
func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (bool, error)) error {
|
||||
func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (bool, error), assignCollisions bool) error {
|
||||
f := loadProjectsFile()
|
||||
previous := make([]string, 0, len(f.Projects))
|
||||
for _, project := range f.Projects {
|
||||
previous = append(previous, project.Root)
|
||||
}
|
||||
changed, err := mutator(&f)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -32,5 +37,10 @@ func updateProjectsFileCrossProcessLocked(mutator func(*desktopProjectFile) (boo
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
if assignCollisions {
|
||||
if err := assignAddedProjectStateCollisions(previous, f.Projects); err != nil {
|
||||
return fmt.Errorf("%w: %w", errProjectStateCollisionAssignment, err)
|
||||
}
|
||||
}
|
||||
return saveProjectsFile(f)
|
||||
}
|
||||
+6
-3
@@ -66,7 +66,8 @@ func (a *App) forkForTabWithOptions(tabID string, turn int, isolateWorkspace boo
|
||||
if scope != "project" || strings.TrimSpace(srcRoot) == "" {
|
||||
result.FallbackToShared = true
|
||||
} else {
|
||||
avail := inspectDeliveryWorktree(a.bootContext(), srcRoot)
|
||||
srcRepo, _ := workspaceRepo(srcRoot, ctrl)
|
||||
avail := inspectDeliveryWorktree(a.bootContext(), srcRepo)
|
||||
if !avail.Available {
|
||||
result.FallbackToShared = true
|
||||
} else if avail.SourceDirty {
|
||||
@@ -80,7 +81,7 @@ func (a *App) forkForTabWithOptions(tabID string, turn int, isolateWorkspace boo
|
||||
return worktree.Result{}, err
|
||||
}
|
||||
defer releaseAdmission()
|
||||
return createDeliveryWorktree(a.bootContext(), srcRoot, config.DeliveryWorktreeDir())
|
||||
return createDeliveryWorktree(a.bootContext(), srcRepo, config.DeliveryWorktreeDir())
|
||||
}()
|
||||
if createErr != nil {
|
||||
return ForkWorktreeResultView{}, fmt.Errorf("create isolated fork worktree: %w", createErr)
|
||||
@@ -280,7 +281,9 @@ func (a *App) openForkedSessionTabWithWorkspace(sourceTab *WorkspaceTab, locator
|
||||
if err := prependTopicInProjectsFile(workspaceRoot, topicID, true); err != nil {
|
||||
slog.Warn("desktop: persist isolated fork topic", "workspace", workspaceRoot, "topic", topicID, "err", err)
|
||||
}
|
||||
a.registerProjectRoot(workspaceRoot)
|
||||
if err := a.registerProjectRoot(workspaceRoot); err != nil {
|
||||
return forkedSessionTabOpen{}, err
|
||||
}
|
||||
}
|
||||
if hook := forkTabBeforePublishHookForTest.Load(); hook != nil {
|
||||
(*hook)()
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/gitcmd"
|
||||
"reasonix/internal/worktree"
|
||||
)
|
||||
|
||||
@@ -28,10 +29,12 @@ func TestForkWorktreeForTabCreatesIsolatedWorkspace(t *testing.T) {
|
||||
rollbackDeliveryWorktree = origRollback
|
||||
})
|
||||
|
||||
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
|
||||
root := repo.Dir
|
||||
return worktree.Availability{Available: true, RepoRoot: root, Branch: "main"}
|
||||
}
|
||||
createDeliveryWorktree = func(_ context.Context, source, gotManaged string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(_ context.Context, sourceRepo gitcmd.Repo, gotManaged string) (worktree.Result, error) {
|
||||
source := sourceRepo.Dir
|
||||
return worktree.Result{
|
||||
WorkspaceRoot: isolatedRoot,
|
||||
WorktreeRoot: filepath.Dir(isolatedRoot),
|
||||
@@ -90,11 +93,12 @@ func TestForkWorktreeForTabRefusesDirtySourceWithoutMutation(t *testing.T) {
|
||||
inspectDeliveryWorktree = origInspect
|
||||
createDeliveryWorktree = origCreate
|
||||
})
|
||||
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
|
||||
root := repo.Dir
|
||||
return worktree.Availability{Available: true, RepoRoot: root, SourceDirty: true}
|
||||
}
|
||||
createCalls := 0
|
||||
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
|
||||
createCalls++
|
||||
return worktree.Result{}, nil
|
||||
}
|
||||
@@ -125,10 +129,10 @@ func TestForkWorktreeForTabFallsBackToSharedFork(t *testing.T) {
|
||||
inspectDeliveryWorktree = origInspect
|
||||
createDeliveryWorktree = origCreate
|
||||
})
|
||||
inspectDeliveryWorktree = func(context.Context, string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(context.Context, gitcmd.Repo) worktree.Availability {
|
||||
return worktree.Availability{Reason: "not a repository"}
|
||||
}
|
||||
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
|
||||
t.Fatal("fallback must not create a worktree")
|
||||
return worktree.Result{}, nil
|
||||
}
|
||||
@@ -164,7 +168,8 @@ func TestForkWorktreeForTabRollsBackUnusedCreation(t *testing.T) {
|
||||
createDeliveryWorktree = origCreate
|
||||
rollbackDeliveryWorktree = origRollback
|
||||
})
|
||||
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
|
||||
root := repo.Dir
|
||||
return worktree.Availability{Available: true, RepoRoot: root}
|
||||
}
|
||||
created := worktree.Result{
|
||||
@@ -174,7 +179,7 @@ func TestForkWorktreeForTabRollsBackUnusedCreation(t *testing.T) {
|
||||
Branch: "reasonix/delivery-test",
|
||||
Head: "deadbeef",
|
||||
}
|
||||
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
|
||||
return created, nil
|
||||
}
|
||||
rollbackCalls := 0
|
||||
@@ -211,7 +216,8 @@ func TestForkWorktreeForTabPreservesReferencedWorkspaceWhenSourceCloses(t *testi
|
||||
rollbackDeliveryWorktree = origRollback
|
||||
forkTabBeforePublishHookForTest.Store(nil)
|
||||
})
|
||||
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
|
||||
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
|
||||
root := repo.Dir
|
||||
return worktree.Availability{Available: true, RepoRoot: root}
|
||||
}
|
||||
worktreeRoot := filepath.Join(t.TempDir(), "worktree")
|
||||
@@ -226,7 +232,7 @@ func TestForkWorktreeForTabPreservesReferencedWorkspaceWhenSourceCloses(t *testi
|
||||
Branch: "reasonix/delivery-preserved",
|
||||
Head: "deadbeef",
|
||||
}
|
||||
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
|
||||
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
|
||||
return created, nil
|
||||
}
|
||||
rollbackCalls := 0
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"test:code-browser": "node bench/code-theme.mjs",
|
||||
"test:dock-responsive-browser": "node bench/responsive-dock.mjs",
|
||||
"test:diagnostics": "tsx src/__tests__/diagnostics-settings.test.tsx",
|
||||
"test:transcript": "tsx src/__tests__/canonical-user-display.test.ts && tsx src/__tests__/steer-transcript.test.ts && tsx src/__tests__/chat-tool-presentation.test.ts && tsx src/__tests__/chat-view-source.test.ts && tsx src/__tests__/maintenance-lifecycle.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/maintenance-card.test.tsx && tsx src/__tests__/presented-files.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/presented-files-ui.test.tsx && tsx src/__tests__/chat-content-loader.test.ts && tsx src/__tests__/chat-details-race.test.tsx && tsx src/__tests__/tool-payload-preview.test.ts && tsx src/__tests__/markdown-natural-flow.test.tsx && tsx src/__tests__/chat-natural-flow.test.tsx && tsx src/__tests__/chat-scroll-controller.test.ts && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/markdown-worker-client.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-memory-pressure.test.ts && tsx src/__tests__/transcript-tool-association.test.ts && tsx src/__tests__/transcript-follow-client.test.ts && tsx src/__tests__/transcript-session-follower.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/session-export-routing.test.tsx && tsx src/__tests__/session-export-operation.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/transcript-outline-store.test.ts && tsx src/__tests__/chat-turn-jump.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/chat-turn-outline-jump.test.tsx && tsx src/__tests__/native-transcript-history.test.ts && tsx src/__tests__/history-read-binding.test.ts",
|
||||
"test:transcript": "tsx src/__tests__/canonical-user-display.test.ts && tsx src/__tests__/steer-transcript.test.ts && tsx src/__tests__/chat-tool-presentation.test.ts && tsx src/__tests__/chat-view-source.test.ts && tsx src/__tests__/maintenance-lifecycle.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/maintenance-card.test.tsx && tsx src/__tests__/presented-files.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/presented-files-ui.test.tsx && tsx src/__tests__/chat-content-loader.test.ts && tsx src/__tests__/chat-details-race.test.tsx && tsx src/__tests__/tool-payload-preview.test.ts && tsx src/__tests__/markdown-natural-flow.test.tsx && tsx src/__tests__/chat-natural-flow.test.tsx && tsx src/__tests__/chat-scroll-controller.test.ts && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/markdown-worker-client.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-memory-pressure.test.ts && tsx src/__tests__/transcript-tool-association.test.ts && tsx src/__tests__/transcript-follow-client.test.ts && tsx src/__tests__/transcript-session-follower.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/session-export-routing.test.tsx && tsx src/__tests__/session-export-operation.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/transcript-outline-store.test.ts && tsx src/__tests__/chat-turn-jump.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/chat-turn-outline-jump.test.tsx && tsx src/__tests__/native-transcript-history.test.ts && tsx src/__tests__/history-read-binding.test.ts && tsx src/__tests__/history-reload-identity.test.ts",
|
||||
"test:session-export-browser": "node bench/session-export.mjs",
|
||||
"test:session-loading-browser": "node bench/session-loading.mjs && node bench/draft-recovery.mjs && node bench/manual-creation.mjs && node bench/manual-creation-recovery.mjs",
|
||||
"test:draft-recovery-browser": "node bench/draft-recovery.mjs",
|
||||
@@ -72,14 +72,14 @@
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@modelcontextprotocol/ext-apps": "1.7.5",
|
||||
"@tanstack/react-virtual": "^3.14.10",
|
||||
"@tanstack/react-virtual": "^3.14.13",
|
||||
"@xterm/addon-fit": "0.11.0",
|
||||
"@xterm/xterm": "6.0.0",
|
||||
"anser": "^2.3.5",
|
||||
"hast-util-to-jsx-runtime": "2.3.6",
|
||||
"highlight.js": "^11.12.0",
|
||||
"html-url-attributes": "3.0.1",
|
||||
"katex": "^0.18.6",
|
||||
"katex": "^0.18.9",
|
||||
"lucide-react": "^1.41.0",
|
||||
"mermaid": "^11.17.2",
|
||||
"qrcode.react": "^4.2.0",
|
||||
@@ -103,7 +103,7 @@
|
||||
"@types/node": "^26.4.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@typescript-eslint/parser": "^8.69.0",
|
||||
"@typescript-eslint/parser": "^8.70.1",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"decode-named-character-reference": "^1.3.0",
|
||||
"eslint": "^10.10.0",
|
||||
@@ -113,7 +113,7 @@
|
||||
"mdast-util-math": "^3.0.0",
|
||||
"playwright": "^1.63.0",
|
||||
"terser": "^5.51.2",
|
||||
"tsx": "^4.23.13",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.2.2"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { act } from "react";
|
||||
import { createTranscriptHarness } from "./transcript-dom-harness";
|
||||
import type { Item } from "../lib/useController";
|
||||
|
||||
const page: Item[] = [];
|
||||
for (let turn = 1; turn <= 4; turn += 1) {
|
||||
page.push(
|
||||
{ kind: "user", id: `u${turn}`, text: `prompt ${turn}`, checkpointTurn: turn },
|
||||
{ kind: "assistant", id: `a${turn}`, text: `answer ${turn}`, reasoning: "", streaming: false },
|
||||
);
|
||||
}
|
||||
const harness = await createTranscriptHarness({ deterministic: true });
|
||||
try {
|
||||
let loads = 0;
|
||||
const window = { hasNewerHistory: true, historyStartTurn: 0, historyEndTurn: 4, totalTurns: 20, geometrySessionKey: "newer-auto" };
|
||||
const props = { ...window, onLoadNewerHistory: async () => { loads += 1; return "loaded" as const; } };
|
||||
await harness.render(page, props);
|
||||
await harness.settle();
|
||||
assert.equal(loads, 1, "a reader sitting at the bottom of a window with newer history requests the next page once");
|
||||
await harness.render([...page], props);
|
||||
await harness.settle();
|
||||
assert.equal(loads, 1, "a republished window does not request another page");
|
||||
|
||||
await harness.render(page, { ...props, hasNewerHistory: false });
|
||||
await harness.settle();
|
||||
assert.equal(loads, 1, "nothing is requested once the window reaches the latest turn");
|
||||
|
||||
let blocked = 0;
|
||||
const guarded = { ...window, geometrySessionKey: "newer-auto-selected", onLoadNewerHistory: async () => { blocked += 1; return "loaded" as const; } };
|
||||
await harness.render(page, { ...guarded, hasNewerHistory: false });
|
||||
await harness.settle();
|
||||
const selected = harness.container.querySelector<HTMLElement>('[data-chat-anchor-key="a2"] .md p')!;
|
||||
const range = harness.dom.window.document.createRange();
|
||||
range.selectNodeContents(selected.firstChild!);
|
||||
const selection = harness.dom.window.getSelection()!;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
await harness.render(page, guarded);
|
||||
await harness.settle();
|
||||
assert.equal(blocked, 0, "a native selection in the transcript blocks the automatic request");
|
||||
|
||||
let failed = 0;
|
||||
selection.removeAllRanges();
|
||||
const failing = { ...window, geometrySessionKey: "newer-auto-failed", onLoadNewerHistory: async () => { failed += 1; return "empty" as const; } };
|
||||
await harness.render(page, failing);
|
||||
await harness.settle();
|
||||
await harness.render([...page], failing);
|
||||
await harness.settle();
|
||||
assert.equal(failed, 1, "a page that did not load is not retried without the reader leaving and returning to the bottom");
|
||||
|
||||
const manual = harness.container.querySelector<HTMLButtonElement>(".chat-history-newer .btn")!;
|
||||
await act(async () => manual.click());
|
||||
assert.equal(failed, 2, "the manual button still requests a page");
|
||||
console.log("chat newer auto-load: bottom request, no repeat, selection guard and manual button passed");
|
||||
} finally { await harness.unmount(); await harness.close(); }
|
||||
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { createTranscriptHarness } from "./transcript-dom-harness";
|
||||
import type { Item } from "../lib/useController";
|
||||
|
||||
const harness = await createTranscriptHarness({ deterministic: true });
|
||||
const { ChatSource } = await harness.loadModule<typeof import("../lib/chatViewSource")>("/src/lib/chatViewSource.ts");
|
||||
const { ChatNodeList } = await harness.loadModule<typeof import("../components/ChatNodes")>("/src/components/ChatNodes.tsx");
|
||||
const { ChatMountedOrder } = await harness.loadModule<typeof import("../lib/chatMountedOrder")>("/src/lib/chatMountedOrder.ts");
|
||||
const { ChatContentLoader } = await harness.loadModule<typeof import("../lib/chatContentLoader")>("/src/lib/chatContentLoader.ts");
|
||||
const { ChatScrollController } = await harness.loadModule<typeof import("../lib/chatScrollController")>("/src/lib/chatScrollController.ts");
|
||||
const { LocaleProvider } = await harness.loadModule<typeof import("../lib/i18n")>("/src/lib/i18n.tsx");
|
||||
|
||||
const source = new ChatSource("reasoning-load-full");
|
||||
let fail = false;
|
||||
let release: (() => void) | undefined;
|
||||
const loads: string[] = [];
|
||||
const loader = new ChatContentLoader(undefined, (item, field) => {
|
||||
if (field !== "reasoning") return Promise.resolve("");
|
||||
loads.push(item.id);
|
||||
if (fail) return Promise.reject(new Error("unavailable"));
|
||||
return new Promise<string>(resolve => { release = () => resolve((item as { reasoning: string }).reasoning); });
|
||||
});
|
||||
const scroll = new ChatScrollController("reasoning-load-full");
|
||||
const mounts = new ChatMountedOrder();
|
||||
await harness.unmount();
|
||||
const root = createRoot(harness.container);
|
||||
const question: Item = { kind: "user", id: "q", text: "think" };
|
||||
const live = (reasoning: string, streaming = true): Item => ({ kind: "assistant", id: "a", text: "", reasoning, streaming });
|
||||
const publish = async (items: Item[], running: boolean) => {
|
||||
await act(async () => source.update({ items, running, hydrating: false, hasOlder: false, loadingOlder: false }));
|
||||
await harness.settle();
|
||||
};
|
||||
const body = () => harness.container.querySelector(".chat-reasoning__body")?.textContent ?? "";
|
||||
const loadButton = () => [...harness.container.querySelectorAll<HTMLButtonElement>(".chat-reasoning__body > .btn")][0];
|
||||
|
||||
try {
|
||||
const first = "a".repeat(9000);
|
||||
await publish([question, live(first)], true);
|
||||
await act(async () => root.render(createElement(LocaleProvider, null,
|
||||
createElement(ChatNodeList, { source, mounts, loader, scroll, actions: { openDetails: () => {}, recover: () => {} } }))));
|
||||
await harness.settle();
|
||||
await act(async () => harness.container.querySelector<HTMLElement>(".chat-reasoning [data-disclosure-row]")!.click());
|
||||
assert.ok(loadButton(), "long streaming reasoning offers a full-text button");
|
||||
await act(async () => loadButton()!.click());
|
||||
await harness.settle();
|
||||
assert.ok(body().length >= first.length, "clicking load shows the whole reasoning while it streams");
|
||||
assert.equal(loadButton(), undefined, "the button is gone once the full text is shown");
|
||||
|
||||
const grown = first + "b".repeat(500);
|
||||
await publish([question, live(grown)], true);
|
||||
assert.ok(body().length >= grown.length, "the loaded view follows the growing stream instead of snapping back");
|
||||
assert.equal(loadButton(), undefined, "streaming chunks do not bring the button back");
|
||||
|
||||
const final = grown + "c".repeat(500);
|
||||
await publish([question, live(final, false)], false);
|
||||
assert.ok(body().length >= final.length, "the body never shrinks while the settled load is pending");
|
||||
assert.equal(loadButton(), undefined, "no button while the settled load is pending");
|
||||
await act(async () => { release?.(); });
|
||||
await harness.settle();
|
||||
assert.ok(body().length >= final.length, "the loaded view survives the settled load");
|
||||
assert.equal(loadButton(), undefined, "settling does not bring the button back");
|
||||
assert.equal(loads.length, 1, "the settled load runs once");
|
||||
|
||||
const copies = loads.length;
|
||||
await publish([question, { ...live("d".repeat(9000)), id: "b" }], true);
|
||||
await act(async () => harness.container.querySelectorAll<HTMLButtonElement>(".chat-reasoning .copybtn")[1]?.click());
|
||||
assert.equal(loads.length, copies, "copying while streaming does not start a load");
|
||||
|
||||
const start = loads.length;
|
||||
await publish([question, { ...live("e".repeat(9000)), id: "c" }], true);
|
||||
await act(async () => harness.container.querySelector<HTMLElement>(".chat-reasoning [data-disclosure-row]")!.click());
|
||||
await act(async () => loadButton()!.click());
|
||||
const idle = loadButton() ? "" : "gone";
|
||||
fail = true;
|
||||
const failedFinal = "e".repeat(9500);
|
||||
await publish([question, { ...live(failedFinal, false), id: "c" }], false);
|
||||
await harness.settle();
|
||||
assert.equal(loads.length, start + 1, "the failing load ran once");
|
||||
assert.ok(body().length >= failedFinal.length, "a failed load does not shrink the body");
|
||||
const retry = loadButton();
|
||||
assert.ok(retry, `a failed load keeps a retry button (${idle})`);
|
||||
assert.equal(retry!.disabled, false, "the retry button is enabled");
|
||||
const failedLabel = retry!.textContent;
|
||||
fail = false;
|
||||
await act(async () => retry!.click());
|
||||
await act(async () => { release?.(); });
|
||||
await harness.settle();
|
||||
assert.equal(loads.length, start + 2, "retry loads again");
|
||||
assert.equal(loadButton(), undefined, "a successful retry removes the button");
|
||||
assert.ok(failedLabel, "the failed state is labelled");
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
source.dispose(); mounts.dispose(); loader.dispose(); scroll.dispose();
|
||||
await harness.unmount(); await harness.close();
|
||||
}
|
||||
console.log("reasoning load full: stays loaded across streaming chunks and settlement");
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { ChatSource, type ChatInput } from "../lib/chatViewSource";
|
||||
import type { Item } from "../lib/useController";
|
||||
|
||||
const tool = (id: string): Item => ({ kind: "tool", id, name: "read_file", args: "", readOnly: true, status: "done", output: "ok" });
|
||||
const answer = (id: string, streaming = false): Item => ({ kind: "assistant", id, text: "done", reasoning: "", streaming });
|
||||
const base: ChatInput = { items: [], running: false, hydrating: false, hasOlder: true, loadingOlder: false };
|
||||
const process = (source: ChatSource, key: string) => {
|
||||
const node = source.getNodeSnapshot(`${key}:process`);
|
||||
assert.equal(node?.kind, "process");
|
||||
return node as Extract<NonNullable<typeof node>, { kind: "process" }>;
|
||||
};
|
||||
|
||||
const head: Item[] = [tool("t1"), tool("t2"), answer("a1")];
|
||||
const settled = new ChatSource("head-settled");
|
||||
settled.update({ ...base, items: head });
|
||||
await Promise.resolve();
|
||||
assert.ok(process(settled, "history-head").foldable, "a head group with tools can fold");
|
||||
assert.ok(process(settled, "history-head").collapsed, "a settled head group starts folded");
|
||||
settled.toggleProcess("history-head");
|
||||
assert.equal(process(settled, "history-head").collapsed, false, "the head group folds and unfolds by hand");
|
||||
settled.toggleProcess("history-head");
|
||||
settled.dispose();
|
||||
|
||||
const running = new ChatSource("head-running");
|
||||
running.update({ ...base, items: [tool("t1"), answer("a1", true)], running: true });
|
||||
await Promise.resolve();
|
||||
const live = process(running, "history-head");
|
||||
assert.ok(!live.foldable && !live.collapsed, "a head group still running stays open");
|
||||
running.dispose();
|
||||
|
||||
const chosen = new ChatSource("head-choice");
|
||||
chosen.update({ ...base, items: head });
|
||||
await Promise.resolve();
|
||||
chosen.toggleProcess("history-head");
|
||||
const user: Item = { kind: "user", id: "u0", text: "long task", checkpointTurn: 1 };
|
||||
chosen.update({ ...base, items: [user, ...head], hasOlder: false });
|
||||
await Promise.resolve();
|
||||
assert.equal(chosen.getNodeSnapshot("history-head:process"), undefined, "the head key is gone once the user message arrives");
|
||||
assert.equal(process(chosen, "u0").collapsed, false, "the manual choice follows the turn when its user message loads");
|
||||
chosen.dispose();
|
||||
|
||||
const folded = new ChatSource("head-folded");
|
||||
folded.update({ ...base, items: head });
|
||||
await Promise.resolve();
|
||||
folded.update({ ...base, items: [user, ...head], hasOlder: false });
|
||||
await Promise.resolve();
|
||||
assert.ok(process(folded, "u0").collapsed, "an untouched head stays folded after the user message loads");
|
||||
folded.dispose();
|
||||
|
||||
const unrelated = new ChatSource("head-unrelated");
|
||||
unrelated.update({ ...base, items: head });
|
||||
await Promise.resolve();
|
||||
unrelated.toggleProcess("history-head");
|
||||
const other: Item[] = [{ kind: "user", id: "u9", text: "other", checkpointTurn: 9 }, tool("x1"), answer("ax")];
|
||||
unrelated.update({ ...base, items: other });
|
||||
await Promise.resolve();
|
||||
assert.ok(process(unrelated, "u9").collapsed, "a choice made on the head does not leak onto a turn that does not contain it");
|
||||
unrelated.dispose();
|
||||
|
||||
console.log("chat view: history head fold, running head and choice migration passed");
|
||||
@@ -48,12 +48,12 @@ const warning: Item = { kind: "notice", id: "warning", level: "warn", text: "int
|
||||
source.update({ ...input, items: [...settled, warning], running: false });
|
||||
await Promise.resolve();
|
||||
const failed = source.getNodeSnapshot("u2:process");
|
||||
assert.ok(failed?.kind === "process" && !failed.foldable);
|
||||
assert.ok(failed?.kind === "process" && failed.foldable && !failed.collapsed, "an interrupted turn stays open but can be folded");
|
||||
assert.ok(source.getOrderSnapshot().includes("warning"));
|
||||
source.update({ ...input, items: [settled[1]], running: false });
|
||||
await Promise.resolve();
|
||||
const incomplete = source.getNodeSnapshot("history-head:process");
|
||||
assert.ok(incomplete?.kind === "process" && !incomplete.foldable);
|
||||
assert.ok(incomplete?.kind === "process" && incomplete.foldable, "a group without its user message still has a fold seat");
|
||||
source.update(input);
|
||||
source.dispose();
|
||||
const notified = active;
|
||||
@@ -120,7 +120,8 @@ const restored = new ChatSource("recovered-weather");
|
||||
restored.update({ ...input, items: weather, running: false });
|
||||
assert.equal((restored.getNodeSnapshot("weather-user:process") as typeof compact).collapsed, false, "manual open survives session revisit");
|
||||
restored.update({ ...input, items: [...weather, { kind: "notice", id: "stopped", level: "info", text: "Stopped" }], running: false });
|
||||
assert.equal((restored.getNodeSnapshot("weather-user:process") as typeof compact).foldable, false, "terminal interruption is not hidden by a partial answer");
|
||||
const interrupted = restored.getNodeSnapshot("weather-user:process") as typeof compact;
|
||||
assert.ok(interrupted.foldable && !interrupted.collapsed, "terminal interruption stays open and keeps the manual choice");
|
||||
restored.dispose();
|
||||
|
||||
const deliverables = new ChatSource("deliverables");
|
||||
@@ -199,3 +200,34 @@ const maintenanceProcess = maintenanceSource.getNodeSnapshot("compact-user:proce
|
||||
assert.ok(maintenanceProcess?.kind === "process" && !maintenanceProcess.members.includes("maintenance:op"), "maintenance card is independent from the previous process fold");
|
||||
assert.ok(maintenanceSource.getOrderSnapshot().includes("maintenance:op"), "pending maintenance remains mounted");
|
||||
maintenanceSource.dispose();
|
||||
|
||||
const endings: Record<string, Item[]> = {
|
||||
clean: [{ kind: "assistant", id: "clean-answer", text: "done", reasoning: "", streaming: false, turnFinal: true }],
|
||||
stopped: [{ kind: "notice", id: "stopped-note", level: "info", text: "Stopped" }],
|
||||
errored: [{ kind: "notice", id: "errored-note", level: "warn", text: "provider error" }],
|
||||
unfinishedTool: [{ kind: "tool", id: "hung", name: "fleet", args: "{}", readOnly: true, status: "stopped" }],
|
||||
streamingLeftover: [{ kind: "assistant", id: "leftover", text: "partial", reasoning: "", streaming: true }],
|
||||
};
|
||||
for (const [ending, tail] of Object.entries(endings)) {
|
||||
const source = new ChatSource(`ending-${ending}`);
|
||||
const turn: Item[] = [
|
||||
{ kind: "user", id: "q", text: "go", checkpointTurn: 1 },
|
||||
{ kind: "tool", id: "t1", name: "bash", args: "{}", readOnly: true, status: "done", output: "ok" },
|
||||
...tail,
|
||||
];
|
||||
source.update({ ...input, items: turn, running: false });
|
||||
await Promise.resolve();
|
||||
const seat = () => source.getNodeSnapshot("q:process") as Extract<NonNullable<ReturnType<typeof source.getNodeSnapshot>>, { kind: "process" }>;
|
||||
assert.ok(seat().foldable, `${ending}: every ended turn has a fold control`);
|
||||
const startsFolded = seat().collapsed;
|
||||
assert.equal(startsFolded, ending === "clean", `${ending}: only a settled turn starts folded`);
|
||||
source.toggleProcess("q");
|
||||
assert.equal(seat().collapsed, !startsFolded, `${ending}: the control flips the turn`);
|
||||
source.update({ ...input, items: [...turn], running: false });
|
||||
await Promise.resolve();
|
||||
assert.equal(seat().collapsed, !startsFolded, `${ending}: the manual choice survives republication`);
|
||||
source.toggleProcess("q");
|
||||
assert.equal(seat().collapsed, startsFolded, `${ending}: the control flips back`);
|
||||
source.dispose();
|
||||
}
|
||||
console.log("chat view: every ended turn is foldable by hand");
|
||||
@@ -0,0 +1,55 @@
|
||||
// A busy-window submit (the desktop thinks idle, the serve is still finishing)
|
||||
// is answered with a durable queue receipt. The composer must show the queue
|
||||
// entry immediately and treat the message as delivered-to-queue, never as a
|
||||
// failure — local busy sends behave the same way.
|
||||
import assert from "node:assert/strict";
|
||||
import { act } from "react";
|
||||
import { installDom, installBridgeApp, renderComposer } from "./composerInboxHarness";
|
||||
|
||||
const flush = () => new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
|
||||
async function paste(text: string) {
|
||||
await act(async () => {
|
||||
const event = new window.Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", { value: { files: [], items: [], types: ["text/plain"], getData: () => text } });
|
||||
document.querySelector("textarea.composer__input:not([aria-hidden=true])")!.dispatchEvent(event);
|
||||
await flush();
|
||||
});
|
||||
}
|
||||
async function send() {
|
||||
await act(async () => { (document.querySelector(".composer__btn--send") as HTMLButtonElement).click(); await flush(); });
|
||||
}
|
||||
const emptySnapshot = { revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 };
|
||||
const queuedSnapshot = {
|
||||
revision: 2, paused: false, recovered: false, itemsCount: 1, bytes: 12, maxItems: 64, maxBytes: 1024,
|
||||
items: [{ id: "queued-1", intent: "followup", state: "queued", preview: "queued while busy" }],
|
||||
};
|
||||
const queuedError = () => Object.assign(new Error("reasonix_error:queued_followup"), {
|
||||
code: "business",
|
||||
data: { method: "SubmitRemoteTabWithSubmission", queuedFollowup: { itemId: "queued-1", disposition: "queued_followup", position: 0, paused: false } },
|
||||
});
|
||||
|
||||
installDom();
|
||||
installBridgeApp({
|
||||
InboxQueueForTarget: async () => ({ outcome: "applied", snapshot: emptySnapshot }),
|
||||
InboxSnapshot: async () => queuedSnapshot,
|
||||
});
|
||||
await renderComposer({
|
||||
running: false,
|
||||
inboxSessionPath: "session-a",
|
||||
onSend: async () => { throw queuedError(); },
|
||||
});
|
||||
await paste("queued while busy");
|
||||
await send();
|
||||
|
||||
// The queue strip reconciles through the snapshot refresh; let it settle.
|
||||
await act(async () => { await flush(); await flush(); await new Promise(r => setTimeout(r, 60)); await flush(); });
|
||||
const body = document.body.textContent ?? "";
|
||||
assert.ok(body.includes("queued while busy"), "the queued message is visible in the pending queue");
|
||||
assert.equal(body.includes("Send failed"), false, "a queued submit is not reported as a failed send");
|
||||
assert.equal(body.includes("Refresh the session"), false, "a queued submit never asks the user to refresh");
|
||||
assert.equal((document.querySelector("textarea.composer__input:not([aria-hidden=true])") as HTMLTextAreaElement).value, "",
|
||||
"the composer clears once the message is queued");
|
||||
|
||||
console.log("composer queued follow-up outcome: busy submits surface as a visible queue entry");
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,135 @@
|
||||
// A transient inbox-target fence (session switching, reconnecting, route
|
||||
// adoption) clears by itself. The composer must wait it out and deliver the
|
||||
// message instead of surfacing "the message was not sent" for a window the
|
||||
// user cannot act on.
|
||||
import assert from "node:assert/strict";
|
||||
import { act } from "react";
|
||||
import { installDom, installBridgeApp, renderComposer } from "./composerInboxHarness";
|
||||
import { runtimeStateStore, type RuntimeProjection } from "../lib/runtimeStateStore";
|
||||
import { acceptRuntimeState } from "../lib/runtimeStateReducer";
|
||||
import { pendingFollowups, followupSessionKey, type InboxTarget } from "../lib/pendingFollowup";
|
||||
import { formatInboxError } from "../lib/inboxError";
|
||||
const TRANSIENT_INBOX_TARGET_CODE = "inbox_target_transient";
|
||||
const TRANSIENT_GUIDANCE_RETRY_DELAYS_MS = [150, 350, 500, 500];
|
||||
|
||||
const pendingSession = followupSessionKey("session-a");
|
||||
const DEAD_END_COPY = "Refresh the session";
|
||||
|
||||
const flush = () => new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
const settle = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
|
||||
|
||||
async function paste(text: string) {
|
||||
await act(async () => {
|
||||
const event = new window.Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", { value: { files: [], items: [], types: ["text/plain"], getData: () => text } });
|
||||
document.querySelector("textarea.composer__input:not([aria-hidden=true])")!.dispatchEvent(event);
|
||||
await flush();
|
||||
});
|
||||
}
|
||||
async function send() {
|
||||
await act(async () => { (document.querySelector(".composer__btn--send") as HTMLButtonElement).click(); await flush(); });
|
||||
}
|
||||
function transientError() {
|
||||
return Object.assign(new Error(`reasonix_error:${TRANSIENT_INBOX_TARGET_CODE}`), { code: "business", data: { transient: true } });
|
||||
}
|
||||
function state(scenario: number, phase: "idle" | "executing" | "finishing", revision: number): RuntimeProjection {
|
||||
return { epoch: `transient-test-${scenario}`, revision, topics: [], sessions: [{
|
||||
tabId: "tab-a", scope: "project", workspaceRoot: "/repo", topicId: "topic", sessionPath: "session-a", sessionGeneration: 1,
|
||||
open: true, remote: true, freshness: "synced", state: { schemaVersion: 1, runtimeEpoch: "controller", revision,
|
||||
phase, running: phase !== "idle", turnId: "turn", turnStatus: "completed", turnEventSeq: 3, turnStartedAt: 0,
|
||||
pendingPrompt: false, cancelRequested: false, cancellable: phase === "executing", backgroundJobs: 0, activity: "" },
|
||||
}] };
|
||||
}
|
||||
const target: InboxTarget = { tabId: "tab-a", sessionPath: "session-a", generation: 7, selection: 3, remote: true };
|
||||
const receipt = { itemId: "item-1", disposition: "queued_followup", position: 0, paused: false };
|
||||
|
||||
// The classifier and the copy table must recognize the bridge's transient mark.
|
||||
{
|
||||
assert.equal(formatInboxError(new Error(`reasonix_error:${TRANSIENT_INBOX_TARGET_CODE}`), "en").includes("switching"), true);
|
||||
}
|
||||
|
||||
// 1. A capture-phase fence is waited out: the send completes normally.
|
||||
{
|
||||
installDom();
|
||||
let captures = 0, posts = 0;
|
||||
installBridgeApp({
|
||||
CaptureInboxTarget: async () => { captures++; if (captures <= 2) throw transientError(); return target; },
|
||||
EnqueueInboxFollowupForTarget: async () => { posts++; return receipt; },
|
||||
InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }),
|
||||
});
|
||||
const SCENARIO = 1;
|
||||
await renderComposer({ inboxSessionPath: "session-a" });
|
||||
await act(async () => { acceptRuntimeState(runtimeStateStore, state(SCENARIO, "finishing", 1), true); await flush(); });
|
||||
await paste("held during switch");
|
||||
await send();
|
||||
await act(async () => { await settle(TRANSIENT_GUIDANCE_RETRY_DELAYS_MS[0] + TRANSIENT_GUIDANCE_RETRY_DELAYS_MS[1] + 80); });
|
||||
assert.equal(captures, 3, "capture retried until the tab settled");
|
||||
assert.equal(posts, 1, "the settled send enqueued exactly once");
|
||||
assert.equal(document.body.textContent?.includes(DEAD_END_COPY), false, "no dead-end copy for a self-clearing window");
|
||||
}
|
||||
|
||||
// 2. An enqueue-phase fence holds the request and auto-delivers on retry.
|
||||
{
|
||||
installDom();
|
||||
let posts = 0;
|
||||
installBridgeApp({
|
||||
CaptureInboxTarget: async () => target,
|
||||
EnqueueInboxFollowupForTarget: async () => { posts++; if (posts === 1) throw transientError(); return receipt; },
|
||||
InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }),
|
||||
});
|
||||
const SCENARIO = 2;
|
||||
await renderComposer({ inboxSessionPath: "session-a" });
|
||||
await act(async () => { acceptRuntimeState(runtimeStateStore, state(SCENARIO, "finishing", 1), true); await flush(); });
|
||||
await paste("retried until queued");
|
||||
await send();
|
||||
await act(async () => { await settle(TRANSIENT_GUIDANCE_RETRY_DELAYS_MS[0] + 80); });
|
||||
assert.equal(posts, 2, "the held request retried the enqueue");
|
||||
assert.equal(pendingFollowups.get(pendingSession), undefined, "a delivered request clears its pending identity");
|
||||
assert.equal(document.body.textContent?.includes(DEAD_END_COPY), false, "delivery never surfaced the dead-end copy");
|
||||
}
|
||||
|
||||
// 3. Outlasting the bound keeps the request visible for later reconciliation
|
||||
// instead of telling the user to refresh.
|
||||
{
|
||||
installDom();
|
||||
let posts = 0;
|
||||
installBridgeApp({
|
||||
CaptureInboxTarget: async () => target,
|
||||
EnqueueInboxFollowupForTarget: async () => { posts++; throw transientError(); },
|
||||
InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }),
|
||||
});
|
||||
const SCENARIO = 3;
|
||||
await renderComposer({ inboxSessionPath: "session-a" });
|
||||
await act(async () => { acceptRuntimeState(runtimeStateStore, state(SCENARIO, "finishing", 1), true); await flush(); });
|
||||
await paste("switching forever");
|
||||
await send();
|
||||
await act(async () => { await settle(TRANSIENT_GUIDANCE_RETRY_DELAYS_MS.reduce((sum, ms) => sum + ms, 0) + 200); });
|
||||
assert.equal(posts, 1 + TRANSIENT_GUIDANCE_RETRY_DELAYS_MS.length, "the retry bound is finite");
|
||||
assert.ok(pendingFollowups.get(pendingSession), "an undelivered request stays pending for reconciliation");
|
||||
assert.equal(document.body.textContent?.includes(DEAD_END_COPY), false, "exhaustion still avoids the dead-end copy");
|
||||
// The pending store is module-global: retire this scenario's leftover so the
|
||||
// next one starts from a clean session identity.
|
||||
pendingFollowups.clear(pendingSession, pendingFollowups.get(pendingSession));
|
||||
}
|
||||
|
||||
// 4. Permanent refusals keep the historical behavior (clear + report).
|
||||
{
|
||||
installDom();
|
||||
installBridgeApp({
|
||||
CaptureInboxTarget: async () => target,
|
||||
EnqueueInboxFollowupForTarget: async () => { throw new Error("reasonix_error:inbox_item_too_large"); },
|
||||
InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }),
|
||||
});
|
||||
const SCENARIO = 4;
|
||||
await renderComposer({ inboxSessionPath: "session-a" });
|
||||
await act(async () => { acceptRuntimeState(runtimeStateStore, state(SCENARIO, "finishing", 1), true); await flush(); });
|
||||
await paste("permanently refused");
|
||||
await send();
|
||||
await act(async () => { await settle(60); });
|
||||
assert.equal(pendingFollowups.get(pendingSession), undefined, "permanent refusals drop the pending identity");
|
||||
assert.equal(document.body.textContent?.includes("too large"), true, "permanent refusals stay visible to the user");
|
||||
}
|
||||
|
||||
console.log("composer transient guidance: retries a self-clearing fence and keeps the message visible");
|
||||
// Mounted roots leave timers behind; exit once the scenarios above ran.
|
||||
process.exit(0);
|
||||
@@ -25,7 +25,7 @@ const background = [{ id: "local-runtime" }] as unknown as BackgroundRuntimeView
|
||||
const view = projectConversation({ local, remote, tab, activeTabId: tab.id, backgroundRuntimes: background, connectingLabel: "connecting" });
|
||||
assert.equal(view.runtime, remote.transcript, "projection shares the canonical state and message arrays");
|
||||
assert.equal(view.context.items, remote.transcript.items);
|
||||
assert.equal(view.context.tabId, undefined, "remote context cannot fetch local telemetry");
|
||||
assert.equal(view.context.tabId, tab.id, "remote context reads the selected remote tab");
|
||||
assert.equal(view.composer.modelLabel, "remote-model");
|
||||
assert.equal(view.composer.cwd, "remote-cwd");
|
||||
assert.equal(view.composer.turnTokens, 7);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Run: tsx src/__tests__/decision-surface-remote.test.ts
|
||||
import assert from "node:assert/strict";
|
||||
import { projectDecisionSurface } from "../app-runtime/decisionSurfaceProjection";
|
||||
|
||||
const base = { approval: undefined, ask: undefined, mcpInteraction: undefined, extensionForm: undefined, workspaceConflict: null, pendingClose: null, clearContextPending: false } as unknown as Parameters<typeof projectDecisionSurface>[0];
|
||||
const ask = { id: "1", questions: [{ id: "q1", prompt: "?", options: [{ label: "a" }, { label: "b" }] }] } as unknown as Parameters<typeof projectDecisionSurface>[0]["ask"];
|
||||
const approval = { id: "call-1", tool: "bash" } as unknown as Parameters<typeof projectDecisionSurface>[0]["approval"];
|
||||
|
||||
assert.equal(projectDecisionSurface({ ...base, ask }), "ask", "a local ask docks in the footer");
|
||||
assert.equal(projectDecisionSurface({ ...base, ask, remote: true }), null, "a remote ask belongs to the remote surface, not a second footer card");
|
||||
assert.equal(projectDecisionSurface({ ...base, approval, remote: true }), null, "a remote approval is not duplicated in the footer");
|
||||
assert.equal(projectDecisionSurface({ ...base, ask, remote: true, clearContextPending: true }), "clear_context", "shell decisions still reach the footer on a remote tab");
|
||||
console.log("decision-surface-remote: 4 passed");
|
||||
@@ -23,6 +23,7 @@ const acceptedTopics: number[] = [];
|
||||
let intent = 0;
|
||||
let registration: ReturnType<typeof deferred<string>> | undefined;
|
||||
let preparationReads = 0;
|
||||
let lastTopicPath = "";
|
||||
let api!: ReturnType<typeof useDesktopNavigation>;
|
||||
const activate = (id: string) => { calls.push(`open:${id}`); const request = deferred<TabMeta>(); pending.set(id, request); return request.promise; };
|
||||
const ports: Parameters<typeof useDesktopNavigation>[0]["ports"] = {
|
||||
@@ -30,7 +31,7 @@ const ports: Parameters<typeof useDesktopNavigation>[0]["ports"] = {
|
||||
registeredNavigationIntent: async seq => registration ? registration.promise : String(seq),
|
||||
openRemoteProject: async (_host, workspace) => activate(`remote:${workspace}`),
|
||||
switchRemoteTab: async (meta, seq) => { calls.push(`remote-switch:${meta.id}:${seq}`); },
|
||||
activateTopic: async (_scope, _workspace, id) => activate(id),
|
||||
activateTopic: async (_scope, _workspace, id, path) => { lastTopicPath = path; return activate(id); },
|
||||
openCanonicalSession: async (ref) => { await activate(`canonical:${ref.sessionId}`); },
|
||||
openTopicSession: async (_scope, _workspace, id) => { calls.push("tab-session"); return activate(id); },
|
||||
openGlobalTab: async id => { calls.push("tab-global"); return activate(id); },
|
||||
@@ -104,6 +105,14 @@ try {
|
||||
await finish("blank:im", validIM);
|
||||
assert.ok(calls.includes("channel:blank:im:channel.jsonl"));
|
||||
|
||||
calls.length = 0;
|
||||
const botConnection = { ...connection, sessionId: "session:local:bot-conversation" };
|
||||
const botNavigation = api.enqueueNavigation({ kind: "sidebar-im", connection: botConnection });
|
||||
await finish("", botNavigation);
|
||||
assert.equal(lastTopicPath, "bot-session:local:bot-conversation");
|
||||
assert.ok(!calls.some(value => value.startsWith("open:blank:") || value.startsWith("channel:")),
|
||||
"canonical bot sessions open through the shared runtime surface, not a new blank tab");
|
||||
|
||||
calls.length = 0;
|
||||
const isolated = api.enqueueNavigation({ kind: "isolated-worktree", workspaceRoot: "dirty" });
|
||||
await paint(); // Normal commits do not change the request epoch.
|
||||
|
||||
@@ -16,6 +16,7 @@ for (const locale of ["en", "zh", "zh-TW"] as const) {
|
||||
const translate: Translator = key => dict[key];
|
||||
for (const [error, key] of [
|
||||
["dial tcp: connection refused", "error.connection"],
|
||||
[{ code: "transport_protocol", message: "connection error: PROTOCOL_ERROR" }, "error.transportProtocol"],
|
||||
["lookup host: no such host", "error.dns"],
|
||||
["deepseek: status 503: invalid api key mentioned in upstream diagnostics", "error.service"],
|
||||
["HTTP 429: too many requests", "error.rateLimit"],
|
||||
@@ -36,6 +37,10 @@ for (const locale of ["en", "zh", "zh-TW"] as const) {
|
||||
] as const) {
|
||||
assert.equal(presentError(error, translate, locale).summary, dict[key], `${locale}: ${JSON.stringify(error)}`);
|
||||
}
|
||||
assert.deepEqual(presentError("opaque failure", translate, locale, { kind: "transport_protocol", transportCode: "PROTOCOL_ERROR" }), {
|
||||
summary: dict["error.transportProtocol"], detail: "opaque failure\nHTTP/2: PROTOCOL_ERROR",
|
||||
});
|
||||
assert.notEqual(presentError("connection error: PROTOCOL_ERROR", translate, locale).summary, dict["error.transportProtocol"], "untyped wording is not transport evidence");
|
||||
if (locale !== "en") {
|
||||
for (const raw of ["unknown backend failure", "read /tmp/timeout/404.txt: unsupported format", "read /tmp/中文文件: unsupported format", "<img src=x onerror=alert(1)>"]) {
|
||||
assert.deepEqual(presentError(raw, translate, locale), { summary: dict["error.unknown"], detail: raw });
|
||||
@@ -59,10 +64,11 @@ await Promise.all([preloadLocale("zh"), preloadLocale("zh-TW")]);
|
||||
let changeLocale: (locale: Locale) => void;
|
||||
let toast: ReturnType<typeof useToast>["showToast"];
|
||||
const raw = "unknown library failure <img src=x onerror=alert(1)>";
|
||||
const protocolError = "connection error: PROTOCOL_ERROR <img src=x onerror=alert(1)>";
|
||||
function Probe() {
|
||||
changeLocale = useI18n().setPref;
|
||||
toast = useToast().showToast;
|
||||
return <><p id="inline-error"><ErrorMessage error={raw} /></p><p id="summary-only"><ErrorMessage error={undefined} summary="仅有说明" /></p></>;
|
||||
return <><p id="inline-error"><ErrorMessage error={raw} /></p><p id="transport-error"><ErrorMessage error={protocolError} diagnostic={{ kind: "transport_protocol", transportCode: "PROTOCOL_ERROR" }} /></p><p id="summary-only"><ErrorMessage error={undefined} summary="仅有说明" /></p></>;
|
||||
}
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
await act(async () => root.render(<LocaleProvider><ToastProvider><Probe /></ToastProvider></LocaleProvider>));
|
||||
@@ -76,9 +82,14 @@ assert.equal(toggle.getAttribute("aria-expanded"), "true");
|
||||
assert.equal(document.querySelector(".user-error__detail")?.textContent, raw);
|
||||
assert.equal(document.querySelector("#inline-error img"), null, "diagnostics are text, never executable markup");
|
||||
await act(async () => changeLocale("zh-TW"));
|
||||
assert.equal(document.querySelector("#transport-error .user-error__summary")?.textContent, zhTW["error.transportProtocol"]);
|
||||
assert.equal(document.querySelector("#inline-error .user-error__summary")?.textContent, zhTW["error.unknown"]);
|
||||
assert.equal(document.querySelector(".user-error__detail")?.textContent, raw, "language switching preserves diagnostics");
|
||||
await act(async () => changeLocale("en"));
|
||||
assert.equal(document.querySelector("#transport-error .user-error__summary")?.textContent, en["error.transportProtocol"]);
|
||||
await act(async () => document.querySelector<HTMLButtonElement>("#transport-error .user-error__toggle")!.click());
|
||||
assert.equal(document.querySelector("#transport-error .user-error__detail")?.textContent, `${protocolError}\nHTTP/2: PROTOCOL_ERROR`);
|
||||
assert.equal(document.querySelector("#transport-error img"), null);
|
||||
assert.equal(document.querySelector("#inline-error .user-error__summary")?.textContent, raw, "English errors follow the active English UI locale");
|
||||
await act(async () => changeLocale("zh-TW"));
|
||||
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
// Run: tsx src/__tests__/goal-lifecycle-actions.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { GoalLifecycleActions } from "../components/GoalLifecycleActions";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { url: "http://localhost/" });
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
@@ -14,10 +9,16 @@ globalThis.document = dom.window.document;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
|
||||
const React = await import("react");
|
||||
const { act } = React;
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { GoalLifecycleActions } = await import("../components/GoalLifecycleActions");
|
||||
const { LocaleProvider } = await import("../lib/i18n");
|
||||
|
||||
let edit: { objective: string; maxGoalRounds: number | null } | undefined;
|
||||
let paused = 0;
|
||||
const prompts = ["finish the migration safely", "12"];
|
||||
window.prompt = () => prompts.shift() ?? null;
|
||||
window.prompt = () => { throw new Error("prompt() is not supported."); };
|
||||
window.alert = () => { throw new Error("alert() is not supported."); };
|
||||
|
||||
await act(async () => {
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
@@ -45,7 +46,28 @@ const pauseButton = buttons.find((button) => button.textContent === "Pause goal"
|
||||
if (!editButton || !pauseButton) throw new Error("active goal lifecycle actions did not render");
|
||||
if (pauseButton.disabled) throw new Error("running automatic Goal round is not pausable");
|
||||
|
||||
const setValue = async (el: HTMLInputElement | HTMLTextAreaElement, value: string) => {
|
||||
const proto = el instanceof dom.window.HTMLTextAreaElement ? dom.window.HTMLTextAreaElement.prototype : dom.window.HTMLInputElement.prototype;
|
||||
await act(async () => {
|
||||
Object.getOwnPropertyDescriptor(proto, "value")!.set!.call(el, value);
|
||||
el.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
await act(async () => { editButton.click(); });
|
||||
const objectiveInput = document.querySelector<HTMLTextAreaElement>("[data-goal-edit-objective]");
|
||||
const roundsInput = document.querySelector<HTMLInputElement>("[data-goal-edit-rounds]");
|
||||
if (!objectiveInput || !roundsInput) throw new Error("edit goal did not open an in-app form");
|
||||
if (objectiveInput.value !== "finish the migration") throw new Error("objective not prefilled");
|
||||
await setValue(roundsInput, "0");
|
||||
const form = document.querySelector<HTMLFormElement>("form[data-goal-edit-form]")!;
|
||||
await act(async () => { form.dispatchEvent(new dom.window.Event("submit", { bubbles: true, cancelable: true })); });
|
||||
if (edit) throw new Error("invalid rounds must not submit");
|
||||
if (!document.querySelector("[data-goal-edit-error]")) throw new Error("invalid rounds error not shown");
|
||||
await setValue(objectiveInput, "finish the migration safely");
|
||||
await setValue(roundsInput, "12");
|
||||
await act(async () => { form.dispatchEvent(new dom.window.Event("submit", { bubbles: true, cancelable: true })); });
|
||||
if (document.querySelector("form[data-goal-edit-form]")) throw new Error("form should close after save");
|
||||
if (edit?.objective !== "finish the migration safely" || edit.maxGoalRounds !== 12) {
|
||||
throw new Error(`edit action returned ${JSON.stringify(edit)}`);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ let navigationEpoch = 0;
|
||||
let cancelled = 0;
|
||||
const opened: string[] = [];
|
||||
const preparedSelectors: unknown[] = [];
|
||||
let preparedSource: SessionPreparationView | undefined;
|
||||
const source = { hostId: "local", sourceKey: "legacy", path: "/fixture/legacy.jsonl" };
|
||||
const host = installDesktopHostStub({
|
||||
CheckHistoricalSourceUpdate: async () => ({ sourceKey: "legacy", status: "available", version: "v2", source, retryable: false }),
|
||||
@@ -33,6 +34,7 @@ const host = installDesktopHostStub({
|
||||
},
|
||||
PrepareSession: async (selector: unknown) => {
|
||||
preparedSelectors.push(selector);
|
||||
if (preparedSource) return preparedSource;
|
||||
return { operationId: "prepare-source", sourceKey: "legacy", status: "ready", revision: 8,
|
||||
target: { hostId: "local", sessionId: "imported-source" }, retryable: false };
|
||||
},
|
||||
@@ -133,6 +135,29 @@ const [canonicalTab] = seedActiveTabMetaList([{ ...baseProps.tab, historicalSour
|
||||
assert.equal(canonicalTab.historicalSource, undefined, "canonical metadata omission clears the old preparation state");
|
||||
await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners {...baseProps} tab={canonicalTab} /></LocaleProvider>));
|
||||
assert.equal(document.getElementById("reasonix-prepare-restored-session"), null, "successful activation removes the preparation action");
|
||||
const failingSourceTab = { ...baseProps.tab, sessionId: undefined, ready: false, historicalSource: source };
|
||||
let recoveryOpened = 0;
|
||||
const openFailures: unknown[] = [];
|
||||
preparedSource = { operationId: "prepare-conflict", sourceKey: "legacy", status: "failed", revision: 9, errorCode: "workspace_conflict",
|
||||
errorDetail: "session workspace identity is inconsistent; the session files were left unchanged", retryable: true };
|
||||
await act(async () => root.render(<LocaleProvider><HistoricalSessionBanners tab={failingSourceTab}
|
||||
navigate={async intent => { openFailures.push(intent); throw new Error("open refused: workspace registry busy"); }}
|
||||
openRecoveryDetails={() => { recoveryOpened++; }} /></LocaleProvider>));
|
||||
assert.equal(document.body.textContent?.includes("Review recovery details"), false, "recovery details appear only with a failure");
|
||||
await act(async () => document.getElementById("reasonix-prepare-restored-session")!.click());
|
||||
const failureAlert = () => document.querySelector('[role="alert"]');
|
||||
assert.ok(failureAlert()?.textContent?.includes("records a different project folder"), "the banner names the failure class the host reported");
|
||||
await act(async () => failureAlert()!.querySelector<HTMLButtonElement>(".user-error__toggle")!.click());
|
||||
assert.ok(failureAlert()?.textContent?.includes("session workspace identity is inconsistent"), "the host's failure detail is reachable from the banner");
|
||||
await act(async () => [...document.querySelectorAll<HTMLButtonElement>("button")].find(button => button.textContent === "Review recovery details")!.click());
|
||||
assert.equal(recoveryOpened, 1, "the promised recovery details open");
|
||||
assert.deepEqual(openFailures, [], "a failed preparation never navigates");
|
||||
preparedSource = { operationId: "prepare-ready", sourceKey: "legacy", status: "ready", revision: 10,
|
||||
target: { hostId: "local", sessionId: "imported-source" }, retryable: false };
|
||||
await act(async () => document.getElementById("reasonix-prepare-restored-session")!.click());
|
||||
assert.ok(failureAlert()?.textContent?.includes("Imported, but the conversation could not be opened"), "an open failure is not reported as an import failure");
|
||||
await act(async () => failureAlert()!.querySelector<HTMLButtonElement>(".user-error__toggle")!.click());
|
||||
assert.ok(failureAlert()?.textContent?.includes("open refused: workspace registry busy"), "the open failure keeps its cause");
|
||||
await act(async () => root.unmount());
|
||||
host.uninstall();
|
||||
dom.window.close();
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { installDesktopHostStub } from "./desktopHostStub";
|
||||
import { getTranscriptStore } from "../lib/transcriptStore";
|
||||
import { loadHistoryWindow, type HistoryWindowAction } from "../lib/historyWindowController";
|
||||
import { initialState, reducer, type State } from "../lib/useController";
|
||||
|
||||
const dom = new JSDOM("", { url: "http://localhost/" });
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
|
||||
let revision = 1;
|
||||
const stub = installDesktopHostStub({
|
||||
SessionHistoryWindowForTab: async () => ({
|
||||
status: "ready",
|
||||
snapshotSequence: revision,
|
||||
generation: `generation-${revision}`,
|
||||
totalTurns: 30,
|
||||
hasOlder: true,
|
||||
olderCursor: "older-cursor",
|
||||
messages: [{ messageId: `user-${revision}`, position: 30, version: 1, role: "user", eventSequence: 30,
|
||||
visibleTurn: 30, preview: `revision ${revision}`, inline: { id: `user-${revision}`, role: "user", content: `revision ${revision}` } }],
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
const store = getTranscriptStore();
|
||||
for (const protocol of [1, 2] as const) {
|
||||
revision = 1;
|
||||
const tabId = `history-reload-tab-${protocol}`;
|
||||
const sessionPath = `/fixture/history-reload-${protocol}.jsonl`;
|
||||
const initial = await store.loadLatest(tabId, sessionPath);
|
||||
assert.equal(initial?.revision, 1);
|
||||
store.evictTab(tabId);
|
||||
revision = 2;
|
||||
let state: State = {
|
||||
...initialState,
|
||||
transcriptProtocol: protocol,
|
||||
historyStartTurn: initial!.startTurn,
|
||||
historyTotalTurns: initial!.totalTurns,
|
||||
historyHasOlder: true,
|
||||
historyHasNewer: false,
|
||||
historyOlderLoading: false,
|
||||
historyNewerLoading: false,
|
||||
historyRevision: 1,
|
||||
historyDigest: "generation-1",
|
||||
running: false,
|
||||
meta: { label: "fixture", ready: true, eventChannel: "fixture", cwd: "/fixture", sessionPath,
|
||||
sessionRevision: 1, sessionDigest: "generation-1" },
|
||||
};
|
||||
const actions: HistoryWindowAction[] = [];
|
||||
const outcome = await loadHistoryWindow({
|
||||
tabId, direction: "older", trigger: "retry", state, requestSeq: 1,
|
||||
isCurrent: () => true,
|
||||
currentState: () => state,
|
||||
dispatch: action => {
|
||||
actions.push(action);
|
||||
state = reducer(state, action);
|
||||
},
|
||||
});
|
||||
assert.equal(outcome, "loaded", `protocol ${protocol}: refreshed page should replace an evicted old window: ${JSON.stringify(actions)}`);
|
||||
assert.equal(actions[actions.length - 1]?.type, protocol === 2 ? "transcript_records" : "history_replace");
|
||||
assert.equal(actions.find(action => action.type === "history_older_error"), undefined);
|
||||
assert.equal(state.historyRevision, 2);
|
||||
assert.equal(state.historyDigest, "generation-2");
|
||||
assert.equal(state.historyOlderLoading, false);
|
||||
assert.ok(state.items.some(item => item.kind === "user" && item.text === "revision 2"));
|
||||
}
|
||||
} finally {
|
||||
stub.uninstall();
|
||||
dom.window.close();
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Run: tsx src/__tests__/hydrate-failure-attribution.test.ts
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { FollowRequest, TranscriptFollowResponse } from "../generated/desktopContract.generated";
|
||||
import { hydrateFailureDetail } from "../lib/hydrateErrorState";
|
||||
import { projectSessionAvailability } from "../lib/sessionAvailability";
|
||||
import { TranscriptFollowClient } from "../lib/transcriptFollowClient";
|
||||
import { initialState, reducer } from "../lib/useController";
|
||||
|
||||
const SUMMARY = "Failed to load conversation history.";
|
||||
|
||||
async function localFollowFailure(cause: unknown): Promise<unknown> {
|
||||
const client = new TranscriptFollowClient((request: FollowRequest) =>
|
||||
request.close ? Promise.resolve({ protocolVersion: 2, subscription: "", changes: [], resetRequired: false } as TranscriptFollowResponse)
|
||||
: Promise.reject(cause), { transport: "local" });
|
||||
try {
|
||||
await client.start({ install: () => {}, changes: () => {}, connection: () => {} });
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
assert.fail("follow start resolved although the host rejected the baseline read");
|
||||
}
|
||||
|
||||
test("a failed local follow keeps its stage, reason and host cause in the recovery detail", async () => {
|
||||
const failure = await localFollowFailure(new Error("session store: database is locked"));
|
||||
const detail = hydrateFailureDetail(SUMMARY, failure);
|
||||
assert.ok(detail.startsWith(SUMMARY), detail);
|
||||
assert.match(detail, /baseline_read\.transport_rejected/);
|
||||
assert.match(detail, /session store: database is locked/);
|
||||
|
||||
const failed = reducer({ ...initialState, hydrating: true }, { type: "hydrate_error", reason: "startup", error: detail });
|
||||
const availability = projectSessionAvailability({ local: failed });
|
||||
assert.deepEqual({ kind: availability.kind, source: availability.source }, { kind: "error", source: "history" });
|
||||
assert.equal(availability.detail, detail, "the banner's details show the cause, not only the summary");
|
||||
});
|
||||
|
||||
test("causes without a follow identity keep their message; an absent cause keeps the summary alone", () => {
|
||||
assert.equal(hydrateFailureDetail(SUMMARY, new Error("resident snapshot unreadable")), `${SUMMARY}\nresident snapshot unreadable`);
|
||||
assert.equal(hydrateFailureDetail(SUMMARY, "plain rejection"), `${SUMMARY}\nplain rejection`);
|
||||
assert.equal(hydrateFailureDetail(SUMMARY, undefined), SUMMARY);
|
||||
assert.equal(hydrateFailureDetail(SUMMARY, new Error(" ")), SUMMARY);
|
||||
});
|
||||
|
||||
test("every local history-read failure hands its cause to the recovery detail", () => {
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const controller = readFileSync(join(root, "lib/useController.ts"), "utf8");
|
||||
assert.doesNotMatch(controller, /type: "hydrate_error", reason, error: t\("history\.failedLoadHistory"\) \}/,
|
||||
"a history-read failure must not replace its cause with the generic summary");
|
||||
assert.doesNotMatch(controller, /const error = t\("history\.failedLoadHistory"\);/,
|
||||
"the follow failure must not be replaced by the generic summary");
|
||||
assert.equal(controller.match(/hydrateFailureDetail\(t\("history\.failedLoadHistory"\), /g)?.length, 2,
|
||||
"both the follow path and the readable-baseline path attribute their failure");
|
||||
});
|
||||
@@ -51,7 +51,7 @@ type ControllerState = Parameters<typeof reducer>[0];
|
||||
const appSource = readFileSync(new URL("../AppRuntime.tsx", import.meta.url), "utf8");
|
||||
const sessionCompositionSource = readFileSync(new URL("../app-runtime/useAppSessionComposition.ts", import.meta.url), "utf8");
|
||||
ok(
|
||||
/\[clearContextPending, pendingClose, state\.approval, state\.ask, state\.extensionForm, state\.mcpInteraction, workspaceConflict\]/.test(sessionCompositionSource),
|
||||
/\[clearContextPending, pendingClose, remoteSurfaceActive, state\.approval, state\.ask, state\.extensionForm, state\.mcpInteraction, workspaceConflict\]/.test(sessionCompositionSource),
|
||||
"App decision surface recomputes when an MCP interaction arrives",
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// Run: tsx src/__tests__/project-tree-organization-group-drop.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { useProjectTreeOrganization } from "../components/ProjectTreeOrganization";
|
||||
import type { ProjectNode, ProjectTreeOrganizationBindings, SessionGroup } from "../lib/types";
|
||||
import type { SessionOrganizationMutation, SessionOrganizationSnapshot } from "../generated/desktopContract.generated";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function ok(value: boolean, label: string) {
|
||||
process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`);
|
||||
if (value) passed += 1; else failed += 1;
|
||||
}
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
return dom;
|
||||
}
|
||||
|
||||
function topic(id: string): ProjectNode {
|
||||
return { key: `topic-${id}`, kind: "topic", label: id, root: "/repo", topicId: id, session: { hostId: "local", sessionId: id } } as ProjectNode;
|
||||
}
|
||||
|
||||
const inside = topic("inside");
|
||||
const sibling = topic("sibling");
|
||||
const outside = topic("outside");
|
||||
const loose = topic("loose");
|
||||
const folder: ProjectNode = { key: "project-/repo", kind: "project", label: "Repo", root: "/repo", children: [inside, sibling, outside, loose] };
|
||||
const groups: SessionGroup[] = [{ id: "g1", title: "Group", sessionKeys: ["ref\0local\0inside", "ref\0local\0sibling"] }];
|
||||
|
||||
function Harness({ bindings }: { bindings: ProjectTreeOrganizationBindings }) {
|
||||
const organization = useProjectTreeOrganization({ tree: [folder], refresh: async () => {}, sortMode: "created", bindings });
|
||||
const loaded = organization.groupsFor(folder).length > 0;
|
||||
return <>
|
||||
<output id="loaded">{loaded ? "yes" : "no"}</output>
|
||||
{folder.children!.map((node) => {
|
||||
const row = organization.topicRow(node, false);
|
||||
return <div key={node.key} id={`row-${node.topicId}`} {...row.props}>{node.label}</div>;
|
||||
})}
|
||||
</>;
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
function fireDrag(type: string, target: Element, data: Map<string, string>) {
|
||||
const event = new window.Event(type, { bubbles: true, cancelable: true });
|
||||
const dataTransfer = {
|
||||
setData: (format: string, value: string) => { data.set(format, value); },
|
||||
getData: (format: string) => data.get(format) ?? "",
|
||||
effectAllowed: "all",
|
||||
dropEffect: "none",
|
||||
};
|
||||
Object.defineProperty(event, "dataTransfer", { value: dataTransfer });
|
||||
Object.defineProperty(event, "clientY", { value: 10 });
|
||||
target.dispatchEvent(event);
|
||||
}
|
||||
|
||||
async function dropOnto(draggedID: string, targetID: string): Promise<SessionOrganizationMutation[]> {
|
||||
const dom = installDom();
|
||||
const mutations: SessionOrganizationMutation[] = [];
|
||||
const snapshot = (): SessionOrganizationSnapshot => ({ groups: structuredClone(groups), revision: 1, applied: true, order: [], manualOrderEnabled: false });
|
||||
const bindings: ProjectTreeOrganizationBindings = {
|
||||
ReorderTopics: async () => {},
|
||||
ListProjectGroups: async () => [],
|
||||
SaveSessionGroups: async () => {},
|
||||
GetSessionOrganization: async () => snapshot(),
|
||||
UpdateSessionOrganization: async (_workspace, _revision, mutation) => { mutations.push(mutation); return snapshot(); },
|
||||
};
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
await act(async () => {
|
||||
root.render(<LocaleProvider><ToastProvider><Harness bindings={bindings} /></ToastProvider></LocaleProvider>);
|
||||
await flush();
|
||||
});
|
||||
for (let attempt = 0; attempt < 30 && document.getElementById("loaded")?.textContent !== "yes"; attempt += 1) await act(flush);
|
||||
const data = new Map<string, string>();
|
||||
await act(async () => { fireDrag("dragstart", document.getElementById(`row-${draggedID}`)!, data); await flush(); });
|
||||
await act(async () => { fireDrag("dragover", document.getElementById(`row-${targetID}`)!, data); await flush(); });
|
||||
await act(async () => { fireDrag("drop", document.getElementById(`row-${targetID}`)!, data); await flush(); });
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) await act(flush);
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
return mutations;
|
||||
}
|
||||
|
||||
console.log("\nproject tree drop onto a group member row");
|
||||
|
||||
{
|
||||
const mutations = await dropOnto("outside", "inside");
|
||||
ok(mutations.length === 1 && mutations[0].kind === "set-group" && mutations[0].groupId === "g1",
|
||||
`dropping a non-member on a member row adds it to that group (got ${JSON.stringify(mutations.map((m) => [m.kind, m.groupId ?? ""]))})`);
|
||||
}
|
||||
|
||||
{
|
||||
const mutations = await dropOnto("sibling", "inside");
|
||||
ok(mutations.length === 1 && mutations[0].kind === "move",
|
||||
`dropping a member on another member of its group reorders (got ${JSON.stringify(mutations.map((m) => m.kind))})`);
|
||||
}
|
||||
|
||||
{
|
||||
const mutations = await dropOnto("outside", "loose");
|
||||
ok(mutations.length === 1 && mutations[0].kind === "move",
|
||||
`dropping on an ungrouped row reorders (got ${JSON.stringify(mutations.map((m) => m.kind))})`);
|
||||
}
|
||||
|
||||
{
|
||||
const mutations = await dropOnto("outside", "loose");
|
||||
ok(mutations.length === 1 && mutations[0].sortMode === "created",
|
||||
`a move carries the sort order on screen, so a first manual move starts from it (got ${JSON.stringify(mutations.map((m) => m.sortMode ?? ""))})`);
|
||||
}
|
||||
|
||||
process.stdout.write(`\nproject-tree-organization-group-drop: ${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,52 @@
|
||||
// Run: pnpm exec tsx src/__tests__/project-tree-organization-reset.test.tsx
|
||||
import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { useProjectTreeOrganization } from "../components/ProjectTreeOrganization";
|
||||
import type { ProjectNode, ProjectTreeOrganizationBindings } from "../lib/types";
|
||||
import type { SessionOrganizationMutation, SessionOrganizationSnapshot } from "../generated/desktopContract.generated";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import { LocaleProvider, t } from "../lib/i18n";
|
||||
|
||||
const dom = new JSDOM('<html><body><div id="root"></div></body></html>', { url: "http://localhost/" });
|
||||
Object.assign(globalThis, { window: dom.window, document: dom.window.document, Node: dom.window.Node, Element: dom.window.Element,
|
||||
HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const folder: ProjectNode = { key: "project-/repo", kind: "project", label: "Repo", root: "/repo", children: [] };
|
||||
const initialOrder = ["ref\0local\0older", "ref\0local\0newer"];
|
||||
let snapshot: SessionOrganizationSnapshot = { revision: 1, applied: true, manualOrderEnabled: true, order: initialOrder, groups: [] };
|
||||
const mutations: SessionOrganizationMutation[] = [];
|
||||
let refreshes = 0;
|
||||
const bindings: ProjectTreeOrganizationBindings = {
|
||||
GetSessionOrganization: async () => snapshot,
|
||||
UpdateSessionOrganization: async (_workspace, revision, mutation) => {
|
||||
assert.equal(revision, snapshot.revision);
|
||||
mutations.push(mutation);
|
||||
snapshot = { ...snapshot, revision: revision + 1, manualOrderEnabled: false };
|
||||
return snapshot;
|
||||
},
|
||||
};
|
||||
|
||||
function Harness() {
|
||||
const organization = useProjectTreeOrganization({ tree: [folder], refresh: async () => { refreshes += 1; }, bindings });
|
||||
const resetItem = organization.resetOrderMenuItems(folder, t, () => {});
|
||||
return <><output id="order">{organization.orderFor?.(folder).join(",")}</output>
|
||||
{resetItem.map(item => <button key={item.key} id="reset" onClick={item.onSelect}>Reset</button>)}</>;
|
||||
}
|
||||
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
const flush = async () => { await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)); }); };
|
||||
try {
|
||||
await act(async () => root.render(<LocaleProvider><ToastProvider><Harness /></ToastProvider></LocaleProvider>));
|
||||
await flush();
|
||||
assert.equal(document.getElementById("order")?.textContent, initialOrder.join(","));
|
||||
await act(async () => { document.getElementById("reset")!.click(); });
|
||||
await flush();
|
||||
assert.deepEqual(mutations.map(mutation => mutation.kind), ["reset-order"]);
|
||||
assert.equal(document.getElementById("order")?.textContent, "");
|
||||
assert.equal(refreshes, 1);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
dom.window.close();
|
||||
}
|
||||
@@ -25,8 +25,9 @@ const { ToastProvider } = await import("../lib/toast");
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
let reject!: (reason: Error) => void;
|
||||
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const pending: ReturnType<typeof deferred<ProjectTreeSnapshot>>[] = [];
|
||||
@@ -78,9 +79,21 @@ try {
|
||||
try {
|
||||
await render(0);
|
||||
assert.equal(pending.length, 1, "mount starts one shell read");
|
||||
assert.match(container.querySelector(".project-tree__empty-state")?.textContent ?? "", /读取|讀取|Reading/,
|
||||
"an unanswered shell read must say it is loading");
|
||||
assert.doesNotMatch(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/,
|
||||
"pending shell read must not claim there are no projects");
|
||||
if (scenario.name === "addition") {
|
||||
assert.equal(container.querySelector(".project-tree__empty-state .project-tree__skeleton"), null,
|
||||
"fast shell reads must not flash a skeleton");
|
||||
await act(async () => mock.timers.tick(250));
|
||||
assert.ok(container.querySelector(".project-tree__empty-state .project-tree__skeleton"),
|
||||
"a delayed shell read shows a skeleton");
|
||||
}
|
||||
await act(async () => pending[0].resolve(snapshot(scenario.before)));
|
||||
await flush();
|
||||
assert.deepEqual(labels(), scenario.before.map(item => item.label));
|
||||
assert.equal(container.querySelector(".project-tree__empty-state .project-tree__skeleton"), null);
|
||||
|
||||
// A read started before the mutation retains the old shell contents.
|
||||
// Its scalar revision may equal the post-mutation read: this fixture
|
||||
@@ -114,6 +127,44 @@ try {
|
||||
await act(async () => root.unmount());
|
||||
}
|
||||
}
|
||||
|
||||
pending.length = 0;
|
||||
localStorage.clear();
|
||||
const emptyRoot = createRoot(container);
|
||||
try {
|
||||
await act(async () => emptyRoot.render(<LocaleProvider><ToastProvider><ProjectTree
|
||||
onOpenTopic={noop} onAddProject={addProject}
|
||||
/></ToastProvider></LocaleProvider>));
|
||||
await flush();
|
||||
assert.doesNotMatch(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/);
|
||||
await act(async () => pending[0].resolve(snapshot([])));
|
||||
await flush();
|
||||
assert.match(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/,
|
||||
"an empty answer restores the true empty state");
|
||||
} finally {
|
||||
await act(async () => emptyRoot.unmount());
|
||||
}
|
||||
|
||||
pending.length = 0;
|
||||
localStorage.clear();
|
||||
const failedRoot = createRoot(container);
|
||||
try {
|
||||
await act(async () => failedRoot.render(<LocaleProvider><ToastProvider><ProjectTree
|
||||
onOpenTopic={noop} onAddProject={addProject}
|
||||
/></ToastProvider></LocaleProvider>));
|
||||
await flush();
|
||||
assert.match(container.querySelector(".project-tree__empty-state")?.textContent ?? "", /读取|讀取|Reading/);
|
||||
await act(async () => pending[0].reject(new Error("snapshot unavailable")));
|
||||
await flush();
|
||||
assert.doesNotMatch(container.textContent ?? "", /读取项目|讀取專案|Reading projects/,
|
||||
"a failed first shell read must settle the loading state");
|
||||
assert.match(container.textContent ?? "", /Add new project|添加新项目|新增專案/,
|
||||
"the empty-state project action must remain available after a failed read");
|
||||
assert.match(container.textContent ?? "", /Remote connection|远程连接|遠端連線/,
|
||||
"the empty-state remote action must remain available after a failed read");
|
||||
} finally {
|
||||
await act(async () => failedRoot.unmount());
|
||||
}
|
||||
} finally {
|
||||
mock.timers.reset();
|
||||
dom.window.close();
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { initialState, reducer } from "../lib/useController";
|
||||
import { historyMessagesToItems } from "../lib/historyItems";
|
||||
import { historyNoticeItems } from "../lib/controllerNotices";
|
||||
import { presentError } from "../lib/errorPresentation";
|
||||
import { en } from "../locales/en";
|
||||
import type { HistoryMessage } from "../lib/types";
|
||||
|
||||
const started = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
|
||||
const failed = reducer(started, {
|
||||
@@ -25,3 +30,17 @@ assert.equal(
|
||||
"Connection ID: deepseek-anthropic\nRequest path: /anthropic/v1/chat/completions",
|
||||
"stable provider id and sanitized path stay in live diagnostic details",
|
||||
);
|
||||
|
||||
const diagnostic = { kind: "transport_protocol", transportCode: "PROTOCOL_ERROR" };
|
||||
const history: HistoryMessage = { role: "notice", content: "opaque provider failure", level: "warn", diagnostic };
|
||||
const live = reducer(started, { type: "event", e: { kind: "turn_done", err: history.content, diagnostic } });
|
||||
const interrupted = reducer(started, { type: "event", e: { kind: "turn_done", status: "interrupted", err: history.content, diagnostic } });
|
||||
const liveNotice = reducer(started, { type: "event", e: { kind: "notice", level: "warn", text: history.content, diagnostic } });
|
||||
for (const items of [live.items, interrupted.items, liveNotice.items, historyMessagesToItems([history], "full").items, historyNoticeItems(history, "windowed")]) {
|
||||
const item = items.find(item => item.kind === "notice" && item.level === "warn");
|
||||
assert.ok(item?.kind === "notice");
|
||||
assert.deepEqual(item.diagnostic, diagnostic, "live and both history projections retain structured evidence");
|
||||
const presentation = presentError(item.text, key => en[key], "en", item.diagnostic);
|
||||
assert.equal(presentation.summary, en["error.transportProtocol"]);
|
||||
assert.match(presentation.detail, /PROTOCOL_ERROR/);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Switching back to a remote session whose transcript is already resident must
|
||||
// paint it immediately and reconcile in the background — the local session
|
||||
// switch experience — instead of paying the network round trips behind a
|
||||
// loading placeholder every time.
|
||||
import React, { act } from "react";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { FollowRequest, TranscriptFollowResponse } from "../generated/desktopContract.generated";
|
||||
import type { RemoteSessionApi } from "../lib/useRemoteSession";
|
||||
import { installDesktopHostStub } from "./desktopHostStub";
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
function ok(value: boolean, label: string) {
|
||||
process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`);
|
||||
if (value) passed += 1; else failed += 1;
|
||||
}
|
||||
console.log("\nRemote session resident-cache paint");
|
||||
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.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame?.bind(dom.window) ?? ((cb: FrameRequestCallback) => setTimeout(() => cb(Date.now()), 16) as unknown as number);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame?.bind(dom.window) ?? ((handle: number) => clearTimeout(handle));
|
||||
|
||||
type FollowMode = "ok" | "deferred";
|
||||
const followMode = new Map<string, FollowMode>();
|
||||
const deferred = new Map<string, (response: TranscriptFollowResponse) => void>();
|
||||
const answerFor = new Map<string, string>();
|
||||
const statusFor = () => ({ running: false, label: "Model", plan: false, toolApprovalMode: "ask", goal: "", takenOver: false });
|
||||
const inline = (id: string, content: string) => ({ messageId: id, position: 0, version: 1, role: "assistant", eventSequence: 1, visibleTurn: 1, preview: "", inline: { id, role: "assistant", content } });
|
||||
const followResponse = (tabId: string): TranscriptFollowResponse => ({
|
||||
protocolVersion: 2, subscription: `sub-${tabId}`, changes: [], resetRequired: false,
|
||||
snapshot: {
|
||||
protocolVersion: 1, snapshotId: `cut-${tabId}`, identity: { sessionId: tabId, runtimeEpoch: "epoch", rewriteEpoch: 0, headId: "" },
|
||||
projectionRevision: 10, coveredThroughSeq: 4, durableSeq: 4, records: [], activeRecords: [], activeAttempts: [],
|
||||
runtime: { status: "completed", pendingEvents: [], samplingCount: 0, toolCount: 0 }, before: 0, hasOlder: false, totalRecords: 1, totalTurns: 1, stale: false,
|
||||
},
|
||||
history: { status: "ready", snapshotSequence: 4, coverageSequence: 4, generation: "gen", totalTurns: 1, hasOlder: false, hasNewer: false, messages: [inline(`answer-${tabId}`, answerFor.get(tabId) ?? "follower answer")] },
|
||||
});
|
||||
installDesktopHostStub({
|
||||
async RegisterNavigationIntent() {},
|
||||
async SetActiveTab() {},
|
||||
async ForkTargetsRemoteTab() { return { targets: [], verifiable: false }; },
|
||||
async RemoteTabStatus() { return statusFor(); },
|
||||
async RemoteTabMetadata() { return { status: statusFor() }; },
|
||||
async RemoteTabSnapshot() { return { history: [], status: statusFor() }; },
|
||||
async RemoteTranscriptFollowForTab(tabId: string, request: FollowRequest) {
|
||||
if (request.close) return { protocolVersion: 2, subscription: request.subscription ?? "", changes: [], resetRequired: false };
|
||||
if (request.subscription) return new Promise<TranscriptFollowResponse>(() => {});
|
||||
if ((followMode.get(tabId) ?? "ok") === "deferred") {
|
||||
return new Promise<TranscriptFollowResponse>((resolve) => { deferred.set(tabId, resolve); });
|
||||
}
|
||||
return followResponse(tabId);
|
||||
},
|
||||
async RemoteSessionHistoryWindowForTab() { return new Promise(() => {}); },
|
||||
} as Partial<AppBindings> as AppBindings);
|
||||
|
||||
const [{ createRoot }, { useRemoteSession }, { getTranscriptStore }, { setTranscriptBindingIdentity }] = await Promise.all([
|
||||
import("react-dom/client"), import("../lib/useRemoteSession"), import("../lib/transcriptStore"), import("../lib/canonicalTranscriptBackend"),
|
||||
]);
|
||||
setTranscriptBindingIdentity(() => "remote");
|
||||
|
||||
async function flush(ticks = 8) {
|
||||
for (let i = 0; i < ticks; i++) await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
}
|
||||
let probe: RemoteSessionApi | undefined;
|
||||
function Probe({ tabId, sessionPath }: { tabId: string; sessionPath: string }) {
|
||||
probe = useRemoteSession(tabId, "ready", sessionPath);
|
||||
return null;
|
||||
}
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
const mount = (tabId: string, sessionPath: string) => act(async () => {
|
||||
root.render(<Probe tabId={tabId} sessionPath={sessionPath} />);
|
||||
await flush();
|
||||
// The follower runtime is dynamically imported on first use.
|
||||
for (let i = 0; i < 3; i++) await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
await flush();
|
||||
});
|
||||
const texts = () => (probe?.transcript.items ?? []).flatMap((item) => item.kind === "assistant" ? [item.text] : []);
|
||||
|
||||
// 1. Visit session A so its transcript becomes resident, then switch away.
|
||||
followMode.set("tab-switch", "ok");
|
||||
answerFor.set("tab-switch", "session A answer");
|
||||
await mount("tab-switch", "session-a");
|
||||
// The mount effect's hydrate depends on the follower runtime's dynamic import,
|
||||
// which settles unreliably in JSDOM (the repo's prime suite is red on HEAD for
|
||||
// the same reason). Drive it explicitly to reach the state under test.
|
||||
await act(async () => { await probe?.retryHydration(); await flush(); });
|
||||
ok(probe?.hydrated === true && texts().includes("session A answer"), "session A hydrates and becomes resident");
|
||||
|
||||
// 2. Switching to a session with no resident cut keeps the network path.
|
||||
followMode.set("tab-switch", "deferred");
|
||||
await mount("tab-switch", "session-b");
|
||||
ok(probe?.hydrated === false && probe?.revalidating === false, "an uncached session still waits for its hydrate");
|
||||
|
||||
// 3. Switching back paints the resident cut with no network wait.
|
||||
await mount("tab-switch", "session-a");
|
||||
ok(texts().includes("session A answer"), "the resident session is visible immediately on switch-back");
|
||||
ok(probe?.hydrated === true && probe?.revalidating === true, "the painted surface reports revalidating while the hydrate runs");
|
||||
ok(getTranscriptStore().peek("tab-switch", "session-a") !== undefined, "the paint reads the session-keyed resident cut");
|
||||
|
||||
// 4. The background hydrate supersedes the paint.
|
||||
answerFor.set("tab-switch", "session A revalidated");
|
||||
await act(async () => { void probe?.retryHydration(); await flush(); });
|
||||
await act(async () => { deferred.get("tab-switch")?.(followResponse("tab-switch")); await flush(); });
|
||||
ok(texts().includes("session A revalidated") && !texts().includes("session A answer"), "the authoritative cut replaces the painted one");
|
||||
ok(probe?.revalidating === false && probe?.hydrated === true, "revalidating clears once the hydrate settles");
|
||||
|
||||
await act(async () => { root.unmount(); });
|
||||
dom.window.close();
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -391,6 +391,22 @@ await act(async () => {
|
||||
ok(Boolean(document.querySelector(".prompt-shelf--ask")) && document.body.textContent?.includes("remote answer failed") === true && document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.disabled === false, "a failed remote Ask answer preserves the card, surfaces the error, and re-enables retry"); failAnswer = false; await act(async () => { document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.click(); await flush(); }); ok(tape.filter((entry) => entry.startsWith("answer:tab-remote-1:ask-7:")).length === 2 && !document.querySelector(".prompt-shelf--ask"), "a successful remote Ask retry resubmits the complete answer and clears the card");
|
||||
}
|
||||
|
||||
// A CLI serve never binds desktop routing metadata, so its prompts carry no
|
||||
// runtimeEpoch; the serve fences on the epoch only when it has one.
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", turnId: "turn-main", ask: { id: "ask-serve", questions: [{ id: "q1", prompt: "Ship it?", options: [{ label: "ship" }, { label: "wait" }] }] } });
|
||||
await flush();
|
||||
});
|
||||
{
|
||||
ok(Boolean(document.querySelector(".prompt-shelf--ask")) && !document.body.textContent?.includes("exact request identity is unavailable"), "an ask without a runtime epoch is answerable, not refused as unidentified");
|
||||
await act(async () => {
|
||||
[...document.querySelectorAll<HTMLButtonElement>("button")].find((b) => b.textContent?.trim() === "ship")?.click();
|
||||
await flush();
|
||||
});
|
||||
await act(async () => { document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.click(); await flush(); });
|
||||
ok(tape.some((entry) => entry.startsWith("answer:tab-remote-1:ask-serve:")) && !document.querySelector(".prompt-shelf--ask"), "the answer reaches the serve and the card clears");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", turnId: "turn-main", runtimeEpoch: "runtime-main", ask: { id: "ask-custom", questions: [{ id: "q-custom", prompt: "Where?", options: [{ label: "staging" }] }] } });
|
||||
await flush();
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { setAttentionPreference, setSuccessPreference } from "../lib/sound";
|
||||
import { createRuntimeNotifications } from "../lib/runtimeNotifications";
|
||||
import { t } from "../lib/i18n";
|
||||
|
||||
const stored = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem(key: string) { return stored.get(key) ?? null; },
|
||||
setItem(key: string, value: string) { stored.set(key, value); },
|
||||
removeItem(key: string) { stored.delete(key); },
|
||||
clear() { stored.clear(); },
|
||||
},
|
||||
});
|
||||
let contexts = 0;
|
||||
class FakeAudioContext {
|
||||
destination = {};
|
||||
constructor() { contexts++; }
|
||||
createOscillator() { return { type: "sine", frequency: { setValueAtTime() {} }, connect() {}, start() {}, stop() {} }; }
|
||||
createGain() {
|
||||
return { gain: { setValueAtTime() {}, linearRampToValueAtTime() {}, exponentialRampToValueAtTime() {} }, connect() {} };
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
Object.defineProperty(globalThis, "AudioContext", { configurable: true, value: FakeAudioContext });
|
||||
|
||||
const owner = createRuntimeNotifications(() => ({ activeTabId: "A", t, showToast() {} }));
|
||||
|
||||
function chimesFor(event: { err?: string; outcome?: string }, only: "success" | "attention"): number {
|
||||
setSuccessPreference(only === "success" ? "synth" : "off");
|
||||
setAttentionPreference(only === "attention" ? "synth" : "off");
|
||||
const before = contexts;
|
||||
owner.accept({ event: { kind: "turn_done", tabId: "A", ...event } });
|
||||
return contexts - before;
|
||||
}
|
||||
|
||||
const readiness = { err: "delivery checks are incomplete", outcome: "final_readiness" };
|
||||
assert.equal(chimesFor(readiness, "attention"), 1, "a turn that stopped for delivery checks must alert the user");
|
||||
assert.equal(chimesFor(readiness, "success"), 0, "a turn that stopped for delivery checks is not a success");
|
||||
for (const outcome of ["recovery_paused", "incomplete_read", "completion_uncertain"]) {
|
||||
assert.equal(chimesFor({ err: "paused", outcome }, "attention"), 1, `${outcome} waits on the user`);
|
||||
}
|
||||
assert.equal(chimesFor({}, "success"), 1, "a clean finish keeps the success chime");
|
||||
assert.equal(chimesFor({}, "attention"), 0, "a clean finish does not ring the attention chime");
|
||||
assert.equal(chimesFor({ err: "provider failed" }, "success") + chimesFor({ err: "provider failed" }, "attention"), 0,
|
||||
"an ordinary failure stays silent");
|
||||
owner.dispose();
|
||||
console.log("runtime notifications: paused turn outcomes ring the attention chime");
|
||||
@@ -35,7 +35,9 @@ try {
|
||||
assert.deepEqual([...document.querySelector("header")!.children].map(node => node.className),
|
||||
["topicbar__brand", "btn btn--small", "tooltip-trigger", "topicbar__identity", "topicbar__spacer", "topicbar__actions"],
|
||||
"the Windows bar leads with the app mark");
|
||||
assert.equal(document.querySelector(".topicbar__brand-logo")!.tagName, "IMG", "the leading mark is the app logo asset");
|
||||
const logo = document.querySelector(".topicbar__brand-logo")!;
|
||||
assert.equal(logo.getAttribute("role"), "img", "the leading mark is the app logo");
|
||||
assert.equal(logo.getAttribute("aria-label"), "Reasonix");
|
||||
await paint();
|
||||
const action = document.querySelector(".topicbar__actions button");
|
||||
assert.equal(document.querySelectorAll(".topicbar__subtitle .worktree-badge").length, 1);
|
||||
|
||||
@@ -87,7 +87,7 @@ export function projectConversation({ local, remote, tab, activeTabId, backgroun
|
||||
cacheMissTokens: runtime.usage?.cacheMissTokens, balance: runtime.balance,
|
||||
},
|
||||
context: {
|
||||
tabId: remote ? undefined : activeTabId,
|
||||
tabId: activeTabId,
|
||||
items: runtime.items, context: runtime.context, usage: runtime.usage,
|
||||
sessionTokens: runtime.sessionTokens, sessionCost: runtime.sessionCost,
|
||||
sessionCurrency: runtime.sessionCurrency, turnTokens: runtime.turnTotalTokens,
|
||||
|
||||
@@ -19,6 +19,26 @@ export function projectDecisionSurface(input: {
|
||||
workspaceConflict: WorkspaceConflictView | null;
|
||||
pendingClose: PendingClose;
|
||||
clearContextPending: boolean;
|
||||
remote?: boolean;
|
||||
}): AppDecisionSurfaceKind | null {
|
||||
// A remote session's prompts and forms are answered by RemoteSessionSurface,
|
||||
// the one owner of the serve's exact prompt route; a footer copy would be a
|
||||
// second card whose answer has no local session to reach.
|
||||
if (!input.remote) {
|
||||
const prompt = projectPromptSurface(input);
|
||||
if (prompt) return prompt;
|
||||
}
|
||||
if (input.workspaceConflict) return "workspace_conflict";
|
||||
if (input.pendingClose) return "close_active";
|
||||
if (input.clearContextPending) return "clear_context";
|
||||
return null;
|
||||
}
|
||||
|
||||
function projectPromptSurface(input: {
|
||||
approval: State["approval"];
|
||||
ask: State["ask"];
|
||||
mcpInteraction: State["mcpInteraction"];
|
||||
extensionForm: State["extensionForm"];
|
||||
}): AppDecisionSurfaceKind | null {
|
||||
if (input.approval) {
|
||||
return input.approval.tool === "exit_plan_mode" ? "plan_approval" : "tool_approval";
|
||||
@@ -26,8 +46,5 @@ export function projectDecisionSurface(input: {
|
||||
if (input.ask) return "ask";
|
||||
if (input.mcpInteraction) return "mcp_interaction";
|
||||
if (input.extensionForm) return "extension_form";
|
||||
if (input.workspaceConflict) return "workspace_conflict";
|
||||
if (input.pendingClose) return "close_active";
|
||||
if (input.clearContextPending) return "clear_context";
|
||||
return null;
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export type HistoricalPreparationSurface = {
|
||||
operationId: string;
|
||||
status: SessionPreparationView["status"];
|
||||
errorCode?: string;
|
||||
errorDetail?: string;
|
||||
retryable: boolean;
|
||||
revision?: number;
|
||||
isCurrent?: () => boolean;
|
||||
@@ -59,7 +60,7 @@ export function reconcileHistoricalPreparation(expected: HistoricalPreparationSu
|
||||
if (!current || current.operationId !== expected.operationId || current.session !== expected.session
|
||||
|| expected.isCurrent?.() === false || view.operationId !== expected.operationId
|
||||
|| view.revision < (current.revision ?? 0) || view.status === "ready") return;
|
||||
setHistoricalPreparation({ ...current, status: view.status, errorCode: view.errorCode,
|
||||
setHistoricalPreparation({ ...current, status: view.status, errorCode: view.errorCode, errorDetail: view.errorDetail,
|
||||
retryable: view.retryable, revision: view.revision });
|
||||
}
|
||||
export type NavigationNotice = {
|
||||
@@ -148,6 +149,8 @@ export async function executeDesktopNavigation(input: DesktopNavigationCapture,
|
||||
checkpoint();
|
||||
if (connection.sessionSource === "auto") await ports.openChannelSession(target.value, tab.id, seq);
|
||||
else await ports.resumeSession(target.value, tab.id, seq);
|
||||
} else if (target.kind === "bot-session") {
|
||||
tab = await openTopic(connection.scope, connection.workspaceRoot, "", `bot-session:local:${target.value}`);
|
||||
} else tab = await openTopic(connection.scope, connection.workspaceRoot, target.value);
|
||||
checkpoint(); ports.seedTab(tab);
|
||||
await refresh(); checkpoint(); ports.reveal(); ports.projectChanged(); return;
|
||||
|
||||
@@ -216,7 +216,7 @@ export function sidebarImConnectionsFromBot(
|
||||
return qqConnection ? [qqConnection, ...connectionItems] : connectionItems;
|
||||
}
|
||||
|
||||
function mappedSessionTarget(sessionId: string): { kind: "path" | "topic"; value: string } | null {
|
||||
function mappedSessionTarget(sessionId: string): { kind: "path" | "topic" | "bot-session"; value: string } | null {
|
||||
const trimmed = sessionId.trim();
|
||||
if (!trimmed) return null;
|
||||
const lower = trimmed.toLowerCase();
|
||||
@@ -224,6 +224,10 @@ function mappedSessionTarget(sessionId: string): { kind: "path" | "topic"; value
|
||||
const value = trimmed.slice(5).trim();
|
||||
return value ? { kind: "path", value } : null;
|
||||
}
|
||||
if (lower.startsWith("session:local:")) {
|
||||
const value = trimmed.slice("session:local:".length).trim();
|
||||
return value ? { kind: "bot-session", value } : null;
|
||||
}
|
||||
if (lower.startsWith("topic:")) {
|
||||
const value = trimmed.slice(6).trim();
|
||||
return value ? { kind: "topic", value } : null;
|
||||
@@ -240,7 +244,7 @@ export function taskSessionIDFromPath(path: string): string {
|
||||
return extension > 0 ? base.slice(0, extension) : base;
|
||||
}
|
||||
|
||||
export function sidebarImSessionTarget(connection: SidebarImConnection): { kind: "path" | "topic"; value: string } | null {
|
||||
export function sidebarImSessionTarget(connection: SidebarImConnection): { kind: "path" | "topic" | "bot-session"; value: string } | null {
|
||||
return mappedSessionTarget(connection.sessionId);
|
||||
}
|
||||
|
||||
|
||||
@@ -345,8 +345,8 @@ export function useAppSessionComposition(input: AppSessionCompositionInput) {
|
||||
|
||||
const decisionSurface = useMemo((): AppDecisionSurfaceKind | null => projectDecisionSurface({
|
||||
approval: state.approval, ask: state.ask, mcpInteraction: state.mcpInteraction, extensionForm: state.extensionForm,
|
||||
workspaceConflict, pendingClose, clearContextPending,
|
||||
}), [clearContextPending, pendingClose, state.approval, state.ask, state.extensionForm, state.mcpInteraction, workspaceConflict]);
|
||||
workspaceConflict, pendingClose, clearContextPending, remote: remoteSurfaceActive,
|
||||
}), [clearContextPending, pendingClose, remoteSurfaceActive, state.approval, state.ask, state.extensionForm, state.mcpInteraction, workspaceConflict]);
|
||||
const visibleDecisionSurface = decisionSurface;
|
||||
// Navigation used to hide the entire composer until the controller/runtime
|
||||
// activation ticket and the transcript paint ticket both settled. That made
|
||||
|
||||
@@ -31,7 +31,7 @@ export function useRuntimeNotifications(activeTabId: string | undefined) {
|
||||
pending.current = [];
|
||||
};
|
||||
}, [readPorts]);
|
||||
const handleNotification = useCommittedCommand((event: AttentionChimeEvent & { err?: string }) => {
|
||||
const handleNotification = useCommittedCommand((event: AttentionChimeEvent & { err?: string; outcome?: string }) => {
|
||||
if (event.kind === "ask_request" || event.kind === "approval_request" || event.kind === "turn_done") accept({ event });
|
||||
});
|
||||
const resetLegacyAttention = useCommittedCommand((resetTabId?: string) => accept({ resetTabId }));
|
||||
|
||||
@@ -310,6 +310,7 @@ export function AppRuntimeView(props: AppRuntimeViewProps) {
|
||||
})} startupError={navigationCommands.manualCreation && navigationCommands.manualCreation.operation?.phase !== "ready" ? undefined : state.meta?.startupErr}
|
||||
historical={core.remoteSurfaceActive ? undefined : { tab: activeTab, navigate: session.desktopNavigation.enqueueNavigation,
|
||||
captureNavigation: () => { const intent = runtime.navigation.currentNavigationIntent(); return () => runtime.navigation.isNavigationIntentCurrent(intent); },
|
||||
openRecoveryDetails: () => chromeCommands.openSidebarSettings("storage"),
|
||||
}} />
|
||||
|
||||
<ChatPaneRegion
|
||||
|
||||
@@ -45,7 +45,9 @@ export function SidebarRegion(props: SidebarRegionProps) {
|
||||
</button>
|
||||
</div>
|
||||
<section className="sidebar__section sidebar__section--projects">
|
||||
<Suspense fallback={null}><ProjectTree {...props.projectTree} /></Suspense>
|
||||
<Suspense fallback={<div className="project-tree__empty project-tree__empty--subtle" role="status">{t("projectTree.loadingProjects")}</div>}>
|
||||
<ProjectTree {...props.projectTree} />
|
||||
</Suspense>
|
||||
</section>
|
||||
<nav className="sidebar__nav sidebar__nav--footer">
|
||||
<div className="sidebar__utility-row" aria-label={t("sidebar.utilityActions")}>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
import logoSymbol from "../assets/logo-symbol.svg";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { WorktreeBadge } from "../components/WorktreeBadge";
|
||||
|
||||
@@ -36,7 +35,7 @@ export function TopicbarRegion({ view, commands, children }: {
|
||||
const { sidebar, title, subtitle } = view;
|
||||
return <header className="topicbar">
|
||||
{view.brand && <div className="topicbar__brand">
|
||||
<img src={logoSymbol} alt="Reasonix" className="topicbar__brand-logo" draggable={false} />
|
||||
<span role="img" aria-label="Reasonix" className="topicbar__brand-logo" />
|
||||
</div>}
|
||||
{view.automationReturn && <button className="btn btn--small" type="button" onClick={event => {
|
||||
event.currentTarget.focus({ preventScroll: true });
|
||||
|
||||
@@ -144,7 +144,7 @@ function ChatNotice({ node, actions, scroll }: { node: Extract<ChatNode, { kind:
|
||||
// Empty delivery accounting is not a chat result. Keep meaningful records in details.
|
||||
if (summary && !summary.mutations && !summary.changed_files && !summary.checks_passed && !summary.checks_failed) return null;
|
||||
if (item.level === "warn" || item.action === "recover_context") return <div className="chat-notice" role="status" data-level={item.level}>
|
||||
{item.title && <strong>{item.title} </strong>}<ErrorMessage error={item.text} />
|
||||
{item.title && <strong>{item.title} </strong>}<ErrorMessage error={item.text} diagnostic={item.diagnostic} />
|
||||
{summary && <details className="chat-notice__details"><summary>{t("chat.details")}</summary><pre>{JSON.stringify(summary, null, 2)}</pre></details>}
|
||||
{item.detail && <ChatDisclosure label={t("chat.details")}><pre>{item.detail}</pre></ChatDisclosure>}
|
||||
{item.action === "recover_context" && item.recoveryId && <button className="btn" onClick={() => actions.recover(item.recoveryId!)}>{t("notice.protocolRecoveryAction")}</button>}
|
||||
@@ -232,30 +232,36 @@ function ChatAnswer({ node, loader, source, tabId, hostId }: { node: Extract<Cha
|
||||
function ChatReasoning({ node, loader, source, scroll }: { node: Extract<ChatNode, { kind: "reasoning" }>; loader: ChatContentLoader; source: ChatSource; scroll: ChatScrollController }) {
|
||||
const t = useT();
|
||||
const [result, setResult] = useState<{ item: unknown; text: string }>();
|
||||
const [following, setFollowing] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const epoch = useRef(0);
|
||||
useEffect(() => { return () => { epoch.current++; }; }, []);
|
||||
const text = node.item.reasoning;
|
||||
const full = result && (result.item === node.item || result.text === node.item.reasoning) ? result.text : undefined;
|
||||
if (!text) return null;
|
||||
const needsFull = full === undefined && (text.length > 8000 || loader.needsFullContent(node.item, "reasoning"));
|
||||
const streaming = Boolean(node.item.streaming);
|
||||
const settled = result && (result.item === node.item || result.text === node.item.reasoning) ? result.text : undefined;
|
||||
const full = settled ?? (following ? text : undefined);
|
||||
const load = async () => {
|
||||
const ticket = ++epoch.current; setBusy(true); setError(false);
|
||||
try {
|
||||
const value = await loader.load(node.item, "reasoning");
|
||||
const current = source.getNodeSnapshot(node.key);
|
||||
if (ticket !== epoch.current || current?.kind !== "reasoning" || (current.item !== node.item && current.item.reasoning !== value)) throw new Error("Reasoning content changed; retry");
|
||||
setResult({ item: current.item, text: value }); return value;
|
||||
setResult({ item: current.item, text: value }); setFollowing(false); return value;
|
||||
}
|
||||
catch (error) { if (ticket === epoch.current) setError(true); throw error; }
|
||||
finally { if (ticket === epoch.current) setBusy(false); }
|
||||
};
|
||||
useEffect(() => {
|
||||
if (following && !streaming) void load().catch(() => {});
|
||||
}, [following, streaming]);
|
||||
if (!text) return null;
|
||||
const needsFull = error || full === undefined && (text.length > 8000 || loader.needsFullContent(node.item, "reasoning"));
|
||||
return <div className="chat-reasoning"><ReasoningRow text={text} running={node.item.streaming} t={t} beforeToggle={scroll.beforeChange}
|
||||
duration={node.item.reasoningDurationMs != null ? `${(node.item.reasoningDurationMs / 1000).toFixed(1)}s` : undefined}>
|
||||
<div className="chat-reasoning__body">{full ?? text.slice(0, 8000)}
|
||||
{needsFull && <button className="btn" disabled={busy} onClick={() => void load().catch(() => {})}>{t(error ? "chat.loadFailed" : busy ? "chat.loading" : "chat.loadFull")}</button>}
|
||||
<CopyButton getText={() => full === undefined ? load() : full} label={t("chat.copyFull")} />
|
||||
{needsFull && <button className="btn" disabled={busy} onClick={() => streaming ? setFollowing(true) : void load().catch(() => {})}>{t(error ? "chat.loadFailed" : busy ? "chat.loading" : "chat.loadFull")}</button>}
|
||||
<CopyButton getText={() => full ?? (streaming ? text : load())} label={t("chat.copyFull")} />
|
||||
</div></ReasoningRow></div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { emptyComposerDraft, persistentComposerDraft, persistentSnapshot } from "./composerDraftState";
|
||||
import { clipboardFiles, clipboardHasImageHint, isPasteShortcut, dataURLHash } from "../lib/composerClipboard";
|
||||
import { composerDraftFingerprint, emptyComposerDraft, persistentComposerDraft, persistentSnapshot } from "./composerDraftState";
|
||||
import { SessionInputRecovery } from "./SessionInputRecovery";
|
||||
import { recoveryStatusText, type RecoveryRetry } from "../lib/recoveryStatus";
|
||||
import { useRuntimeSession } from "../lib/useRuntimeState";
|
||||
import { isCompactCommand } from "../lib/sessionMaintenanceOperation";
|
||||
import { pendingFollowups, confirmFollowup, followupNotSubmitted, followupSessionKey, type PendingFollowup } from "../lib/pendingFollowup";
|
||||
import { pendingFollowups, confirmFollowup, followupNotSubmitted, followupSessionKey, queuedFollowupOutcome, type PendingFollowup } from "../lib/pendingFollowup";
|
||||
import { useAppNavigationStore } from "../store/appNavigation";
|
||||
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||
@@ -23,6 +24,7 @@ import type { ComposerTarget } from "../generated/desktopContract.generated";
|
||||
import { desktopHost } from "../lib/desktopHost";
|
||||
import { steerInboxItemForActiveTurn } from "../lib/inboxSubmit";
|
||||
import { formatInboxError, isInboxItemMissing } from "../lib/inboxError";
|
||||
import { captureStableInboxTarget, createTransientGuidance } from "../lib/transientComposerGuidance";
|
||||
import { inboxScopeKey } from "../lib/composerInboxQueue";
|
||||
import { useComposerInboxRefresh } from "../lib/useComposerInboxRefresh";
|
||||
import { useComposerImeGuard } from "../lib/useComposerImeGuard";
|
||||
@@ -288,46 +290,6 @@ function draftHasAttachmentDedupKey(draft: ComposerDraft, key: AttachmentDedupKe
|
||||
return Object.values(draft.attachmentDedupKeys).some((existing) => existing.hash === key.hash && existing.source === key.source);
|
||||
}
|
||||
|
||||
function fileKey(file: File): string {
|
||||
return `${file.name}:${file.type}:${file.size}:${file.lastModified}`;
|
||||
}
|
||||
|
||||
function clipboardFiles(data: DataTransfer): File[] {
|
||||
const files = Array.from(data.files);
|
||||
const seen = new Set(files.map(fileKey));
|
||||
for (const item of Array.from(data.items)) {
|
||||
if (item.kind !== "file") continue;
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
const key = fileKey(file);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
files.push(file);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function clipboardHasImageHint(data: DataTransfer): boolean {
|
||||
const imageType = (value: string) => {
|
||||
const type = value.toLowerCase();
|
||||
return type.startsWith("image/") || type.includes("png") || type.includes("jpeg") || type.includes("jpg") || type.includes("tiff");
|
||||
};
|
||||
return Array.from(data.items).some((item) => imageType(item.type)) || Array.from(data.types).some(imageType);
|
||||
}
|
||||
|
||||
function isPasteShortcut(e: KeyboardEvent<HTMLElement>): boolean {
|
||||
return e.key.toLowerCase() === "v" && (e.metaKey || e.ctrlKey) && !e.altKey;
|
||||
}
|
||||
|
||||
async function dataURLHash(dataUrl: string): Promise<string> {
|
||||
try {
|
||||
const res = await fetch(dataUrl);
|
||||
return sha256(await res.blob());
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function composerMaxHeight(): number {
|
||||
if (typeof window === "undefined") return COMPOSER_MAX_HEIGHT;
|
||||
return Math.max(COMPOSER_MIN_HEIGHT, Math.min(COMPOSER_MAX_HEIGHT, Math.floor(window.innerHeight * COMPOSER_MAX_VIEWPORT_RATIO)));
|
||||
@@ -833,6 +795,9 @@ export function Composer({
|
||||
);
|
||||
const guidanceReceiptTrackerRef = useRef<GuidanceReceiptTracker | null>(null);
|
||||
guidanceReceiptTrackerRef.current ??= createGuidanceReceiptTracker();
|
||||
// Messages held behind a transient target fence, keyed by follow-up key so a
|
||||
// dismissal or a second send can cancel the pending retry loop.
|
||||
const transientRetriesRef = useRef(new Map<string, { cancelled: boolean }>());
|
||||
const selfDispatchedGuidanceByDraftRef = useRef<Record<string, string[]>>({});
|
||||
const submittingRef = useRef<false | "message" | "compact">(false);
|
||||
const nativeClipboardPasteTimerRef = useRef<number | null>(null);
|
||||
@@ -2073,15 +2038,9 @@ export function Composer({
|
||||
showToast(text, "warn");
|
||||
}, [showToast, t]);
|
||||
|
||||
const followupDraftFingerprint = (key: string): string => {
|
||||
const draft = key === activeDraftKeyRef.current ? {
|
||||
text: textRef.current, invocations: invocationsRef.current, attachments: attachmentsRef.current,
|
||||
workspaceRefs: workspaceRefsRef.current, sessionRefs: sessionRefsRef.current,
|
||||
selectedTextRefs: selectedTextRefsRef.current, pastedBlocks: pastedBlocksRef.current,
|
||||
} : draftsBySessionRef.current[key] ?? emptyComposerDraft();
|
||||
return JSON.stringify([draft.text, draft.invocations, draft.attachments, draft.workspaceRefs,
|
||||
draft.sessionRefs, draft.selectedTextRefs, draft.pastedBlocks]);
|
||||
};
|
||||
const followupDraftFingerprint = (key: string): string => composerDraftFingerprint(
|
||||
key === activeDraftKeyRef.current ? snapshotComposerDraft() : draftsBySessionRef.current[key] ?? emptyComposerDraft(),
|
||||
);
|
||||
|
||||
const submit = (guideCurrent = false) => trackPersistentTask(activeDraftKeyRef.current, performSubmit(guideCurrent));
|
||||
const performSubmit = async (guideCurrent = false, modelChoice?:ModelApplicationChoice) => {
|
||||
@@ -2153,6 +2112,16 @@ export function Composer({
|
||||
let submissionAttachmentTarget: string | undefined;
|
||||
let attachmentSubmissionId: string | undefined;
|
||||
let attachmentSubmit: Awaited<ReturnType<typeof loadAttachmentSubmit>> | undefined;
|
||||
// Captured for the outer catch: a transient submit failure still holds the
|
||||
// exact message (display and submit text) the user typed.
|
||||
let submittedDisplayText = "";
|
||||
let submittedSubmitText = "";
|
||||
let submittedStructured: StructuredInvocationSubmit | undefined;
|
||||
const holdTransientGuidance = createTransientGuidance({
|
||||
app, transientRetriesRef, pendingKeyRef, submitPendingKey, submitDraftKey, submitTabId,
|
||||
inboxSessionPath, submittedDraft, queueOnly, followupDraftFingerprint, clearSubmittedDraft,
|
||||
setGuidanceRetryNonce, showToast, t, locale,
|
||||
});
|
||||
try {
|
||||
submissionCapture = onCaptureSubmit?.(persistentSnapshot(snapshotComposerDraft()));
|
||||
if (onCaptureSubmit && !submissionCapture) return;
|
||||
@@ -2162,8 +2131,7 @@ export function Composer({
|
||||
await restoreExternalFolderReferences(app, bridgeTarget, currentWorkspaceRefs);
|
||||
}
|
||||
if (queueOnly && !submitPendingKey) throw new Error("reasonix_error:inbox_not_submitted");
|
||||
const target = running && app.CaptureInboxTarget
|
||||
? await app.CaptureInboxTarget(submitTabId || "", inboxSessionPath || "") : undefined;
|
||||
const target = running ? await captureStableInboxTarget(app, submitTabId || "", inboxSessionPath || "") : undefined;
|
||||
const orderedAttachments = sortComposerAttachments(currentAttachments);
|
||||
const refs = [
|
||||
...currentWorkspaceRefs.map((ref) => formatWorkspaceReference(ref.path, ref.isDir)),
|
||||
@@ -2175,6 +2143,7 @@ export function Composer({
|
||||
...currentSelectedTextRefs.map(formatSelectionLabel),
|
||||
].join(" ");
|
||||
const displayText = [trimmedText, displayRefs].filter(Boolean).join(trimmedText && displayRefs ? " " : "");
|
||||
submittedDisplayText = displayText;
|
||||
// PR-B: when past:chats refs are attached, prepend their formatted transcript
|
||||
// to submitText only (displayText stays unchanged so the user still sees their
|
||||
// original prompt in the input preview). With no refs we keep the original
|
||||
@@ -2185,6 +2154,7 @@ export function Composer({
|
||||
const baseSubmitText = [expandPastedBlocks(invocationText, currentPastedBlocks), refs].filter(Boolean).join(" ");
|
||||
const submitBase = sessionContext ? `${sessionContext}${baseSubmitText}` : baseSubmitText;
|
||||
const submitText = [submitBase, selectedTextContext].filter(Boolean).join("\n\n");
|
||||
submittedSubmitText = submitText;
|
||||
const structuredInput = [expandPastedBlocks(trimmedText, currentPastedBlocks), refs].filter(Boolean).join(" ");
|
||||
let structured: StructuredInvocationSubmit | undefined = trimmedDraft.invocations.length > 0 ? {
|
||||
display: [invocationText, displayRefs].filter(Boolean).join(invocationText && displayRefs ? " " : ""),
|
||||
@@ -2199,6 +2169,7 @@ export function Composer({
|
||||
attachmentSubmissionId = prepared.submissionId;
|
||||
structured = prepared.structured;
|
||||
}
|
||||
submittedStructured = structured;
|
||||
// Repeated compaction asks the owner for its current operation receipt;
|
||||
// queueing it would unexpectedly start another compaction after this one.
|
||||
if (running && !(maintenanceActive && !structured && isCompactCommand(submitText))) {
|
||||
@@ -2265,6 +2236,10 @@ export function Composer({
|
||||
}
|
||||
if (queueOnly || receipt.disposition === "queued_followup") showToast(t("runtime.queued"), "info");
|
||||
} catch (error) {
|
||||
// A transient fence keeps the pending request and retries it
|
||||
// briefly: the message stays visibly pending instead of surfacing
|
||||
// an error the user cannot act on.
|
||||
if (holdTransientGuidance(error, { display: guidanceText, submit: guidanceSubmitText, structured, turnId })) return;
|
||||
if (followupNotSubmitted(error)) attachmentSubmit?.settleImageSubmission(submitDraftKey, attachmentSubmissionId);
|
||||
// Registration still needs reconciliation, but no inbox receipt exists before enqueue starts.
|
||||
if (unresolvedRequest && (!enqueueAttempted || followupNotSubmitted(error))) pendingFollowups.clear(submitPendingKey, unresolvedRequest);
|
||||
@@ -2285,6 +2260,33 @@ export function Composer({
|
||||
attachmentSubmit?.settleImageSubmission(submitDraftKey, attachmentSubmissionId);
|
||||
if (!persistentDraft && followupDraftFingerprint(submitDraftKey) === submittedDraft) clearSubmittedDraft(submitDraftKey);
|
||||
} catch (error) {
|
||||
// A submit that raced a session switch is refused by the serve's
|
||||
// expected-session fence. Hold it visibly and retry once the route
|
||||
// settles instead of reporting a failure the user cannot act on.
|
||||
if (holdTransientGuidance(error, {
|
||||
display: submittedDisplayText || trimmedText,
|
||||
submit: submittedSubmitText || trimmedText,
|
||||
structured: submittedStructured,
|
||||
})) return;
|
||||
// A busy-window submit was durably queued instead of starting a turn:
|
||||
// show the queue entry immediately and confirm the message left the
|
||||
// composer, rather than reporting a conflict.
|
||||
const queued = queuedFollowupOutcome(error);
|
||||
if (queued) {
|
||||
updatePendingGuidanceForDraft(submitDraftKey, (items) => items.some((item) => item.id === queued.itemId) ? items : [...items, {
|
||||
id: queued.itemId,
|
||||
text: trimmedText.slice(0, 120),
|
||||
submitText: "",
|
||||
intent: "followup",
|
||||
state: "queued",
|
||||
source: "desktop",
|
||||
paused: queued.paused,
|
||||
}]);
|
||||
if (ownsDraft() && followupDraftFingerprint(submitDraftKey) === submittedDraft) clearSubmittedDraft(submitDraftKey);
|
||||
setGuidanceRetryNonce((value) => value + 1);
|
||||
showToast(t("runtime.queued"), "info");
|
||||
return;
|
||||
}
|
||||
if (definitelyNotAccepted(error) || followupNotSubmitted(error)) attachmentSubmit?.settleImageSubmission(submitDraftKey, attachmentSubmissionId);
|
||||
if (savedInput.target && modelApplicationError(error)) savedInput.reportSubmissionError(error);
|
||||
else if (savedInput.target) showToast(formatInboxError(error, locale), "warn");
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { presentError } from "../lib/errorPresentation";
|
||||
import type { WireEvent } from "../lib/types";
|
||||
|
||||
/** Inline-safe error surface, including inside paragraphs and existing banners. */
|
||||
export function ErrorMessage({ error, summary, onInspect }: { error: unknown; summary?: string; onInspect?: () => void }) {
|
||||
export function ErrorMessage({ error, summary, diagnostic, onInspect }: { error: unknown; summary?: string; diagnostic?: WireEvent["diagnostic"]; onInspect?: () => void }) {
|
||||
const { t, locale } = useI18n();
|
||||
const id = useId();
|
||||
const [expandedError, setExpandedError] = useState<{ error: unknown } | null>(null);
|
||||
const presentation = presentError(error, t, locale);
|
||||
const presentation = presentError(error, t, locale, diagnostic);
|
||||
const expanded = expandedError !== null && expandedError.error === error;
|
||||
const title = summary || presentation.summary;
|
||||
const detail = error == null || error === "" ? "" : presentation.detail || (summary ? presentation.summary : "");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import type { GoalLifecycleView } from "../lib/types";
|
||||
|
||||
@@ -26,23 +27,65 @@ export function GoalLifecycleActions({
|
||||
|| (goalView?.phase === "active" && goalView.activation === "disarmed")
|
||||
|| (!goalView && goalStatus === "blocked");
|
||||
|
||||
const editGoal = () => {
|
||||
const [draft, setDraft] = useState<{ objective: string; rounds: string; invalid: boolean } | null>(null);
|
||||
|
||||
const editButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const wasOpen = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wasOpen.current && !draft) editButtonRef.current?.focus();
|
||||
wasOpen.current = draft !== null;
|
||||
}, [draft]);
|
||||
|
||||
const openEditor = () => {
|
||||
if (!goalView) return;
|
||||
const objective = window.prompt(t("composer.goalEditObjective"), goalView.objective);
|
||||
if (objective === null) return;
|
||||
const rawLimit = window.prompt(t("composer.goalEditMaxRounds"), goalView.maxGoalRounds?.toString() ?? "");
|
||||
if (rawLimit === null) return;
|
||||
const trimmedLimit = rawLimit.trim();
|
||||
const parsedLimit = trimmedLimit === "" ? null : Number(trimmedLimit);
|
||||
if (parsedLimit !== null && (!Number.isSafeInteger(parsedLimit) || parsedLimit <= 0)) {
|
||||
window.alert(t("composer.goalEditInvalidRounds"));
|
||||
setDraft({ objective: goalView.objective, rounds: goalView.maxGoalRounds?.toString() ?? "", invalid: false });
|
||||
};
|
||||
|
||||
const submitEdit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!draft) return;
|
||||
const trimmed = draft.rounds.trim();
|
||||
const limit = trimmed === "" ? null : Number(trimmed);
|
||||
if (limit !== null && (!Number.isSafeInteger(limit) || limit <= 0)) {
|
||||
setDraft({ ...draft, invalid: true });
|
||||
return;
|
||||
}
|
||||
onEditGoal(objective, parsedLimit);
|
||||
setDraft(null);
|
||||
onEditGoal(draft.objective, limit);
|
||||
};
|
||||
|
||||
return <>
|
||||
{goalView && <button type="button" className="composer-intent-menu__stop" onClick={editGoal} disabled={disabled}>
|
||||
{draft && (
|
||||
<form className="goal-edit-form" data-goal-edit-form="" onSubmit={submitEdit} onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); setDraft(null); } }}>
|
||||
<label>
|
||||
{t("composer.goalEditObjective")}
|
||||
<textarea
|
||||
className="remote-secret-dialog__input"
|
||||
rows={2}
|
||||
data-goal-edit-objective=""
|
||||
autoFocus
|
||||
value={draft.objective}
|
||||
onChange={(e) => setDraft({ ...draft, objective: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("composer.goalEditMaxRounds")}
|
||||
<input
|
||||
className="remote-secret-dialog__input"
|
||||
data-goal-edit-rounds=""
|
||||
inputMode="numeric"
|
||||
value={draft.rounds}
|
||||
onChange={(e) => setDraft({ ...draft, rounds: e.target.value, invalid: false })}
|
||||
/>
|
||||
</label>
|
||||
{draft.invalid && <p className="goal-edit-form__error" role="alert" data-goal-edit-error="">{t("composer.goalEditInvalidRounds")}</p>}
|
||||
<div className="goal-edit-form__actions">
|
||||
<button type="button" className="btn" onClick={() => setDraft(null)}>{t("common.cancel")}</button>
|
||||
<button type="submit" className="btn btn--primary" disabled={draft.objective.trim() === ""}>{t("common.save")}</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{goalView && <button ref={editButtonRef} type="button" className="composer-intent-menu__stop" onClick={openEditor} disabled={disabled || draft !== null}>
|
||||
{t("composer.taskModeEditGoal")}
|
||||
</button>}
|
||||
{resumable ? (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useT } from "../lib/i18n";
|
||||
import type { HistoricalImportStatus, HistoricalSessionView, HistoricalSourceUpdateView } from "../generated/desktopContract.generated";
|
||||
import type { SessionRef } from "../lib/sessionRef";
|
||||
import { desktopHost } from "../lib/desktopHost";
|
||||
import { useManagementT, type ManagementKey } from "../lib/managementLocale";
|
||||
import { historicalFailureKey, useManagementT, type ManagementKey } from "../lib/managementLocale";
|
||||
|
||||
const emptyHistoricalStatus: HistoricalImportStatus = { items: [], running: false, paused: false, remaining: 0, completed: 0, blocked: 0, failed: 0 };
|
||||
const historicalStatusKeys: Record<string, ManagementKey> = {
|
||||
@@ -97,7 +97,7 @@ export function HistoricalImportList({ active, onOpenSession }: { active: boolea
|
||||
<div className="archived-sessions__row" key={item.id}>
|
||||
<span>{item.title}</span><small>{item.format}</small>
|
||||
<span>{m(historicalStatusKeys[item.status] ?? "historicalNeedsAttention")}</span>
|
||||
{item.errorCode && <small>{m(item.errorCode === "source_busy" ? "historicalSourceBusy" : "historicalImportFailed")}</small>}
|
||||
{item.errorCode && item.errorCode !== "cancelled" && <small><ErrorMessage error={item.errorDetail ?? ""} summary={m(historicalFailureKey(item.errorCode))} /></small>}
|
||||
<button className="btn btn--small" disabled={!!busy || item.status === "importing" || item.status === "queued" || item.status === "archived" || item.status === "deleted"}
|
||||
onClick={() => void open(item)}>{item.session && onOpenSession ? t("history.openRestored") : m("historicalImportOpen")}</button>
|
||||
{item.session && item.source && <button className="btn btn--small" disabled={!!busy} onClick={() => void checkUpdate(item)}>{t("common.retry")} · {m("historicalTitle")}</button>}
|
||||
|
||||
@@ -128,6 +128,7 @@ export function ProjectTree({
|
||||
const compactTopics = variant === "workbench";
|
||||
const creationTopics = variant === "creation";
|
||||
const [tree, setTree] = useState<ProjectNode[]>([]);
|
||||
const [shellStage, setShellStage] = useState<"loading" | "slow" | "ready">("loading");
|
||||
const treeRef = useRef<ProjectNode[]>([]);
|
||||
const latestRevisionRef = useRef(0);
|
||||
const shellRequestRef = useRef(0);
|
||||
@@ -490,10 +491,11 @@ export function ProjectTree({
|
||||
})));
|
||||
await reloadRequestedProjects(projects);
|
||||
} catch (err) {
|
||||
// A shell snapshot is metadata-only. If it fails, the resident folder
|
||||
// identity can still drive the requested canonical topic reload.
|
||||
// A failed shell read can still reload topics from resident folders.
|
||||
if (request === shellRequestRef.current) await reloadRequestedProjects(treeRef.current);
|
||||
if (throwOnSnapshotError) throw err;
|
||||
} finally {
|
||||
if (request === shellRequestRef.current) setShellStage("ready");
|
||||
}
|
||||
}, [applyRuntimeProjection, reloadProjectTopicLists]);
|
||||
refreshRef.current = refresh;
|
||||
@@ -561,6 +563,10 @@ export function ProjectTree({
|
||||
return () => { shellRequestRef.current++; };
|
||||
}, [refresh, refreshSignal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shellStage !== "loading") return;
|
||||
const timer = setTimeout(() => setShellStage("slow"), 250); return () => clearTimeout(timer);
|
||||
}, [shellStage]);
|
||||
useEffect(() => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let latest: Parameters<Parameters<typeof onProjectTreeChangedV2>[0]>[0] | undefined;
|
||||
@@ -1018,7 +1024,7 @@ export function ProjectTree({
|
||||
}
|
||||
}, [onTopicsChanged, refresh, tree]);
|
||||
|
||||
const organization = useProjectTreeOrganization({ tree, refresh, onTopicsChanged, organizationRevision });
|
||||
const organization = useProjectTreeOrganization({ tree, refresh, onTopicsChanged, organizationRevision, sortMode: creationTopics ? "updated" : workbenchSortMode });
|
||||
|
||||
const clearProjectDrag = useCallback(() => {
|
||||
setDragProjectRoot(null);
|
||||
@@ -1588,7 +1594,7 @@ export function ProjectTree({
|
||||
label: t("projectTree.newGroup"),
|
||||
onSelect: () => organization.createGroup(node, t("projectTree.newGroup")),
|
||||
},
|
||||
newSessionMenuItem,
|
||||
newSessionMenuItem, ...organization.resetOrderMenuItems(node, t, closeMenu),
|
||||
...isolatedWorkspaceItems,
|
||||
{
|
||||
key: "rename",
|
||||
@@ -1642,7 +1648,7 @@ export function ProjectTree({
|
||||
: []),
|
||||
];
|
||||
const workbenchProjectMenuItems: ContextMenuItem[] = [
|
||||
newSessionMenuItem,
|
||||
newSessionMenuItem, ...organization.resetOrderMenuItems(node, t, closeMenu),
|
||||
...(scope === "project"
|
||||
? [
|
||||
{
|
||||
@@ -2010,6 +2016,11 @@ export function ProjectTree({
|
||||
);
|
||||
|
||||
const renderEmptyState = () => {
|
||||
if (shellStage !== "ready") return <div className="project-tree__empty-state" role="status"><div className="project-tree__empty project-tree__empty--subtle">{t("projectTree.loadingProjects")}</div>
|
||||
{shellStage === "slow" && <div className="project-tree__skeleton" aria-hidden="true">
|
||||
<span className="project-tree__skeleton-bar" /><span className="project-tree__skeleton-bar project-tree__skeleton-bar--short" />
|
||||
</div>}
|
||||
</div>;
|
||||
if (query.trim()) return <div className="project-tree__empty">{t("projectTree.emptyNoMatch")}</div>;
|
||||
return (
|
||||
<div className="project-tree__empty-state">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState, lazy, Suspense, type DragEvent, type HTMLAttributes, type ReactNode } from "react";
|
||||
import { Archive, FolderMinus, Pencil } from "lucide-react";
|
||||
import { Archive, Clock, FolderMinus, Pencil } from "lucide-react";
|
||||
import { app } from "../lib/bridge";
|
||||
import { asArray } from "../lib/array";
|
||||
import type { Translator } from "../lib/i18n";
|
||||
@@ -144,6 +144,7 @@ type TopicRowDragProps = Pick<HTMLAttributes<HTMLDivElement>, "draggable" | "onD
|
||||
|
||||
export interface ProjectTreeOrganizationController {
|
||||
orderFor?(folder: ProjectNode): readonly string[];
|
||||
resetOrderMenuItems(folder: ProjectNode, t: Translator, closeMenu: () => void): ContextMenuItem[];
|
||||
topicRow(node: ProjectNode, disabled: boolean): { className: string; props: TopicRowDragProps };
|
||||
topicMenuItems(node: ProjectNode, t: Translator): ContextMenuItem[];
|
||||
createGroup(folder: ProjectNode, title: string): void;
|
||||
@@ -161,15 +162,20 @@ export function useProjectTreeOrganization({
|
||||
refresh,
|
||||
onTopicsChanged,
|
||||
organizationRevision = 0,
|
||||
sortMode,
|
||||
bindings = app,
|
||||
}: {
|
||||
tree: ProjectNode[];
|
||||
refresh: ProjectTreeRefresh;
|
||||
onTopicsChanged?: () => Promise<void> | void;
|
||||
organizationRevision?: number;
|
||||
// The activity order on screen; a first manual move starts from it.
|
||||
sortMode?: string;
|
||||
bindings?: ProjectTreeOrganizationBindings;
|
||||
}): ProjectTreeOrganizationController {
|
||||
const { showToast } = useToast();
|
||||
const sortModeRef = useRef(sortMode);
|
||||
sortModeRef.current = sortMode;
|
||||
const [ordersByKey, setOrdersByKey] = useState<Record<string, string[]>>({});
|
||||
const [dragTopicID, setDragTopicID] = useState<string | null>(null);
|
||||
const [dropTopic, setDropTopic] = useState<{ topicID: string; position: ProjectDropPosition } | null>(null);
|
||||
@@ -313,9 +319,12 @@ export function useProjectTreeOrganization({
|
||||
const position = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
|
||||
const folder = tree.find(folder => projectTreeOrganizationKey(folder) === key);
|
||||
const dragged = folder?.children?.find(row => projectSessionIdentity(row) === draggedID);
|
||||
if (bindings.UpdateSessionOrganization && dragged) {
|
||||
const targetGroup = (groupsRef.current[key] ?? []).find((group) => projectTreeGroupContainsNode(group, node));
|
||||
if (dragged && targetGroup && !projectTreeGroupContainsNode(targetGroup, dragged)) {
|
||||
mutateGroups(key, (groups) => moveNodeToGroup(groups, dragged, targetGroup.id), { kind: "set-group", target: projectNodeSelector(dragged), groupId: targetGroup.id });
|
||||
} else if (bindings.UpdateSessionOrganization && dragged) {
|
||||
void mutateSessionOrganization(bindings, { scope: context.scope, workspaceRoot: context.root, hostId: splitOrganizationKey(key).hostId },
|
||||
{ kind: "move", target: projectNodeSelector(dragged), anchor: projectNodeSelector(node), position })
|
||||
{ kind: "move", target: projectNodeSelector(dragged), anchor: projectNodeSelector(node), position, sortMode: sortModeRef.current })
|
||||
.then(saved => { setOrdersByKey(current => ({ ...current, [key]: saved.order })); return refresh({ reloadAllTopics: true }); })
|
||||
.then(() => onTopicsChanged?.()).catch(error => { showToast(String(error), "error"); loadGroups(key, true); return refresh({ reloadAllTopics: true }); });
|
||||
} else {
|
||||
@@ -326,7 +335,7 @@ export function useProjectTreeOrganization({
|
||||
};
|
||||
props.onDragEnd = clearTopicDrag;
|
||||
return { className, props };
|
||||
}, [bindings, clearTopicDrag, dragTopicID, dropTopic, loadGroups, onTopicsChanged, refresh, showToast, tree]);
|
||||
}, [bindings, clearTopicDrag, dragTopicID, dropTopic, loadGroups, mutateGroups, onTopicsChanged, refresh, showToast, tree]);
|
||||
|
||||
const removeTopicFromGroups = useCallback((node: ProjectNode) => {
|
||||
const key = projectTreeOrganizationKey(node);
|
||||
@@ -340,6 +349,13 @@ export function useProjectTreeOrganization({
|
||||
|
||||
return {
|
||||
orderFor(folder) { return ordersByKey[projectTreeOrganizationKey(folder)] ?? []; },
|
||||
resetOrderMenuItems(folder, t, closeMenu) {
|
||||
const key = projectTreeOrganizationKey(folder);
|
||||
return (ordersByKey[key]?.length ?? 0) > 0 ? [{
|
||||
key: "reset-manual-order", icon: <Clock size={13} />, label: t("projectTree.resetManualOrder"),
|
||||
onSelect: () => { closeMenu(); mutateGroups(key, (groups) => groups, { kind: "reset-order" }); },
|
||||
}] : [];
|
||||
},
|
||||
topicRow,
|
||||
topicMenuItems(node, t) {
|
||||
if (!(groupsRef.current[projectTreeOrganizationKey(node)] ?? []).some((group) => projectTreeGroupContainsNode(group, node))) return [];
|
||||
|
||||
@@ -45,7 +45,9 @@ export function RemoteSessionSurface({ tab, session, surfaceCommitToken, onSurfa
|
||||
const visiblePrompt = approval || ask || mcpInteraction;
|
||||
const promptUpgradeRequired = Boolean(visiblePrompt && !tab.interactionTargetSupported);
|
||||
const promptIdentityUnavailable = Boolean(visiblePrompt && tab.interactionTargetSupported && (
|
||||
!tab.remote?.hostId || !tab.sessionId || !hasSessionGeneration(tab.sessionGeneration) || !visiblePrompt.turnId || !visiblePrompt.runtimeEpoch
|
||||
// runtimeEpoch stays optional: a serve fences on it only when it has one,
|
||||
// and a CLI serve never binds desktop routing metadata.
|
||||
!tab.remote?.hostId || !tab.sessionId || !hasSessionGeneration(tab.sessionGeneration) || !visiblePrompt.turnId
|
||||
));
|
||||
const promptActionDisabled = promptUpgradeRequired || promptIdentityUnavailable;
|
||||
const formUpgradeRequired = Boolean(extensionForm && !tab.extensionFormInstanceSupported);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { app } from "../lib/bridge";
|
||||
import type { SessionTakeoverView, TabMeta } from "../lib/types";
|
||||
import type { HistoricalSourceUpdateView, SessionPreparationView } from "../generated/desktopContract.generated";
|
||||
import { historicalPreparationSnapshot, reconcileHistoricalPreparation, subscribeHistoricalPreparation, type DesktopNavigationIntent } from "../app-runtime/desktopNavigationOwner";
|
||||
import { useManagementT } from "../lib/managementLocale";
|
||||
import { historicalFailureKey, useManagementT } from "../lib/managementLocale";
|
||||
|
||||
/**
|
||||
* SessionTakeoverDialog confirms taking a lease-blocked session over from the
|
||||
@@ -132,15 +132,17 @@ export type HistoricalSessionBannerProps = {
|
||||
tab?: TabMeta;
|
||||
navigate(intent: DesktopNavigationIntent): Promise<void>;
|
||||
captureNavigation?(): () => boolean;
|
||||
openRecoveryDetails?(): void;
|
||||
};
|
||||
export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: HistoricalSessionBannerProps) {
|
||||
type HistoricalFailure = { summary: string; detail?: unknown };
|
||||
export function HistoricalSessionBanners({ tab, navigate, captureNavigation, openRecoveryDetails }: HistoricalSessionBannerProps) {
|
||||
const t = useT();
|
||||
const m = useManagementT();
|
||||
const activeRef = tab?.session ?? (tab?.sessionId ? { hostId: "local", sessionId: tab.sessionId } : undefined);
|
||||
const preparation = useSyncExternalStore(subscribeHistoricalPreparation, historicalPreparationSnapshot);
|
||||
const [update, setUpdate] = useState<HistoricalSourceUpdateView | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [updateError, setUpdateError] = useState("");
|
||||
const [updateError, setUpdateError] = useState<HistoricalFailure | null>(null);
|
||||
const updateOperation = useRef(0);
|
||||
const [cancellingOperationId, setCancellingOperationId] = useState("");
|
||||
const cancellingOperationRef = useRef("");
|
||||
@@ -158,7 +160,7 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
|
||||
let current = true;
|
||||
updateOperation.current++;
|
||||
setUpdate(null);
|
||||
setUpdateError("");
|
||||
setUpdateError(null);
|
||||
setBusy(false);
|
||||
if (!activeSessionId || activeHostId !== "local" || !app.CheckHistoricalSourceUpdate) return () => { current = false; };
|
||||
const ref = { hostId: activeHostId, sessionId: activeSessionId };
|
||||
@@ -200,7 +202,7 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
|
||||
const navigationCurrent = captureNavigation?.() ?? (() => activeKeyRef.current === expectedActive);
|
||||
const current = () => mounted.current && operation === updateOperation.current && activeKeyRef.current === expectedActive && navigationCurrent();
|
||||
setBusy(true);
|
||||
setUpdateError("");
|
||||
setUpdateError(null);
|
||||
try {
|
||||
let view = await start();
|
||||
while (current() && !terminalPreparation.has(view.status)) {
|
||||
@@ -209,13 +211,17 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
|
||||
view = await app.GetSessionPreparation(view.operationId);
|
||||
}
|
||||
if (!current()) return;
|
||||
if (view.status === "ready" && view.target) {
|
||||
await navigate({ kind: "canonical-session", ref: view.target });
|
||||
} else {
|
||||
setUpdateError(m(view.errorCode === "source_busy" ? "historicalSourceBusy" : "historicalImportFailed"));
|
||||
if (view.status !== "ready" || !view.target) {
|
||||
setUpdateError({ summary: m(historicalFailureKey(view.errorCode)), detail: view.errorDetail });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
if (current()) setUpdateError(m("historicalImportFailed"));
|
||||
try {
|
||||
await navigate({ kind: "canonical-session", ref: view.target });
|
||||
} catch (error) {
|
||||
if (current()) setUpdateError({ summary: m("historicalOpenFailed"), detail: error });
|
||||
}
|
||||
} catch (error) {
|
||||
if (current()) setUpdateError({ summary: m("historicalImportFailed"), detail: error });
|
||||
}
|
||||
finally { if (mounted.current && operation === updateOperation.current) setBusy(false); }
|
||||
};
|
||||
@@ -234,27 +240,34 @@ export function HistoricalSessionBanners({ tab, navigate, captureNavigation }: H
|
||||
}
|
||||
};
|
||||
|
||||
const failureNotice = (failure: HistoricalFailure) => <ErrorMessage error={failure.detail ?? ""} summary={failure.summary} />;
|
||||
const recoveryDetails = openRecoveryDetails
|
||||
? <button type="button" className="btn btn--small btn--ghost" onClick={openRecoveryDetails}>{m("historicalRecoveryDetails")}</button> : null;
|
||||
if (preparation) {
|
||||
const waiting = preparation.status === "queued" || preparation.status === "preparing";
|
||||
return <div className={`banner ${waiting ? "banner--warning" : "banner--error"} banner--actionable`} role="status">
|
||||
<span className="banner__msg">{m("historicalImporting")}: {preparation.session.title || preparation.session.topicId || m("historicalTitle")}</span>
|
||||
<span className="banner__hint">{m(preparation.status === "queued" ? "historicalQueued" : preparation.status === "preparing" ? "historicalImporting" : "historicalImportFailed")}</span>
|
||||
<span className="banner__hint">{waiting ? m(preparation.status === "queued" ? "historicalQueued" : "historicalImporting")
|
||||
: failureNotice({ summary: m(historicalFailureKey(preparation.errorCode)), detail: preparation.errorDetail })}</span>
|
||||
<span className="banner__spacer" />
|
||||
{waiting && <button type="button" className="btn btn--small" disabled={cancellingOperationId === preparation.operationId} onClick={() => void cancelPreparation()}>{t("common.cancel")}</button>}
|
||||
{!waiting && recoveryDetails}
|
||||
{!waiting && preparation.retryable && <button type="button" className="btn btn--small" onClick={() => void navigate({ kind: "resume-session", session: preparation.session })}>{t("common.retry")}</button>}
|
||||
</div>;
|
||||
}
|
||||
if (historicalSource) return <div className={`banner ${updateError ? "banner--error" : "banner--warning"} banner--actionable`} role={updateError ? "alert" : "status"}>
|
||||
<span className="banner__msg">{tab?.topicTitle || m("historicalTitle")} · {m("historicalAvailable")}</span>
|
||||
<span className="banner__hint">{updateError ? <ErrorMessage error={updateError} /> : m(busy ? "historicalImporting" : "historicalImportToSend")}</span>
|
||||
<span className="banner__hint">{updateError ? failureNotice(updateError) : m(busy ? "historicalImporting" : "historicalImportToSend")}</span>
|
||||
<span className="banner__spacer" />
|
||||
{updateError && recoveryDetails}
|
||||
<button id="reasonix-prepare-restored-session" type="button" className="btn btn--small" disabled={busy} onClick={() => void importSource()}>{m("historicalImportOpen")}</button>
|
||||
</div>;
|
||||
if (!update) return null;
|
||||
return <div className={`banner ${updateError ? "banner--error" : "banner--warning"} banner--actionable`} role={updateError ? "alert" : "status"}>
|
||||
<span className="banner__msg">{m("historicalSourceUpdated")}</span>
|
||||
{(updateError || busy) && <span className="banner__hint">{updateError ? <ErrorMessage error={updateError} /> : m("historicalImporting")}</span>}
|
||||
{(updateError || busy) && <span className="banner__hint">{updateError ? failureNotice(updateError) : m("historicalImporting")}</span>}
|
||||
<span className="banner__spacer" />
|
||||
{updateError && recoveryDetails}
|
||||
<button type="button" className="btn btn--small" disabled={busy} onClick={() => void importUpdate()}>{m("historicalImportOpen")} · {m("branch")}</button>
|
||||
<button type="button" className="btn btn--small" disabled={busy} onClick={dismissUpdate}>{t("updater.dismiss")}</button>
|
||||
</div>;
|
||||
|
||||
@@ -6652,6 +6652,19 @@ function HooksSection({ onChanged }: { onChanged: (settings?: SettingsView | nul
|
||||
setPathMessage(t("settings.hooksJsonClipboardUnavailable"));
|
||||
}
|
||||
};
|
||||
const approve = async () => {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await app.TrustProjectHooksForRoot(view?.projectRoot?.trim() ?? "");
|
||||
await load(scope);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setErr(String((e as Error)?.message ?? e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
@@ -6694,6 +6707,12 @@ function HooksSection({ onChanged }: { onChanged: (settings?: SettingsView | nul
|
||||
title={t("settings.hooks")}
|
||||
description={scope === "project" ? t("settings.hooksProjectHint") : t("settings.hooksGlobalHint")}
|
||||
>
|
||||
{view && scope === "project" && !view.trusted && view.hooks.length > 0 && (
|
||||
<div className="banner banner--warning" role="status">
|
||||
<span>{t("settings.hooksAwaitingApproval")}</span>
|
||||
<button className="btn btn--small" disabled={busy} onClick={() => void approve()}>{t("settings.hooksApprove")}</button>
|
||||
</div>
|
||||
)}
|
||||
{view && (
|
||||
<div className="hooks-json-panel">
|
||||
<div className="hooks-json-panel__head">
|
||||
|
||||
@@ -239,6 +239,23 @@ function ChatSession(props: TranscriptProps & { sessionKey: string }) {
|
||||
if (result === "loaded" && current()) scroll.toBottom();
|
||||
} finally { release(); }
|
||||
};
|
||||
const newerArmed = useRef(true);
|
||||
const autoNewer = useRef<() => void>(() => undefined);
|
||||
autoNewer.current = () => {
|
||||
if (!onLoadNewerHistory || selectionInsideTranscript()) return;
|
||||
newerArmed.current = false;
|
||||
const intent = ++navigationIntent.current;
|
||||
const generation = lifetime.current;
|
||||
let reading = false;
|
||||
const release = scroll.subscribeReaderIntent(() => { reading = true; });
|
||||
const current = () => !reading && intent === navigationIntent.current && generation === lifetime.current && !selectionRef.current();
|
||||
scroll.stopFollowing();
|
||||
void loadPage("newer", "auto-fill", current).finally(release);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!hasNewerHistory || !position.following) { newerArmed.current = true; return; }
|
||||
if (newerArmed.current && !hydrating && !loadingNewerHistory && !newerHistoryError) autoNewer.current();
|
||||
}, [hasNewerHistory, position.following, hydrating, loadingNewerHistory, newerHistoryError]);
|
||||
useEffect(() => () => jump.dispose(), [jump]);
|
||||
useEffect(() => desktopHost().native.onServiceState(state => {
|
||||
if (state.phase === "stopping" || state.phase === "exited") {
|
||||
|
||||
@@ -48,3 +48,8 @@ export function persistentSnapshot(draft: ComposerDraft): PersistentComposerDraf
|
||||
selectedTextRefs: draft.selectedTextRefs,
|
||||
};
|
||||
}
|
||||
|
||||
export function composerDraftFingerprint(draft: ComposerDraft): string {
|
||||
return JSON.stringify([draft.text, draft.invocations, draft.attachments, draft.workspaceRefs,
|
||||
draft.sessionRefs, draft.selectedTextRefs, draft.pastedBlocks]);
|
||||
}
|
||||
@@ -19921,7 +19921,7 @@
|
||||
},
|
||||
{
|
||||
"cancellation": "before-dispatch",
|
||||
"domain": "desktop/remote_tab.go",
|
||||
"domain": "desktop/remote_submit.go",
|
||||
"name": "SubmitRemoteTab",
|
||||
"owner": "App.SubmitRemoteTab",
|
||||
"params": [
|
||||
@@ -19942,7 +19942,7 @@
|
||||
"resolver": "App.SubmitRemoteTab"
|
||||
},
|
||||
"sources": [
|
||||
"desktop/remote_tab.go"
|
||||
"desktop/remote_submit.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -20055,7 +20055,7 @@
|
||||
},
|
||||
{
|
||||
"cancellation": "before-dispatch",
|
||||
"domain": "desktop/remote_tab.go",
|
||||
"domain": "desktop/remote_submit.go",
|
||||
"name": "SubmitRemoteTabWithSubmission",
|
||||
"owner": "App.SubmitRemoteTabWithSubmission",
|
||||
"params": [
|
||||
@@ -20080,7 +20080,7 @@
|
||||
"resolver": "App.SubmitRemoteTabWithSubmission"
|
||||
},
|
||||
"sources": [
|
||||
"desktop/remote_tab.go"
|
||||
"desktop/remote_submit.go"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -31177,6 +31177,13 @@
|
||||
"kind": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "errorDetail",
|
||||
"optional": true,
|
||||
"type": {
|
||||
"kind": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "session",
|
||||
"optional": true,
|
||||
@@ -40676,6 +40683,13 @@
|
||||
"type": {
|
||||
"kind": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sortMode",
|
||||
"optional": true,
|
||||
"type": {
|
||||
"kind": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -40788,6 +40802,13 @@
|
||||
"kind": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "errorDetail",
|
||||
"optional": true,
|
||||
"type": {
|
||||
"kind": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "retryable",
|
||||
"type": {
|
||||
@@ -45032,6 +45053,13 @@
|
||||
"type": {
|
||||
"kind": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "transportCode",
|
||||
"optional": true,
|
||||
"type": {
|
||||
"kind": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
export const DESKTOP_PROTOCOL_VERSION = 11;
|
||||
|
||||
export const DESKTOP_CONTRACT_DIGEST = "sha256:857039e0868d57e1e367b080f009abd2d21baa856ae5d57e55fc8c3b1cef5734";
|
||||
export const DESKTOP_CONTRACT_DIGEST = "sha256:22737aab34fb4b66050efd11ac4b4ac83623d62cf9e5c870b81e65109fde7398";
|
||||
|
||||
export const DESKTOP_COMMANDS = [
|
||||
"AIRenameSession",
|
||||
@@ -2595,6 +2595,7 @@ export interface HistoricalSessionView {
|
||||
format: string;
|
||||
status: string;
|
||||
errorCode?: string;
|
||||
errorDetail?: string;
|
||||
session?: SessionRef | null;
|
||||
source?: SessionSourceRef | null;
|
||||
}
|
||||
@@ -4370,6 +4371,7 @@ export interface SessionOrganizationMutation {
|
||||
position?: string;
|
||||
groupId?: string;
|
||||
title?: string;
|
||||
sortMode?: string;
|
||||
}
|
||||
|
||||
export interface SessionOrganizationSnapshot {
|
||||
@@ -4393,6 +4395,7 @@ export interface SessionPreparationView {
|
||||
revision: number;
|
||||
target?: SessionRef | null;
|
||||
errorCode?: string;
|
||||
errorDetail?: string;
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
@@ -5202,6 +5205,7 @@ export interface FailureDiagnostic {
|
||||
providerDisplayName?: string;
|
||||
protocol?: string;
|
||||
requestPath?: string;
|
||||
transportCode?: string;
|
||||
}
|
||||
|
||||
export interface MCPAppPresentation {
|
||||
|
||||
@@ -4672,10 +4672,10 @@ function makeMockApp(): MockAppBindings {
|
||||
hookSettings[key].hooks = JSON.parse(JSON.stringify(hooks)) as HookConfigView[];
|
||||
},
|
||||
async TrustProjectHooks() {
|
||||
// Compatibility no-op: project hooks are enabled automatically.
|
||||
hookSettings.project.trusted = true;
|
||||
},
|
||||
async TrustProjectHooksForRoot(_projectRoot: string) {
|
||||
// Compatibility no-op: project hooks are enabled automatically.
|
||||
hookSettings.project.trusted = true;
|
||||
},
|
||||
async SetDefaultModel(ref: string) {
|
||||
settings.defaultModel = ref;
|
||||
|
||||
@@ -36,7 +36,8 @@ const sameReferences = (a: readonly unknown[] = [], b: readonly unknown[] = [])
|
||||
a.length === b.length && a.every((item, index) => item === b[index]);
|
||||
const shallowSame = (a: object, b: object) => Object.keys(a).length === Object.keys(b).length
|
||||
&& Object.entries(a).every(([key, value]) => value === (b as Record<string, unknown>)[key]);
|
||||
const foldViews = new Map<string, Set<string>>();
|
||||
const HEAD_KEY = "history-head";
|
||||
const foldViews = new Map<string, Map<string, boolean>>();
|
||||
const emptyChildren: readonly Extract<ChatNode, { kind: "tool" }>[] = [];
|
||||
function proxyAuditCall(item: Item): string | undefined {
|
||||
if (item.kind !== "notice" || item.code !== "capability_proxy_audit") return undefined;
|
||||
@@ -109,11 +110,12 @@ export class ChatSource implements ChatViewSource {
|
||||
private epoch = 0;
|
||||
private input?: ChatInput;
|
||||
private status: ChatStatus = { running: false, hydrating: true, hasOlder: false, loadingOlder: false };
|
||||
private opened = new Set<string>();
|
||||
private choices = new Map<string, boolean>();
|
||||
private headAnchor?: string;
|
||||
private displayKeyBySubmission = new Map<string, string>();
|
||||
private displayMessageBySubmission = new Map<string, string>();
|
||||
private displayKeyByMessage = new Map<string, string>();
|
||||
constructor(readonly sessionKey: string) { this.opened = new Set(foldViews.get(sessionKey)); }
|
||||
constructor(readonly sessionKey: string) { this.choices = new Map(foldViews.get(sessionKey)); }
|
||||
getOrderSnapshot = () => this.order;
|
||||
getNodeSnapshot = (key: string) => this.nodes.get(key);
|
||||
getStatusSnapshot = () => this.status;
|
||||
@@ -157,7 +159,7 @@ export class ChatSource implements ChatViewSource {
|
||||
const order: string[] = [];
|
||||
const present = new Set<string>();
|
||||
const groups: Array<{ key: string; turn?: number; user?: Extract<Item, { kind: "user" }>; items: Item[] }> = [];
|
||||
let group: (typeof groups)[number] = { key: "history-head", items: [] };
|
||||
let group: (typeof groups)[number] = { key: HEAD_KEY, items: [] };
|
||||
groups.push(group);
|
||||
for (const { item, local } of itemsWithLocalSubmissions(input)) {
|
||||
if (item.kind === "user") {
|
||||
@@ -167,6 +169,7 @@ export class ChatSource implements ChatViewSource {
|
||||
} else group.items.push(item);
|
||||
}
|
||||
const groupKeys = new Set(groups.map(current => current.key));
|
||||
this.adoptHeadChoice(groups);
|
||||
for (const current of groups) {
|
||||
if (!current.user && !current.items.length) continue;
|
||||
const turnKey = current.key;
|
||||
@@ -207,11 +210,13 @@ export class ChatSource implements ChatViewSource {
|
||||
const hasProcess = current.items.some(item => item.kind === "tool" || item.kind === "phase" ||
|
||||
item.kind === "assistant" && (item !== answer && item.text.trim() || item.reasoning.trim()));
|
||||
const hasTrailingWork = current.items.slice(answerIndex + 1).some(item => item.kind === "tool" || item.kind === "phase" || item.kind === "assistant");
|
||||
const foldable = Boolean(hasProcess && current.user && answer && !hasTrailingWork && !active && !failed && !current.user.failed);
|
||||
// Every ended turn can fold by hand; only a settled one starts folded.
|
||||
const settled = Boolean(answer && !hasTrailingWork && !failed && !current.user?.failed);
|
||||
const foldable = Boolean(hasProcess && !active);
|
||||
const allCalls = current.items.filter((item): item is Extract<Item, { kind: "tool" }> => item.kind === "tool");
|
||||
const calls = allCalls.filter(item => !item.parentId);
|
||||
const subagentCount = calls.filter(item => ["task", "read_only_task", "parallel_tasks", "fleet", "subagent"].includes(item.name)).length;
|
||||
add({ kind: "process", key: processKey, turnKey, members: stableMembers, foldable, collapsed: foldable && !this.opened.has(turnKey),
|
||||
add({ kind: "process", key: processKey, turnKey, members: stableMembers, foldable, collapsed: foldable && !(this.choices.get(turnKey) ?? !settled),
|
||||
toolCallCount: calls.length - subagentCount, subagentCount,
|
||||
messageCount: current.items.filter(item => item.kind === "assistant" && item !== answer && item.text.trim()).length,
|
||||
failureCount: calls.filter(item => item.status === "error" || item.error).length });
|
||||
@@ -264,10 +269,20 @@ export class ChatSource implements ChatViewSource {
|
||||
this.children.set(key, next); this.dirty.add(`${key}:children`);
|
||||
}
|
||||
}
|
||||
for (const key of this.opened) if (!groupKeys.has(key)) this.opened.delete(key);
|
||||
for (const key of this.choices.keys()) if (!groupKeys.has(key)) this.choices.delete(key);
|
||||
if (!sameKeys(this.order, order)) { this.order = order; this.orderDirty = true; }
|
||||
recordFrontendDiagnostic("transcript", "presentation", this.presentationStats());
|
||||
}
|
||||
// The head group is a turn whose user message sits on an older page; once
|
||||
// that page loads the same items sit under the user's key.
|
||||
private adoptHeadChoice(groups: ReadonlyArray<{ key: string; user?: unknown; items: readonly Item[] }>) {
|
||||
const head = groups[0];
|
||||
const anchor = this.headAnchor;
|
||||
this.headAnchor = head.key === HEAD_KEY ? head.items[0]?.id : undefined;
|
||||
if (!anchor || !this.choices.has(HEAD_KEY) || (head.key === HEAD_KEY && head.items[0]?.id === anchor)) return;
|
||||
const next = groups.find(current => current.user && current.items.some(item => item.id === anchor));
|
||||
if (next) this.choices.set(next.key, this.choices.get(HEAD_KEY)!);
|
||||
}
|
||||
private userDisplayKey(item: Extract<Item, { kind: "user" }>, local?: LocalSubmission, handoffs?: ChatInput["visibleSubmissionHandoffs"]): string {
|
||||
if (local) {
|
||||
const key = `submission:${local.submissionId}`;
|
||||
@@ -308,10 +323,12 @@ export class ChatSource implements ChatViewSource {
|
||||
toggleProcess(turnKey: string) {
|
||||
const node = this.nodes.get(`${turnKey}:process`);
|
||||
if (node?.kind !== "process") return;
|
||||
if (node.collapsed) this.opened.add(turnKey); else this.opened.delete(turnKey);
|
||||
foldViews.delete(this.sessionKey); foldViews.set(this.sessionKey, new Set(this.opened));
|
||||
if (!node.foldable) return;
|
||||
const open = node.collapsed;
|
||||
this.choices.set(turnKey, open);
|
||||
foldViews.delete(this.sessionKey); foldViews.set(this.sessionKey, new Map(this.choices));
|
||||
if (foldViews.size > 100) foldViews.delete(foldViews.keys().next().value!);
|
||||
this.put({ ...node, collapsed: node.foldable && !this.opened.has(turnKey) });
|
||||
this.put({ ...node, collapsed: !open });
|
||||
this.flush();
|
||||
}
|
||||
toolChildren(id: string) { return this.children.get(id) ?? emptyChildren; }
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { KeyboardEvent } from "react";
|
||||
import { sha256 } from "./attachDedup";
|
||||
|
||||
function fileKey(file: File): string {
|
||||
return `${file.name}:${file.type}:${file.size}:${file.lastModified}`;
|
||||
}
|
||||
|
||||
export function clipboardFiles(data: DataTransfer): File[] {
|
||||
const files = Array.from(data.files);
|
||||
const seen = new Set(files.map(fileKey));
|
||||
for (const item of Array.from(data.items)) {
|
||||
if (item.kind !== "file") continue;
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
const key = fileKey(file);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
files.push(file);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
export function clipboardHasImageHint(data: DataTransfer): boolean {
|
||||
const imageType = (value: string) => {
|
||||
const type = value.toLowerCase();
|
||||
return type.startsWith("image/") || type.includes("png") || type.includes("jpeg") || type.includes("jpg") || type.includes("tiff");
|
||||
};
|
||||
return Array.from(data.items).some((item) => imageType(item.type)) || Array.from(data.types).some(imageType);
|
||||
}
|
||||
|
||||
export function isPasteShortcut(e: KeyboardEvent<HTMLElement>): boolean {
|
||||
return e.key.toLowerCase() === "v" && (e.metaKey || e.ctrlKey) && !e.altKey;
|
||||
}
|
||||
|
||||
export async function dataURLHash(dataUrl: string): Promise<string> {
|
||||
try {
|
||||
const res = await fetch(dataUrl);
|
||||
return sha256(await res.blob());
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import type { HistoryMessage, WireFinalReadiness, WireDecisionReceipt } from "./
|
||||
import type { Item } from "./useController";
|
||||
import { readPauseItem } from "./readPause";
|
||||
|
||||
export function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string, detail?: string, code?: string, decisionReceipt?: WireDecisionReceipt): { items: Item[]; seq: number } {
|
||||
export function appendNoticeItem(items: Item[], seq: number, id: string, level: "info" | "warn", rawText: string, detail?: string, code?: string, decisionReceipt?: WireDecisionReceipt, diagnostic?: HistoryMessage["diagnostic"]): { items: Item[]; seq: number } {
|
||||
if (quietTranscriptNoticeKey(rawText, code)) return { items, seq };
|
||||
const text = localizedNoticeText(rawText, code);
|
||||
if (quietTranscriptNoticeKey(text, code)) return { items, seq };
|
||||
const trimmedDetail = detail?.trim();
|
||||
return { items: [...items, { kind: "notice", id, level, text, ...(trimmedDetail ? { detail: trimmedDetail } : {}), ...(code ? { code } : {}), ...(decisionReceipt ? { decisionReceipt } : {}) }], seq: seq + 1 };
|
||||
return { items: [...items, { kind: "notice", id, level, text, ...(diagnostic ? { diagnostic } : {}), ...(trimmedDetail ? { detail: trimmedDetail } : {}), ...(code ? { code } : {}), ...(decisionReceipt ? { decisionReceipt } : {}) }], seq: seq + 1 };
|
||||
}
|
||||
|
||||
export function errorMessage(err: unknown): string {
|
||||
@@ -190,5 +190,5 @@ export function historyNoticeItems(m: HistoryMessage, id: string): Item[] {
|
||||
if (quietTranscriptNoticeKey(text, m.code)) return [];
|
||||
const detail = m.detail?.trim();
|
||||
return [{ kind: "notice", id, level: m.level === "warn" ? "warn" : "info", text,
|
||||
...(detail ? { detail } : {}), ...(m.decisionReceipt ? { decisionReceipt: m.decisionReceipt } : {}) }];
|
||||
...(m.diagnostic ? { diagnostic: m.diagnostic } : {}), ...(detail ? { detail } : {}), ...(m.decisionReceipt ? { decisionReceipt: m.decisionReceipt } : {}) }];
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { t, getLocale, type DictKey, type Translator, type Locale } from "./i18n";
|
||||
import type { WireEvent } from "./types";
|
||||
|
||||
export interface ErrorPresentation { summary: string; detail: string }
|
||||
|
||||
@@ -24,6 +25,7 @@ const codeKeys: Record<string, DictKey> = {
|
||||
invalid_api_key: "error.auth", insufficient_quota: "error.quota", rate_limit_exceeded: "error.rateLimit",
|
||||
stream_interrupted: "error.interrupted", empty_response: "error.empty", stale_generation: "error.conflict",
|
||||
provider_connection: "error.connection", cancelled: "error.cancelled",
|
||||
transport_protocol: "error.transportProtocol",
|
||||
};
|
||||
|
||||
// Narrow legacy matches bridge errors from older services and OS libraries.
|
||||
@@ -61,8 +63,11 @@ function statusKey(status: number): DictKey | undefined {
|
||||
if (status === 400 || status === 422) return "error.invalidInput";
|
||||
}
|
||||
|
||||
export function presentError(error: unknown, translate: Translator = t, locale: Locale = getLocale()): ErrorPresentation {
|
||||
export function presentError(error: unknown, translate: Translator = t, locale: Locale = getLocale(), diagnostic?: WireEvent["diagnostic"]): ErrorPresentation {
|
||||
const detail = errorDetail(error).trim();
|
||||
if (diagnostic?.kind === "transport_protocol") {
|
||||
return { summary: translate("error.transportProtocol"), detail: [detail, diagnostic.transportCode ? `HTTP/2: ${diagnostic.transportCode}` : ""].filter(Boolean).join("\n") };
|
||||
}
|
||||
const structured = error && typeof error === "object" ? error as { code?: unknown; status?: unknown } : undefined;
|
||||
const code = typeof structured?.code === "string" ? structured.code : detail;
|
||||
const explicitStatus = typeof structured?.status === "number" ? structured.status : 0;
|
||||
|
||||
@@ -102,7 +102,7 @@ export function historyMessagesToItems(messages: HistoryMessage[], idPrefix: str
|
||||
continue;
|
||||
}
|
||||
if (m.content.trim() !== "" || m.decisionReceipt) {
|
||||
const next = appendNoticeItem(items, seq, recordItemId, m.level === "warn" ? "warn" : "info", m.content, m.detail, m.code, m.decisionReceipt);
|
||||
const next = appendNoticeItem(items, seq, recordItemId, m.level === "warn" ? "warn" : "info", m.content, m.detail, m.code, m.decisionReceipt, m.diagnostic);
|
||||
items = next.items;
|
||||
seq = next.seq;
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export async function loadHistoryWindow(input: LoadInput): Promise<HistoryWindow
|
||||
return "empty";
|
||||
}
|
||||
if (!result) { input.dispatch({ type: "history_older_error", error: "history page unavailable" }); return "empty"; }
|
||||
if (!fingerprintMatches(expectedRevision, result.revisionKnown ? result.revision : undefined) || !digestMatches(expectedDigest, result.digest)) {
|
||||
if (result.kind !== "reload" && (!fingerprintMatches(expectedRevision, result.revisionKnown ? result.revision : undefined) || !digestMatches(expectedDigest, result.digest))) {
|
||||
input.dispatch({ type: "history_older_error", error: "history identity changed" });
|
||||
return "empty";
|
||||
}
|
||||
|
||||
@@ -27,6 +27,16 @@ export function applyHydrateErrorState<
|
||||
};
|
||||
}
|
||||
|
||||
/** The recovery detail for a failed history read: the summary, then the reader's own identity and cause. */
|
||||
export function hydrateFailureDetail(summary: string, cause: unknown): string {
|
||||
const message = (cause instanceof Error ? cause.message : typeof cause === "string" ? cause : "").trim();
|
||||
const failure = cause as { stage?: unknown; reason?: unknown } | undefined;
|
||||
const identity = cause instanceof Error && typeof failure?.stage === "string" && typeof failure.reason === "string"
|
||||
? `${failure.stage}.${failure.reason}` : "";
|
||||
const line = [identity, message].filter(Boolean).join(": ");
|
||||
return line ? `${summary}\n${line}` : summary;
|
||||
}
|
||||
|
||||
export function hydratePlaceholderItems<TItem>(
|
||||
optionsItems: TItem[] | undefined,
|
||||
): TItem[] | undefined {
|
||||
|
||||
@@ -20,6 +20,7 @@ const CODE_INDEX = {
|
||||
workspace_start_failed: 12,
|
||||
inbox_not_submitted: 16,
|
||||
image_attachment_unreadable: 17,
|
||||
inbox_target_transient: 18,
|
||||
} as const;
|
||||
|
||||
type InboxErrorCode = keyof typeof CODE_INDEX;
|
||||
@@ -47,6 +48,7 @@ const ERROR_COPY: Record<Locale, readonly string[]> = {
|
||||
"Cancel failed: {error}",
|
||||
"The message was not sent. Refresh the session and try again",
|
||||
"The image could not be read. Re-add it or try again",
|
||||
"The session is switching. The message will be sent automatically.",
|
||||
],
|
||||
zh: [
|
||||
"收件箱已暂停",
|
||||
@@ -67,6 +69,7 @@ const ERROR_COPY: Record<Locale, readonly string[]> = {
|
||||
"取消失败:{error}",
|
||||
"消息未发送,请刷新会话后重试",
|
||||
"图片读取失败,请重新添加或重试",
|
||||
"会话正在切换,消息会自动发出。",
|
||||
],
|
||||
"zh-TW": [
|
||||
"收件匣已暫停",
|
||||
@@ -87,6 +90,7 @@ const ERROR_COPY: Record<Locale, readonly string[]> = {
|
||||
"取消失敗:{error}",
|
||||
"訊息未傳送,請重新整理會話後重試",
|
||||
"圖片讀取失敗,請重新加入或重試",
|
||||
"工作階段正在切換,訊息會自動送出。",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -49,6 +49,14 @@ const messages = {
|
||||
historicalSourceBusy: ["其他实例正在使用此会话,关闭后可重试。", "其他實例正在使用此對話,關閉後可重試。", "In use by another instance. Close it and retry."],
|
||||
historicalImportFailed: ["导入失败,原件已保留。可重试或查看恢复详情。", "匯入失敗,原件已保留。可重試或檢視恢復詳情。", "Import failed. Original files are preserved; retry or review recovery details."],
|
||||
historicalAvailable: ["尚未导入", "尚未匯入", "Not imported"],
|
||||
historicalFailureWorkspaceConflict: ["会话记录的项目路径与当前项目不一致,已停止导入以免放错项目。原件已保留。", "對話記錄的專案路徑與目前專案不一致,已停止匯入以免放錯專案。原件已保留。", "The conversation records a different project folder than this project, so it was not imported. Original files are preserved."],
|
||||
historicalFailureTooLarge: ["会话历史超过安全回放上限,无法导入。原件已保留。", "對話歷史超過安全重播上限,無法匯入。原件已保留。", "The conversation history exceeds the safe replay limit and cannot be imported. Original files are preserved."],
|
||||
historicalFailureDamaged: ["会话历史已损坏或不完整,无法导入。原件已保留。", "對話歷史已損壞或不完整,無法匯入。原件已保留。", "The conversation history is damaged or incomplete and cannot be imported. Original files are preserved."],
|
||||
historicalFailureUnsupported: ["会话由不支持的存储格式写入,无法导入。原件已保留。", "對話由不支援的儲存格式寫入,無法匯入。原件已保留。", "The conversation was written in an unsupported storage format. Original files are preserved."],
|
||||
historicalFailureStateConflict: ["导入记录与已登记的会话冲突,已停止以免覆盖。原件已保留。", "匯入記錄與已登記的對話衝突,已停止以免覆寫。原件已保留。", "The import conflicts with an already registered conversation and was stopped. Original files are preserved."],
|
||||
historicalFailureSourceChanged: ["导入期间原始会话发生了变化,请重试。", "匯入期間原始對話發生了變化,請重試。", "The original conversation changed during import. Retry."],
|
||||
historicalOpenFailed: ["已导入,但打开会话失败。可重试。", "已匯入,但開啟對話失敗。可重試。", "Imported, but the conversation could not be opened. Retry."],
|
||||
historicalRecoveryDetails: ["查看恢复详情", "檢視恢復詳情", "Review recovery details"],
|
||||
historicalImportToSend: ["导入后才能继续发送消息,原始文件会保留。", "匯入後才能繼續傳送訊息,原始檔案會保留。", "Import this conversation to keep sending messages. The original file is kept."],
|
||||
historicalSourceUpdated: ["旧来源有更新,可导入为独立分支", "舊來源有更新,可匯入為獨立分支", "Historical source changed. Import as a separate branch."],
|
||||
historicalQueued: ["排队中", "排隊中", "Queued"],
|
||||
@@ -62,6 +70,18 @@ const messages = {
|
||||
branch: ["分支", "分支", "Branch"],
|
||||
} as const;
|
||||
export type ManagementKey = keyof typeof messages;
|
||||
const historicalFailureKeys: Record<string, ManagementKey> = {
|
||||
source_busy: "historicalSourceBusy",
|
||||
workspace_conflict: "historicalFailureWorkspaceConflict",
|
||||
history_too_large: "historicalFailureTooLarge",
|
||||
history_damaged: "historicalFailureDamaged",
|
||||
unsupported_version: "historicalFailureUnsupported",
|
||||
state_conflict: "historicalFailureStateConflict",
|
||||
target_changed: "historicalFailureSourceChanged",
|
||||
};
|
||||
export function historicalFailureKey(code?: string): ManagementKey {
|
||||
return (code && historicalFailureKeys[code]) || "historicalImportFailed";
|
||||
}
|
||||
export function useManagementT() {
|
||||
const { locale } = useI18n();
|
||||
return useCallback((key: ManagementKey, vars?: Record<string, string | number>) => {
|
||||
|
||||
@@ -45,6 +45,24 @@ export async function confirmFollowup(binding: AppBindings, request: PendingFoll
|
||||
return receipt;
|
||||
}
|
||||
|
||||
// A busy-window submit answers with the durable queue receipt: the desktop
|
||||
// queued the message instead of starting a turn. The receipt rides the bridge
|
||||
// error data so the composer can show the queue entry immediately.
|
||||
export type QueuedFollowupOutcome = { itemId: string; disposition: string; position: number; paused: boolean };
|
||||
|
||||
export function queuedFollowupOutcome(error: unknown): QueuedFollowupOutcome | undefined {
|
||||
const data = (error as { data?: { queuedFollowup?: unknown } } | undefined)?.data?.queuedFollowup;
|
||||
if (!data || typeof data !== "object") return undefined;
|
||||
const receipt = data as Partial<QueuedFollowupOutcome>;
|
||||
if (typeof receipt.itemId !== "string" || receipt.itemId === "") return undefined;
|
||||
return {
|
||||
itemId: receipt.itemId,
|
||||
disposition: typeof receipt.disposition === "string" && receipt.disposition ? receipt.disposition : "queued_followup",
|
||||
position: typeof receipt.position === "number" ? receipt.position : 0,
|
||||
paused: receipt.paused === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function followupNotSubmitted(error: unknown): boolean {
|
||||
const outcome = submissionOutcome(error);
|
||||
if (outcome) return outcome === "not_accepted";
|
||||
|
||||
Loaded 100 of 824 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user