Compare commits

...
97 Commits
Author SHA1 Message Date
github-actions[bot] d4bea95948 docs(release): prepare v1.22.0 notes (#8121)
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.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-08-10 09:21:15 +08:00
SivanCola 24be6e684f Merge pull request #8109 from SivanCola/fix/context-maintenance-idempotency
fix(context): make maintenance projection-idempotent / 上下文维护投影幂等化
2026-08-10 09:13:48 +08:00
SivanCola adf4ac72ac fix(agent): make context maintenance converge transactionally
Problem:
Context maintenance still had divergent strict-role and manual compression paths. Manual folds could archive before a durable commit, Anthropic native editing had no session-stable rejection fallback, and desktop maintenance receipts could surface stale or untranslated state.

Fix:
- converge strict-role pressure handling and explicit range compression on the projection CAS transaction
- persist generation, hashes, receipts, archives, and native/local context-editing lineage
- allow one pre-success Anthropic native rejection fallback, then lock the session request shape
- avoid duplicate Guardian maintenance epochs and fence stale desktop metadata/balance reads
- localize context-maintenance notices and expose projected/canonical maintenance state

Verification:
go test ./... -count=1
go test -race ./internal/agent ./internal/guardian ./internal/provider/anthropic ./internal/control -count=1
go test ./internal/agent -run TestIssue7935Maintains206ToolResultsOnceAndOnlyProcessesNewTail -count=10
cd desktop && go test ./... -count=1
cd desktop/frontend && pnpm test && pnpm build && pnpm test:typecheck
./scripts/cache-guard.sh
go vet ./...
golangci-lint run ./...
go run ./tools/repolint
2026-08-10 08:43:52 +08:00
SivanCola a5978f08ed docs(context): document maintenance and native editing
Problem:
The PR adds durable context-maintenance semantics and an advanced native Anthropic opt-in that were not covered by the embedded guides.

Fix:
Document projection-based maintenance, durable blocked fingerprints, canonical transcript preservation, and the official-Anthropic-only context_editing setting in English and Chinese.

Verification:
- go test ./docs
- go run ./tools/repolint
- git diff --check
2026-08-10 05:16:14 +08:00
SivanCola 87b7d130f3 fix(context): make maintenance projection-idempotent
Problem:
Long sessions could repeatedly snip the same canonical tool results, retry failed summaries, and report misleading context state.

Root cause:
Preflight and post-turn compaction evolved separate views, while pruning rebuilt from canonical history instead of the installed projection. Failed view fingerprints and maintenance outcomes were not durable.

Fix:
Make ContextManager the single request-time owner, evolve projection-only views with CAS and v3 receipts, persist blocked fingerprints, add Anthropic native tool clearing capability gates, and expose current context composition separately from summary-call cost.

Verification:
- go test ./...
- go test -race ./internal/agent ./internal/provider/anthropic ./internal/control
- go vet ./...
- ./scripts/cache-guard.sh
- cd desktop/frontend && pnpm typecheck
- focused Desktop and frontend context contract tests
- go run ./tools/repolint
2026-08-10 05:14:03 +08:00
SivanCola 9b2c85c9d3 Merge remote-tracking branch 'origin/main-v2' into HEAD 2026-08-10 04:07:55 +08:00
SivanCola d541409e26 Merge pull request #8069 from esengine/ci/wails-version-pin
ci(wails): unify the CLI and library version pin / 统一 Wails CLI 与依赖版本锁定
2026-08-10 04:04:05 +08:00
SivanCola 0d29be5463 Merge remote-tracking branch 'origin/main-v2' into HEAD 2026-08-10 04:03:42 +08:00
SivanCola 13c072b5c3 Merge pull request #8084 from SivanCola/fix/desktop-archive-reliability
Make desktop topic archive failure-safe / 修复桌面端话题归档卡死
2026-08-10 03:51:41 +08:00
SivanCola c49ea44087 fix(ci): close Wails pin guard bypasses
Problem: A broken installed Wails CLI made prod_test exit before its repair path, while the regression suite copied the repository's current pin and the guard accepted alternate dynamic install sources.

Root cause: The version probe inherited errexit and pipefail, the integration assertion embedded v2.13.0, and the guard relied mostly on negative checks instead of proving each supported consumer read .wails-version.

Fix: Treat probe failures as an explicit version mismatch, derive real-repository expectations from the shared pin, validate stable pin syntax, whitelist supported install sources, and guard plus quote the Makefile installer. Add regressions for broken CLIs, alternate workflow sources, hard-coded Make variables, and invalid pins.

Verification: make lint; bash scripts/release-workflows.test.sh; isolated GOBIN make wails-install with wails version v2.13.0; broken Wails probe returns an actionable mismatch; git diff --check.

Co-authored-by: SivanCola <[email protected]>
2026-08-10 03:39:23 +08:00
SivanCola 5204882948 Merge pull request #8087 from SivanCola/fix/global-workspace-external-opener
Enable external opener for Global workspaces / 为 Global 工作区启用外部打开按钮
2026-08-10 03:32:06 +08:00
SivanCola d44e1022b7 Merge pull request #8057 from SivanCola/fix/shared-window-output-budget
fix(agent): bound shared-window output budgets / 修复共享窗口输出预算溢出
2026-08-10 03:29:21 +08:00
SivanCola a1e23402e9 Merge pull request #8073 from erphenheimer/fix/8072-windows-shortcut-root-icon
fix(windows): repair legacy root-level shortcut icon and target / 修复旧版根目录快捷方式
2026-08-10 03:25:45 +08:00
SivanCola e939f2dd26 style(desktop): modernize teardown deadline clamp
Problem:
- The macOS lint gate rejected the manual duration bounds checks.

Root cause:
- The modernize linter requires the Go min and max built-ins for simple clamps.

Fix:
- Express the shared teardown deadline clamp with min and max.

Verification:
- golangci-lint run --timeout=5m ./... (desktop)
- go test . -run 'TestWaitDestroyHandleBatchesWaitsAcrossSessionsConcurrently|TestWaitDestroyHandles' -count=1
2026-08-10 03:21:20 +08:00
SivanCola a25ae9ef24 Merge pull request #8053 from SivanCola/fix/core-data-safety
Harden core data-safety paths / 加固核心数据安全路径
2026-08-10 03:17:57 +08:00
SivanCola 984e6f2e1d fix(desktop): finish topic archive reconciliation
Problem:
- A committed topic archive could leave metadata behind without a durable retry path.
- Empty live stubs could create duplicate trash entries.
- Multi-session teardown could consume one watchdog interval per session.

Root cause:
- Session cleanup markers did not carry the topic metadata operation.
- Existing-trash reconciliation treated an empty live transcript as divergent.
- Destroy handles were awaited in per-session serial batches.

Fix:
- Persist a hashed topic archive intent and reconcile sessions plus metadata at startup.
- Remove empty or identical live artifacts under the existing removal guard while preserving sidecar-only recovery.
- Wait all topic destroy batches concurrently under one shared deadline.

Verification:
- go test ./... -count=1 -timeout=10m (desktop)
- go test -race . -run archive-focused suite
- go test ./internal/agent ./internal/control -count=1
- GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go test -exec=true .
- go vet ./... (root and desktop)
- go run ./tools/repolint
- ./scripts/cache-guard.sh
2026-08-10 03:17:23 +08:00
SivanCola 1f93876afe Merge pull request #8077 from SivanCola/feature/native-v2-explicit-resources
fix(plugin): make native v2 resources explicit / 原生 v2 插件资源改为显式声明
2026-08-10 03:15:21 +08:00
SivanCola 303139c612 Merge pull request #8059 from SivanCola/feature/web-cli-handoff
feat(cli): add first-class Web handoff / 增加 CLI Web 会话交接
2026-08-10 03:10:55 +08:00
SivanCola 641f7993fd fix(ci): enforce the shared Wails version everywhere
Problem:
Local production builds and contributor setup could still bypass the shared Wails pin, so the old CLI/library skew remained possible even while the workflow-only guard passed.

Root cause:
The guard inspected only two workflow files, prod_test checked command presence instead of the installed CLI version, and the desktop guide recommended @latest.

Fix:
Add a make-based pinned installer, make prod_test repair and verify version mismatches, reject repository-wide hard-coded Wails installs, move the maintenance guidance out of the cache-stable prompt, and cover each drift path with regression tests.

Verification:
- make lint
- bash scripts/release-workflows.test.sh
- make wails-install installs v2.13.0 in an isolated GOBIN
- wails build -platform darwin/arm64 -clean -skipbindings -nopackage

Co-authored-by: SivanCola <[email protected]>
2026-08-10 03:06:01 +08:00
SivanCola 150bb87d4d chore(windows): satisfy shortcut repair comment budget
Problem:
The required lint job rejected the supplemental shortcut-repair comment because its six-line block exceeded the repository's five-line budget for this position.

Root cause:
The current.json migration rationale was documented too verbosely for the repolint policy.

Fix:
Compress the explanation while preserving the versioned-layout and live-flat distinction.

Verification:
- go run ./tools/repolint
- golangci-lint run --timeout=5m ./...
- cd desktop && GOOS=windows golangci-lint run --timeout=5m ./...
- git diff --check
2026-08-10 03:03:21 +08:00
SivanCola b22ef01193 Merge pull request #8099 from HaoyueQin/feat/status-bar-per-request-tps
fix(desktop): refresh status bar TPS after each provider request / 底部信息栏吞吐速度改为每次请求后更新
2026-08-10 03:03:15 +08:00
SivanCola 33a3454498 fix(desktop): synchronize opener preference across tabs
Problem:
A preference write completing after a tab switch updated only the unmounted selecting component. The visible tab could keep and launch the old opener even though the global preference had changed.

Root cause:
The shared coordinator ordered writes but did not publish persisted preferences or fence discovery results against preference revisions.

Fix:
Publish successful preference writes to mounted opener controls and use a revision fence so discoveries started before a write cannot restore stale state. Add deterministic coverage for both completion orders.

Verification:
- pnpm exec tsx src/__tests__/external-opener-preference-sync.test.tsx
- pnpm exec tsx src/__tests__/external-opener.test.tsx
- pnpm test:typecheck
- pnpm build
- go run ./tools/repolint
2026-08-10 02:58:34 +08:00
SivanCola 65f0a7dd49 Fix Responses replay input calibration
Problem:
Official DeepSeek Responses sessions could add ordinary reasoning or completed web-search items after the last usage calibration without increasing the shared-window input estimate. Near the context limit, that could still leave an oversized completion allowance and reproduce the HTTP 400 this PR is intended to prevent.

Root cause:
The calibration shape intentionally counted only text common to every DeepSeek transport. That assumption is conservative only for adapter-specific text already present in the calibrated request; newly appended Responses-only replay fields were invisible.

Fix:
Add an immutable provider input policy for adapter-specific shared-window replay fields. Have the official DeepSeek Responses adapter declare ordinary reasoning and ResponsesItems, consume that policy in request calibration, preserve it through the fork-capture wrapper, and add regressions for both growth paths while keeping other transports unchanged.

Verification:
- go test ./internal/agent ./internal/provider/... -count=1
- focused go test -race ./internal/agent
- go test ./... -count=1
- go vet ./...
- cd desktop && go test ./... -count=1
- ./scripts/cache-guard.sh
- go run ./tools/repolint
- git diff --check
2026-08-10 02:49:31 +08:00
SivanCola 1763067ce1 fix(runtime): close data-safety race windows
Problem: live shell progress could still grow without bound even though the final result was capped, and a stale session-listing backfill could overwrite newer turn counts.

Root cause: progress bypassed the bounded collector into append-only consumers, while listing decode and metadata update were separated by an unchecked transcript-generation window.

Fix: cap live progress at 64 KiB plus one marker, restore the bounded final result in CLI, and compare sidecar counts plus transcript generation under the metadata lock before backfill.

Verification: go test ./... -count=1; cd desktop && go test ./... -count=1; go test -race ./internal/agent ./internal/shellrun ./internal/cli -count=1; go vet ./...; cd desktop && go vet ./...; go run ./tools/repolint; ./scripts/cache-guard.sh
2026-08-10 02:47:35 +08:00
SivanCola 9ae2e399b5 fix(windows): distinguish versioned shortcut leftovers
Problem:
A root-level reasonix-desktop.exe can survive the versioned migration when best-effort cleanup fails, so file existence alone cannot prove a live flat install.

Root cause:
Shortcut repair treated every existing flat binary as authoritative, and the icon regression test depended on a fixed host path.

Fix:
Use validated current.json as the versioned-layout commit signal, preserve true flat installs, and cover existing and missing flat target and icon cases with isolated temporary roots.

Verification:
- cd desktop && go vet ./...
- cd desktop && go test ./...
- cd desktop && GOOS=windows GOARCH=amd64 go test -c
- go test ./internal/installlayout
- Native Windows 11 focused and real .lnk tests
2026-08-10 02:45:22 +08:00
SivanCola ecde0fd8f0 fix(cli): preserve legacy remote Serve authentication
Problem:
- A newer CLI always opened Remote Serve with a fragment token.
- Reused v1.21.4 Serve processes only understand query-token bootstrap, so reconnects returned 401 after a local CLI upgrade.

Root cause:
- Remote bootstrap intentionally reuses a healthy detached process without negotiating its browser authentication capability.

Fix:
- Probe the forwarded /auth/token endpoint without sending the secret.
- Keep fragment-token URLs for current Serve and fall back to the legacy query bootstrap when the endpoint is unavailable or incompatible.
- Remove the external implementation reference from the instance registry comment.

Verification:
- go test ./internal/cli -run TestRemoteServeBrowserURL -count=1
- go test -race ./internal/cli ./internal/serve -count=1
- go test ./... -count=1
- go vet ./...
- go run ./tools/repolint
- ./scripts/cache-guard.sh
- make build
- npm --prefix site test
- npm --prefix site run build
- git diff --check
2026-08-10 02:43:50 +08:00
SivanCola cadcc7a39a fix(cli): satisfy Web static analysis
Problem:
- CI lint rejected one secondary error formatting path and an index-only loop.

Root cause:
- The readiness error preserved the server error with a non-wrapping verb, and the registry retry loop used the pre-Go-1.22 integer form.

Fix:
- Wrap both readiness errors and use integer range syntax.

Verification:
- golangci-lint run --timeout=5m ./internal/cli/... (0 issues)
- focused CLI tests
- go run ./tools/repolint
2026-08-10 02:43:50 +08:00
SivanCola f8b562edd6 fix(cli): retry occupied Web ports on Windows
Problem:
- Windows CI failed Web port retry and Unix permission-bit assertions.

Root cause:
- Windows reports an occupied socket as WSAEADDRINUSE, while the shared retry loop only recognized Unix EADDRINUSE.
- Windows file modes do not expose Unix 0700/0600 permission bits.

Fix:
- Detect address-in-use errors in platform-specific process files.
- Keep permission assertions on platforms that implement Unix mode bits while retaining restricted creation modes and user-directory ACLs on Windows.

Verification:
- focused tests repeated 10 times
- focused race test
- go run ./tools/repolint
- Windows amd64 test-binary cross-compile
2026-08-10 02:43:50 +08:00
SivanCola 9416069ca3 refactor(cli): keep Web handoff within lint budgets
Problem:
- The initial Web handoff implementation extended already-baselined CLI and TUI files beyond repository size and complexity budgets.

Root cause:
- Browser lifecycle, frontend startup, and new tests were placed beside legacy monolithic command paths.

Fix:
- Extract Web handoff state, browser readiness, Serve frontend lifecycle, and focused tests into bounded files.
- Preserve reasonix serve and reasonix web behavior without updating the repolint baseline.

Verification:
- go test ./... -count=1
- focused go test -race for CLI and Serve Web paths
- go vet ./internal/cli ./internal/serve
- go run ./tools/repolint
- Windows amd64 cross-compile for internal/cli
- make build
2026-08-10 02:43:50 +08:00
SivanCola a90c4e12ff feat(cli): add browser web handoff
Problem:
- The CLI exposed Serve but lacked a user-facing browser command and an in-session Web handoff.
- Token links, port collisions, and fresh-session handoff made local Web startup brittle.

Root cause:
- Browser launch lifecycle was not separated from supervised Serve, and empty TUI sessions had no materialized transcript to resume.

Fix:
- Add reasonix web and /web with ready-gated browser opening, fragment-token bootstrap, deterministic port retry, and a per-instance registry.
- Preserve fresh and persisted session identity across the handoff while keeping reasonix serve backward compatible.
- Update CLI help plus English, Chinese, and website documentation.

Verification:
- go test ./... -count=1
- focused go test -race for CLI and Serve Web paths
- Windows amd64 cross-compile for internal/cli
- go vet ./internal/cli ./internal/serve
- make build
- site npm test and Astro build
2026-08-10 02:43:50 +08:00
SivanCola e3841cff9e docs(site): align extension guide with manifest v2
Problem: the public docs page still labeled the extension runtime and reference as Manifest v1 and omitted the explicit native v2 capability boundary.

Fix: update the bilingual site copy and v2 anchors, and extend the documentation contract regression.

Verification: cd site && pnpm test; cd site && pnpm build; local in-app browser verification of rendered copy, anchors, and console.
2026-08-10 02:41:42 +08:00
SivanCola 3e5eade4a9 merge(main-v2): integrate sidecar durability
Problem: PR #8051 advanced the session sidecar durability and locking contract on main-v2, leaving this branch conflicted in desktop/sessions.go.

Resolution: merge current main-v2 and keep its AtomicWriteFile implementation, split local/external lock budgets, and regression suite while preserving this branch’s independent shell and session-listing work.

Verification: cd desktop && go test -race . -run 'TestSessionSidecarSavesUseDurableAtomicWrite|TestSetSessionTitle|TestRecordSessionDisplaySerializesConcurrentTabs|TestRecordSessionPlannerDisplayConcurrentPreservesEverySession|TestRecordSessionDisplayExternalLockBudget' -count=1; git diff --check origin/main-v2
2026-08-10 02:27:41 +08:00
SivanCola 457c010fb9 fix(desktop): keep latest request TPS truthful
Problem:
Slow or unmeasurable provider requests could leave the status bar showing an older request or completed-turn throughput.

Root cause:
The reducer rounded TPS before validating it and reused the previous value when the rounded rate was zero or no provider-output interval existed. Tests also modeled full tool dispatch before usage instead of the runtime order.

Fix:
- retain raw positive request rates so sub-one TPS renders as <1 t/s
- distinguish no request yet from an unmeasurable latest request
- clear stale throughput for short and final-only tool requests
- cover usage-before-full-tool-dispatch and slow-request ordering

Verification:
- pnpm --dir desktop/frontend exec tsx src/__tests__/use-controller-stream-progress.test.ts
- pnpm --dir desktop/frontend exec tsx src/__tests__/statusbar-workspace.test.tsx
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend build
- pnpm --dir desktop/frontend test:remote
- go run ./tools/repolint
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-10 02:26:18 +08:00
SivanCola 8fec83cae4 merge: sync PR #8099 with main-v2
Problem:
The PR branch was behind the latest main-v2 session durability changes.

Root cause:
The base branch advanced after the previous PR update.

Fix:
Merge the current main-v2 tip without rewriting contributor history.

Verification:
- merge completed without conflicts
- TPS-focused frontend tests run on the integrated tree
2026-08-10 02:26:18 +08:00
SivanCola 7805d07511 fix(desktop): fence opener preference on successful launches
Problem:
A newer failed opener launch could cancel an older successful selection before its preference write, while an already-started older write still completed. The final preference therefore depended on async timing.

Root cause:
The shared preference coordinator advanced its ordering fence when a menu item was clicked instead of when that launch succeeded.

Fix:
Track issued intents separately from the latest successfully launched intent. Serialize writes against the successful fence so failed launches do not participate and newer successful choices still win. Add deterministic regressions for pending launches and in-flight writes followed by a newer failure.

Verification:
- pnpm exec tsx src/__tests__/external-opener.test.tsx (30/30)
- pnpm test:typecheck
- pnpm build
- go run ./tools/repolint
2026-08-10 02:24:36 +08:00
SivanCola 1687729ca9 Keep shared-window hardening within repo budgets
Problem:
The first repair revision exceeded the existing run-loop file-size ratchet and introduced two overlong comment blocks, failing the repository standards job.

Root cause:
The estimated-usage admission explanation lived at the call site instead of the output-budget owner, and the calibration rationale used essay-style comments.

Fix:
Move the provider-usage calibration gate into output_budget.go and condense the comments without changing behavior or widening repolint baselines.

Verification:
- go run ./tools/repolint
- go test ./internal/agent -count=1
- focused go test -race ./internal/agent
- git diff --check
2026-08-10 02:20:30 +08:00
SivanCola 6b9c0659c3 Merge main-v2 into shared-window budget fix
Integration:
Bring the PR onto the latest main-v2 baseline without rewriting the contributor branch history. Preserve the shared-window calibration fixes while incorporating the current run-loop and Desktop changes.

Verification:
Run the focused, race, cache, root, vet, and Desktop suites on the resulting merge tree before publication.
2026-08-10 02:15:31 +08:00
SivanCola a4086d28da style(desktop): modernize archive rollback iteration
Use slices.Backward for reverse ownership rollback so the desktop package satisfies the Go 1.26 modernize lint gate.
2026-08-10 02:15:24 +08:00
SivanCola 19603ef8e5 Harden shared-window calibration boundaries
Problem:
Shared-window output admission could trust a stale low-density calibration after CJK or replay-shape growth, reconstructed usage could replace provider telemetry, and fork capture erased output-budget capabilities.

Root cause:
Calibration tracked only aggregate bytes, the usage store did not distinguish estimated records, and the experiment wrapper exposed only the base Provider methods.

Fix:
Track conservative replay text and CJK composition, price only excess CJK density with the cold safety floor, exclude adapter-only calibration bytes, ignore estimated usage for calibration, and forward shared-window output capabilities through fork capture.

Verification:
- go test ./internal/agent ./internal/provider/... -count=1
- go test -race ./internal/agent -run shared-window-focused-regressions -count=1
- ./scripts/cache-guard.sh
- git diff --check
2026-08-10 02:15:18 +08:00
SivanCola b7640ca095 fix(desktop): make topic archive cleanup transactional
Problem:
- A failed topic archive could publish delete markers before obtaining cross-process removal ownership, then trash the session on a later reconciliation.
- Snapshot-conflict diagnostics logged the raw error, including the local session path.

Root cause:
- Runtime leases had to be released before removal guards could be acquired, leaving no atomic ownership handoff or reversible pre-commit state.
- Post-marker cleanup errors were reported as aborted even though durable deletion intent remained active.

Fix:
- Convert live session leases directly into removal guards without unlocking, and restore the same owner generation when pre-commit validation fails.
- Acquire removal ownership for every topic session before publishing cleanup markers, preserve diverged trash copies, and let committed cleanup converge through reconciliation.
- Log only the safe snapshot-conflict kind and keep cleanup diagnostics value-free.

Verification:
- go test ./... -count=1 -timeout=300s
- cd desktop && go test ./... -count=1 -timeout=300s
- go test -race ./internal/agent -count=1 -timeout=300s
- cd desktop && go test -race . -run 'TestTrashTopic|TestTopicArchiveOwnershipRollback|TestSnapshotTopicRuntimeConflictLog' -count=1 -timeout=180s
- GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go test -exec=true ./internal/agent
- GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go test -exec=true .
- cd desktop/frontend && pnpm build
- ./scripts/cache-guard.sh
- go run ./tools/repolint
2026-08-10 02:11:55 +08:00
SivanCola 53fb380d12 Merge pull request #8051 from SivanCola/fix/session-sidecar-durability
fix(desktop): make session sidecars durable / 持久化并串行化会话 sidecar
2026-08-10 02:06:00 +08:00
SivanCola 8cc7279c86 refactor(plugin): keep compatibility changes within lint budgets
Move Claude root-instruction helpers out of the oversized parser file and keep marketplace fixtures line-neutral while preserving the official skill-based coverage.

Verification: go run ./tools/repolint; go test ./...; ./scripts/cache-guard.sh.
2026-08-10 02:03:33 +08:00
SivanCola df7d060929 Merge remote-tracking branch 'origin/main-v2' into fix/pr8099-request-tps 2026-08-10 02:00:45 +08:00
SivanCola b13f45d886 fix(desktop): scope request TPS to latest attempt
Problem:
Sampling retries could display billable aggregate output tokens against only the final attempt's duration, overstating the latest request TPS. The PR also exceeded existing repolint file-size budgets.

Root cause:
The reducer ignored the latest-attempt Context* token shape when calculating per-request TPS, and the new state flow added too many lines to already-baselined files.

Fix:
- prefer contextCompletionTokens when latest-attempt context is present
- retain completionTokens fallback for legacy usage payloads
- add deterministic sampling-recovery coverage
- compact equivalent TPS state handling without widening baselines

Verification:
- pnpm --dir desktop/frontend exec tsx src/__tests__/use-controller-stream-progress.test.ts
- pnpm --dir desktop/frontend exec tsx src/__tests__/statusbar-workspace.test.tsx
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend exec eslint src/lib/useController.ts src/components/StatusBar.tsx src/__tests__/use-controller-stream-progress.test.ts
- go run ./tools/repolint
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-10 02:00:35 +08:00
SivanCola 1c6776d9d9 fix(plugin): align Claude root instructions with upstream
Problem: Reasonix synthesized a SessionStart context hook from a Claude plugin root CLAUDE.md, although Claude Code does not load that file as plugin project context.

Root cause: Claude manifests shared the Codex compatibility helper that always appended root instructions.

Fix: keep supported Claude convention imports without the synthetic root hook, document the distinction, and update install-source fixtures to use official skills.

Verification: go test ./...; ./scripts/cache-guard.sh; focused plugin and install-source tests; desktop plugin/theme tests.
2026-08-10 01:59:34 +08:00
SivanCola f32cff0e33 Merge origin/main-v2 into fix/global-workspace-external-opener
Resolve the bridge contract overlap by preserving both ResolveWorkspacePathForTab and ExternalOpenersForTab.
2026-08-10 01:57:46 +08:00
SivanCola 577023f8c3 fix(desktop): serialize opener preference writes
Problem:
Switching tabs during an external-opener launch could let an older completion overwrite the latest global opener preference.

Root cause:
Keyed ExternalOpener instances had independent busy guards, while preference writes were neither ordered across instances nor fenced by user intent.

Fix:
Coordinate selection intents per bridge, serialize preference writes, skip stale completions, and reassert the latest successful choice even when it matches the discovered preference. Add a deterministic cross-tab regression.

Verification:
- pnpm exec tsx src/__tests__/external-opener.test.tsx
- pnpm test:typecheck
- pnpm build
- go run ./tools/repolint
2026-08-10 01:56:34 +08:00
SivanCola e3c5affab9 Merge pull request #8065 from SivanCola/fix/disable-composer-spellcheck
Disable composer spellcheck / 禁用输入框拼写检查
2026-08-10 01:53:01 +08:00
SivanCola 95b5ef985c Merge pull request #8076 from SivanCola/feature/workspace-copy-paths
feat(desktop): add workspace path copy actions / 桌面端新增工作区路径复制操作
2026-08-10 01:52:37 +08:00
SivanCola ec5244584d Merge origin/main-v2 into fix/desktop-archive-reliability
Integrate the latest compact SSH status bar baseline before repairing archive ownership and diagnostics.
2026-08-10 01:52:30 +08:00
SivanCola ea57e9bf35 Merge pull request #8088 from SivanCola/feature/compact-ssh-statusbar
Compact idle SSH status / 精简空闲 SSH 状态栏
2026-08-10 01:49:11 +08:00
HaoyueQin 1970a6a16c fix(desktop): refresh status bar TPS after each provider request
PR #6931 computed the status bar throughput from completed-turn
telemetry, so during long tool-calling turns the value stayed stale
until turn_done — while the tooltip copy shipped with that PR already
claimed per-request throughput. Pair each executor usage event with the
provider-output interval it closes: message/tool_dispatch close the
interval first, so the closed duration is stashed in
pendingRequestModelMs until the usage event pairs it with completion
tokens. Non-executor sources (subagent/planner/title) neither compute
nor consume the pending value, and turn boundaries reset it to prevent
cross-turn pairing.
2026-08-10 01:36:28 +08:00
SivanCola b96e3d7938 fix(security): keep fallback diagnostics value-free
Runtime construction errors can carry provider configuration details, including API key environment metadata. Record only the recovery phase so archive fallback failures cannot flow sensitive configuration into Desktop logs.
2026-08-10 00:57:17 +08:00
SivanCola 25a21871a3 fix(ci): preserve project tree size budget
Keep the archive lifecycle extraction within the existing ProjectTree file-size ratchet after merging the latest main-v2 baseline. No repolint baseline update is required.
2026-08-10 00:52:01 +08:00
SivanCola 900767d7f2 Merge origin/main-v2 into fix/desktop-archive-reliability
Resolve the ApprovalModal overlap by using the shared native motion helper from PR #8080 and remove the superseded local helper.

Also make the bounded legacy-wait regression test explicitly join its released waiter so the full Desktop test suite cannot inherit a test goroutine.
2026-08-10 00:50:23 +08:00
SivanCola ecddf4802f chore(desktop): keep status bar within size budget
Problem:
The compact SSH status change pushed StatusBar.tsx beyond its existing repolint file-size budget.

Root cause:
Equivalent status label and icon expressions were expanded across several lines in an already baselined file.

Fix:
Keep the same state-dependent rendering while expressing the small conditions compactly, without widening the repository baseline.

Verification:
- go run ./tools/repolint
- pnpm exec tsx src/__tests__/statusbar-workspace.test.tsx
- pnpm typecheck
- pnpm exec eslint src/components/StatusBar.tsx
2026-08-10 00:44:13 +08:00
SivanCola 20326ae01b feat(desktop): compact idle SSH status
Problem:
The SSH status bar chip always showed its full disconnected summary and a switcher chevron whenever a host was configured, consuming scarce horizontal space while idle.

Root cause:
The trigger reused the accessible connection summary as its visible text in every state and had no responsive idle presentation.

Fix:
- show a compact SSH label for cleanly disconnected hosts
- show a status dot and host name for connected hosts
- retain explicit connecting, degraded, and failure summaries
- collapse the idle label below 820px while preserving a 24px target and full accessible labels
- cover the state-specific rendering with regression tests

Verification:
- pnpm test:remote
- pnpm typecheck
- pnpm check:css
- pnpm exec eslint src/components/StatusBar.tsx src/__tests__/statusbar-workspace.test.tsx
- in-app browser DOM, responsive, popover, and connect/disconnect checks
2026-08-10 00:41:41 +08:00
SivanCola a85df817cd refactor(desktop): extract archive lifecycle helpers
Problem:
- The archive reliability fix added lines to several ratcheted legacy files, causing repolint to reject the PR.

Root cause:
- Runtime teardown, archive transaction, animation, state, and regression helpers were implemented inline in files already above the repository size budgets.

Fix:
- Move each new owner-level concern into a focused Go or TypeScript module.
- Split archive transaction phases so every function remains within the function-size budget.
- Preserve the existing lock order, cleanup markers, generation checks, and UI behavior without widening lint baselines.

Verification:
- go run ./tools/repolint
- go test ./internal/control -count=1
- cd desktop && go test ./... -count=1
- cd desktop && go test -race . -run TrashTopic\|WaitDestroyHandles\|FriendlySessionFileError -count=1
- cd desktop/frontend && pnpm build
- Focused frontend tests and local browser click verification
2026-08-10 00:37:45 +08:00
SivanCola 5620eb6465 chore(desktop): satisfy repository standards
Problem:
The opener capability change exceeded the repository's ratcheted comment and file-size budgets.

Root cause:
A four-line explanatory comment and additive declarations in two already-baselined frontend files introduced new repolint findings.

Fix:
Keep the same contracts and behavior while expressing the comment and declarations within their existing line budgets. The repolint baseline remains unchanged.

Verification:
- go run ./tools/repolint
- go test . -run 'Test(ExternalOpener|ResolveExternalOpener|SetPreferredExternalOpener)' -count=1
- pnpm exec tsx src/__tests__/external-opener.test.tsx
- pnpm test:typecheck
2026-08-10 00:36:28 +08:00
SivanCola 710f3fb107 Merge pull request #8086 from esengine/release-notes/v1.21.5
docs(release): 准备 v1.21.5 中英更新日志
2026-08-10 00:32:07 +08:00
github-actions[bot] 3a0e33a8e5 docs(release): prepare v1.21.5 notes
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.
2026-08-09 16:30:50 +00:00
SivanCola c561fcd4e4 fix(desktop): expose opener for Global workspaces
Problem:
- Global tabs with valid local workspaces could not use the header external-opener control because the renderer treated project scope as the capability signal.
- The split control also retained taller geometry and oversized artwork than adjacent topic-bar actions.

Root cause:
- Mounting was hard-coded to scope === project, while the backend exposed no tab-scoped workspace capability.
- The control used a standalone 32px recipe after the surrounding toolbar had moved to 28px controls.

Fix:
- Add a tab-scoped opener discovery contract backed by the same validated workspace path used for launches.
- Render by backend capability, fence async discovery by tab id, and keep unavailable tabs hidden.
- Align the split control to the shared 28px / 7px topic-bar geometry and use 18px Workbench artwork while preserving Creation and menu sizing.

Verification:
- Desktop Go tests and focused race coverage passed.
- External-opener frontend tests, CSS checks, test typecheck, production build, bundle budgets, and live browser DOM/click checks passed.
2026-08-10 00:29:09 +08:00
SivanCola e8f9fa3192 Merge pull request #8080 from SivanCola/fix/desktop-approval-easing
Fix WebView2 animation crashes / 修复 WebView2 动画崩溃
2026-08-10 00:27:11 +08:00
SivanCola c6023144d9 fix(desktop): make topic archive failure-safe
Problem:
- Topic archive and approval confirmation could appear unresponsive.
- Archive teardown could block the UI or detach a runtime before durable cleanup was guaranteed.

Root cause:
- The approval exit animation passed a GSAP easing token to Web Animations.
- Archive used unbounded runtime locks and teardown waits, removed bindings before snapshot completion, and shared one frontend pending flag across every topic.

Fix:
- Convert approval easing to valid Web Animations values and complete actions exactly once.
- Snapshot before detach, validate the captured runtime generation, publish compatible cleanup markers, bound interactive teardown, and fall back to delayed cleanup.
- Return retryable busy errors for contended mutations and track archive progress per topic.

Verification:
- cd desktop && go test ./...
- go test ./internal/control
- cd desktop && go test -race . -run TrashTopic\|WaitDestroyHandles\|FriendlySessionFileError -count=1
- cd desktop/frontend && pnpm build
- Focused frontend tests, typecheck, and local browser click verification
2026-08-10 00:25:42 +08:00
SivanCola 0262190972 refactor(desktop): preserve repository size budgets
Problem:
- The first PR revision added lines to two components already governed by repolint file-size ratchets.

Root cause:
- Reusable animation completion and transcript entrance identity logic remained in the oversized component files.

Fix:
- Move fail-open exit animation handling into the shared motion module.
- Move model-backed transcript entrance setup into the entrance hook module.

Verification:
- go run ./tools/repolint
- pnpm exec tsx src/__tests__/native-motion.test.tsx
- pnpm exec tsx src/__tests__/approval-animation.test.tsx
- pnpm test:typecheck
- pnpm lint:hooks
- pnpm build
2026-08-10 00:18:34 +08:00
SivanCola 601724c8ac feat(desktop): add workspace path copy actions
Problem:
The workspace file-tree menu could add or open entries, but users could not
copy their workspace-relative or local absolute paths.

Root cause:
The menu had no clipboard actions, and the renderer only knew reference tokens,
which cannot safely identify absolute paths for authorized external folders.

Fix:
- add relative-path and absolute-path copy actions with localized labels
- resolve absolute paths through the owning tab's guarded backend resolver
- preserve clipboard fallbacks and reject missing-tab or escaping paths
- isolate new behavior so baselined large files do not grow

Verification:
- cd desktop && go test ./...
- go run ./tools/repolint
- pnpm exec tsx src/__tests__/workspace-changes-errors.test.tsx
- pnpm exec tsx src/__tests__/workspace-path-copy.test.tsx
- pnpm build
2026-08-10 00:18:31 +08:00
SivanCola b07f6127be fix(desktop): harden native motion fallbacks
Problem:
- A WebView2 client could enter the fatal error surface after an approval because a non-CSS easing token reached Element.animate.
- Shared collapse and entrance effects had no reliable fail-open behavior, and transcript resets could animate restored virtual rows.

Root cause:
- GSAP easing vocabulary and legacy helpers remained mixed with the native Web Animations API.
- Entrance identity depended on a changing transcript boundary and DOM timing, while terminal cleanup depended on transitionend delivery.

Fix:
- Replace GSAP helpers and dependencies with shared CSS motion tokens and guarded native animation hooks.
- Make approval, collapse, and entrance completion idempotent and fail open on unsupported or rejected animations.
- Seed transcript entrance identity from stable model IDs and bound terminal close cleanup with a timer.

Verification:
- pnpm exec tsx src/__tests__/native-motion.test.tsx
- pnpm exec tsx src/__tests__/approval-animation.test.tsx
- pnpm build
- pnpm test:remote
- pnpm test:performance
- Installed Microsoft Edge production-mock regression checks
- Full frontend runner retained only the same six failures reproduced on the clean base
2026-08-10 00:15:06 +08:00
SivanCola bd97fecc5b fix(plugin): make native v2 resources explicit
Problem: Native v2 manifests could silently import Claude compatibility sidecars, including repository instructions and executable hooks.

Root cause: parseNativeV2 applied Claude compatibility discovery after validating the explicit manifest.

Fix: Treat the native v2 manifest as the complete capability boundary while preserving compatibility discovery for Codex and Claude manifests. Document the contract and cover it with a regression test.

Verification: go test ./...; cd desktop && go test . -run "Plugin|Theme" -count=1.
2026-08-10 00:08:02 +08:00
erphenheimer 0a21f96f0f fix(windows): repair legacy root-level shortcut icon and target 2026-08-09 23:21:23 +08:00
esengine eee45d2a35 ci(wails): read the pin from the workspace root, not the job's cwd
The first version failed exactly where it should have been checked: several
desktop steps run with working-directory: desktop, so `cat .wails-version`
found nothing and the install became wails@"" — "version must not be empty".

The guard checked the workflows' content but not that the path it prescribed
resolves from the directory the job actually runs in, which is why it passed a
change CI rejected. It now refuses a bare `cat .wails-version` too, and that
rule was replayed before committing: relative path fails, $GITHUB_WORKSPACE
passes.

The CLI bump itself is still unproven — the desktop jobs never got far enough
to try it.
2026-08-09 07:55:53 -07:00
esengine 17016c741b ci(wails): one pin for the CLI and the library, because they drifted apart
Dependabot bumped desktop/go.mod to wails v2.13.0. Nothing bumped the CLI: its
version is a string inside three workflow files, where Dependabot cannot see
it. So CI — and the desktop release job — have been building a v2.13.0 library
with a v2.12.0 toolchain.

The drift runs the other way too. `wails dev` rewrites desktop/go.mod down to
whatever CLI the developer has installed, so a local run quietly reverts the
dependency upgrade; that is how this was found, after reverting the same
downgrade several times in one afternoon.

.wails-version is now the single pin, read by the Makefile and all three
workflows, exactly as .golangci-version is. check-wails-pin.sh proves it held
and refuses a workflow that hard-codes a version again — it was written failing
first, and each of the three drift directions was replayed against it: a
downgraded go.mod, a library bumped past the pin, and a version written back
into a workflow.

The pin is v2.13.0, matching the library Dependabot chose. That moves CI's
toolchain forward by a minor version; the desktop, desktop-macos and
desktop-windows jobs are what verify it, and they are the reason this is worth
finding out now rather than at the next release.
2026-08-09 07:44:25 -07:00
YHH 0bf2002f42 Merge pull request #8064 from esengine/perf/project-tree-invalidation
perf(desktop): the project tree is data, not a function of which session is open
2026-08-09 22:37:02 +08:00
YHH 1f43fdc065 Merge pull request #8063 from esengine/perf/session-switch-fanout
perf(desktop): stop asking the backend the same question three times per switch
2026-08-09 22:33:33 +08:00
YHH 2167341319 Merge pull request #8050 from esengine/feat/completion-receipt
feat(completion): deliver the receipt, so the host's account is the one the user reads
2026-08-09 22:33:14 +08:00
SivanCola eae381d854 fix(agent): harden shared-window calibration
Problem:
Shared-window requests could still overflow after a successful calibration because replayed reasoning and other provider-visible fields were excluded. Summary folds also spent the output reserve twice and accepted length-truncated digests.

Root cause:
Usage was paired with content-only message size, summary input budgeting overlapped two independent reserves, and the summarizer treated finish_reason=length as success.

Fix:
Calibrate against the complete provider request while retaining a separate compaction ratio, reserve digest and sampling output independently for shared-window providers, and fail closed on truncated summaries. Add regression coverage for reasoning replay, tool schemas, reserve sizing, truncation, and session reset.

Verification:
- go test ./... -count=1
- go test -race ./internal/agent ./internal/provider/... -count=1
- go vet ./...
- go run ./tools/repolint
- ./scripts/cache-guard.sh
- cd desktop && go test . -run ^TestWorkspaceGitBranchDetachedHead$ -count=1 -v
2026-08-09 22:33:04 +08:00
YHH 1284c24920 Merge pull request #8047 from esengine/bench/longrun-recovery
bench(longrun): injected failure priced, and the long-session states reached directly
2026-08-09 22:32:53 +08:00
SivanCola 49960a1e53 fix(ci): preserve frontend line budgets
Problem:
Repository lint rejected the spellcheck change because two already-baselined frontend files gained lines beyond their allowed budgets.

Root cause:
The first implementation placed each related DOM attribute and assertion on a separate line.

Fix:
Keep the same rendered attributes and regression coverage while grouping each related set without increasing the affected files line counts. The repolint baseline remains unchanged.

Verification:
- go run ./tools/repolint
- pnpm exec tsx src/__tests__/composer-goal-toggle.test.tsx
- pnpm typecheck
- pnpm exec eslint src/components/Composer.tsx src/components/RichComposerInput.tsx src/__tests__/composer-goal-toggle.test.tsx
- git diff --check
2026-08-09 22:32:05 +08:00
SivanCola 64addf3b28 fix(desktop): disable composer spellcheck
Problem:
Desktop composers exposed browser spellcheck underlines without correction suggestions, leaving a misleading partial editing experience for code-heavy prompts.

Root cause:
The plain textarea and rich contenteditable composer inherited each platform webview spellcheck, autocorrect, and autocapitalization defaults while Reasonix owned the context menu.

Fix:
Disable spellcheck, autocorrect, and autocapitalization on both composer input modes, and add regression assertions for the rendered DOM contract.

Verification:
- pnpm exec tsx src/__tests__/composer-goal-toggle.test.tsx
- pnpm exec tsx src/__tests__/composer-context-menu-clipboard.test.tsx
- pnpm typecheck
- pnpm exec eslint src/components/Composer.tsx src/components/RichComposerInput.tsx src/__tests__/composer-goal-toggle.test.tsx
- Playwright Chromium DOM attribute check
2026-08-09 22:28:43 +08:00
esengine 17c873cf02 perf(desktop): the project tree is data, not a function of which session is open
Switching a session re-listed the whole project tree. The cause was a
dependency list, not a decision: ProjectTree's refresh callback closed over the
active scope/topic/session because it also seeded the expanded set from them,
so every switch minted a new callback and the effect watching it refetched.

The tree cannot change because a different session became active. Fetching is
now keyed by the data — refreshSignal, which is what marks it stale — and
following the active topic became a separate effect over the tree already held.
That effect also returns the previous set unchanged when a switch expands
nothing new, so a switch that changes nothing re-renders nothing.

Measured on a 201-topic sidebar, six switches: ListProjectTree went from twice
per switch to once.

The remaining one is not this component's: App lists the entire tree to read a
single number — the active topic's turn count, used only as a fallback while
the transcript still shows zero turns. That wants the count on the tab's own
meta rather than a whole-tree walk, which is a Go, type and App change worth
its own review.
2026-08-09 07:23:24 -07:00
esengine 1133d555c5 perf(desktop): stop asking the backend the same question three times per switch
Measured, not guessed: instrumenting every Wails binding and switching six
sessions on a sidebar of 201 topics showed 19-28 bridge calls and 849-1330ms of
IPC per switch, a third of them duplicates inside the same transition —
ContextPanel x3, ListProjectTree x2 (~150ms each, and it grows with the number
of topics), MetaForTab x2, ListTabs x2-3, BackgroundRuntimes x3.

Independent hooks each ask for the same thing. Deduping at the call sites would
mean threading a cache through a dozen of them and hoping the next hook
remembers; the bridge is already a Proxy, so one place covers every caller and
cannot be forgotten.

Only allowlisted read-only queries coalesce, and only for 200ms — a stale
answer is worse than a slow one, so nothing outlives the burst it collapses. A
rejection is never shared onward: the next caller retries rather than
inheriting a failure. The test for that caught the first version doing exactly
what its own comment promised it did not.

Same six switches after: 16-20 calls, 555-1148ms, duplicates down from ~17 per
six switches to 16 total. The remainder are calls with different arguments or
outside the window, which is the honest floor for this approach; the next win
is not re-listing a 201-topic project tree on a switch that cannot change it.
2026-08-09 07:10:39 -07:00
SivanCola f11321b60d fix(desktop): bound cross-process sidecar lock waits
Problem: the 5-second durability budget also applied to external OS lock retries, so another Reasonix process could stall turn completion before display persistence entered its retry queue.

Root cause: filelock.Acquire used one context for both the in-process writer queue and cross-process flock contention.

Fix: add a split-budget acquisition API, keep the 5-second local fsync queue, restore the 750ms external lock budget, and cover both the generic lock and real display-sidecar subprocess path.

Verification: focused and race tests, both Go module suites, go vet, repolint, Windows cross-compilation, and latest-base merge-tree all pass.
2026-08-09 22:07:12 +08:00
SivanCola 98217b6c90 fix(session): recover cached-zero listings
Problem: a session that an older build cached as zero turns after a transient decode failure stayed hidden even after its content became readable, and every listing decoded it again.

Root cause: the recovery probe discarded successful preview and turn results, while listing metadata could not distinguish an old ambiguous zero from a successfully validated empty session.

Fix: advance the listing-count contract to v2, trust positive v1 counts, revalidate only old zero counts once, retain recovered content, and leave unreadable artifacts unstamped and visible.

Verification: go test ./internal/agent -run TestListSessions -count=1; go test -race ./internal/agent -run TestListSessions -count=1; go test ./... -count=1; go vet ./...; go run ./tools/repolint; git diff --check
2026-08-09 21:54:47 +08:00
SivanColaandclearnature d68236de68 fix(agent): bound shared-window output budgets
DeepSeek can reject a request with HTTP 400 when the prompt and configured completion budget each fit independently but exceed the shared context window together.

Clip the final ordinary and summarizer requests against a calibrated prompt estimate, preserve the bounded full-fold compaction path, and reset atomically paired calibration state when sessions change. Independent-ceiling and unknown provider modes remain unchanged.

Co-authored-by: clearnature <[email protected]>
2026-08-09 21:31:46 +08:00
SivanCola 9754fc395f fix(desktop): budget durable sidecar lock bursts
Problem:
Windows CI showed that 32 concurrent display-sidecar writers could exhaust the 750ms lock deadline after sidecar publication gained an fsync.

Root cause:
filelock serializes in-process waiters, but every waiter starts its deadline before entering that queue. Durable Windows writes can therefore consume the full budget of tail writers even without external contention.

Fix:
Use a shared five-second bounded lock budget for title, display, and planner-display sidecars. This keeps contention bounded while accommodating durable burst writes on slower disks.

Verification:
- cd desktop && go test -race . -run 'TestSetSessionTitleSerializesConcurrentUpdates|TestRecordSessionDisplaySerializesConcurrentTabs|TestRecordSessionPlannerDisplayConcurrentPreservesEverySession' -count=10
- cd desktop && go vet ./...
- cd desktop && GOOS=windows CGO_ENABLED=0 go test -c -o /tmp/reasonix-desktop-windows.test.exe .
- go run ./tools/repolint
- git diff --check
2026-08-09 21:28:33 +08:00
esengine fc8312b052 fix(completion): what the first real run showed — noise, and a card that sprawled
Running it against a real provider found two things no test had:

The agent ran the suite, edited, then re-ran with a different command form. The
receipt listed both earlier forms as "verified before the last change" — true,
and useless: a fresh run had already proven the tree. Superseded commands are
now a gap only while nothing fresh has passed. Listing them after a green run
is the pedantry that teaches people to skip receipts.

Real agent commands carry an absolute cd prefix and a shell fallback. Printed
whole they wrapped four times and broke the card's own box — the wall of text
it exists to replace. The cd prefix is dropped (the run is already there) and
the line is clipped to the row. The budget is the row, not the detail: a
per-detail cap that fits English overflows the moment a longer translation
prefixes it, which is exactly what the zh catalogue did.
2026-08-09 06:13:36 -07:00
SivanCola 9527016138 style(shell): adopt Go 1.26 concurrency idioms
Problem: the CI-pinned modernize linter rejected two equivalent legacy forms in the new shell output cap and its concurrency test.

Fix: use max for the non-negative head bound and sync.WaitGroup.Go for test goroutines.

Verification: golangci-lint v2.12.2 run --timeout=5m; go test ./internal/shellrun ./internal/tool/builtin; go test -race ./internal/shellrun; go run ./tools/repolint
2026-08-09 21:05:38 +08:00
SivanCola 781dd38fb8 test(desktop): respect Windows file mode semantics
Problem:
The sidecar durability regression asserted Unix permission bits on Windows, where Go reports regular files as 0666 and access control is governed by Windows ACLs.

Root cause:
The test coupled the cross-platform durable-write contract to a Unix-only os.FileMode representation.

Fix:
Keep the AtomicWriteFile assertion on every platform and restrict the 0600 mode-bit assertion to non-Windows systems.

Verification:
- cd desktop && go test -race . -run 'TestSessionSidecarSavesUseDurableAtomicWrite|TestSetSessionTitle' -count=1
- GOOS=windows CGO_ENABLED=0 go test -c -o /tmp/reasonix-desktop-windows.test.exe .
- go run ./tools/repolint
- git diff --check
2026-08-09 21:04:59 +08:00
SivanCola af2b63fb0a refactor(session): extract listing helpers
Problem: the session safety adaptation pushed internal/agent/save.go beyond its existing repolint file-size budget.

Root cause: new listing helpers were added to an already baselined large persistence file.

Fix: move only the new mapping, artifact probe, and error-aware preview helpers into a focused package file without changing behavior or lint baselines.

Verification: go run ./tools/repolint; go test ./internal/agent; go test -race ./internal/agent; git diff --check
2026-08-09 20:59:08 +08:00
SivanCola 5f74632f03 test(desktop): isolate sidecar durability coverage
Problem:
The new session-sidecar regressions pushed an already large legacy test file further beyond the repository test-file-size budget.

Root cause:
The focused coverage was added beside older session tests instead of in its own owner-level test file.

Fix:
Move the new durability and concurrent-title tests unchanged into session_sidecar_durability_test.go.

Verification:
- go run ./tools/repolint
- cd desktop && go test -race . -run 'TestSessionSidecarSavesUseDurableAtomicWrite|TestSetSessionTitle|TestRecordSessionDisplaySerializesConcurrentTabs|TestRecordSessionPlannerDisplayConcurrentPreservesEverySession' -count=1
- git diff --check
2026-08-09 20:54:29 +08:00
esengine f367862cb7 bench(longrun): the failed-leg test wrote its own stub and skipped the skip
Windows CI failed with "invocations = 0, want 1". The stub was the problem, not
the code: a #!/usr/bin/env bash script cannot be executed there, so the child
never ran, the log was never written, and the assertion blamed runSegments for
a shell that had not started.

fakeAgent already guarded against this; the failed-leg test wrote its own stub
and inherited nothing. The guard is now a named helper both call, so the next
test that writes a stub has one obvious thing to reach for.
2026-08-09 05:51:51 -07:00
SivanColaandipanta 8c7ca1ba31 fix(desktop): make session sidecars durable
Problem:
Desktop title and display sidecars published temporary files without fsync, and concurrent title read-modify-write operations could overwrite each other.

Root cause:
The sidecars duplicated a manual temp-write-close-replace sequence instead of the shared durable atomic writer. Unlike display sidecars, the legacy title map also had no cross-process serialization.

Fix:
Route title, display, and planner-display sidecars through fileutil.AtomicWriteFile, serialize title mutations with a bounded file lock, and cover the durable helper, lock boundary, and concurrent updates.

Verification:
- go test ./internal/fileutil -count=1
- cd desktop && go test ./... -count=1
- cd desktop && go test -race . -run 'TestSessionSidecarSavesUseDurableAtomicWrite|TestSetSessionTitle|TestRecordSessionDisplaySerializesConcurrentTabs|TestRecordSessionPlannerDisplayConcurrentPreservesEverySession' -count=1
- cd desktop && go vet ./...
- git diff --check

Co-authored-by: ipanta <[email protected]>
2026-08-09 20:50:53 +08:00
SivanColaandmyipanta 5e1c054084 fix(session): harden sidecar durability and recovery visibility
Problem: desktop title/display sidecars could be renamed without flushing their temporary files, and malformed non-empty sessions could be cached as empty and disappear from history.

Root cause: three desktop writers duplicated a close-and-replace sequence instead of the durable atomic helper, while preview decoding discarded errors before backfilling zero turns.

Fix: route title, display, and planner-display sidecars through AtomicWriteFile; preserve preview errors, avoid authoritative-empty backfills on decode failure, and list unreadable non-empty artifacts with a recovery warning.

Verification: go test ./internal/agent ./internal/fileutil; cd desktop && go test ./...; go test -race ./internal/agent

Source: adapts the durable-sidecar and corrupt-session visibility objectives from #5841.

Co-authored-by: myipanta <[email protected]>
2026-08-09 20:50:27 +08:00
esengine 1e7dd07028 feat(cli): the receipt, drawn — one quiet line when clean, the gaps when not
The clean case gets exactly one line, and it names its evidence: the user just
watched the tools run, so repeating the work back to them is noise. What no
transcript carries is the absence — the file nobody looked at again, the check
that never passed — so that is what the card spends its lines on.

The list is bounded at five with a stated remainder, because a receipt long
enough to scroll is a receipt nobody reads. An unrecognised gap kind still
reaches the user under its raw name: silently dropping one is precisely the
failure this card exists to prevent.

Gap phrasing is localized in all three catalogues; the details (a path, a
command) are data and pass through. Two paired resets in the TurnDone branch
became one statement so the card's call line did not grow the file.
2026-08-09 05:49:44 -07:00
SivanColaandmyipanta 3e9371225a fix(shell): bound foreground output memory
Problem: foreground commands retained unbounded combined stdout and stderr until the process exited, so noisy commands could exhaust host memory before result truncation.

Root cause: shellrun bounded only the diagnostic tail; its combined buffer had no owner-level limit.

Fix: cap shared foreground output at 10 MiB while preserving opening context and a rolling 64 KiB tail, with concurrent-write regression coverage.

Verification: go test ./internal/shellrun ./internal/tool/builtin; go test -race ./internal/shellrun

Source: adapts the safety objective from #6528.

Co-authored-by: myipanta <[email protected]>
2026-08-09 20:45:43 +08:00
esengine f635378d36 feat(completion): deliver the receipt, so the host's account is the one the user reads
The completion report has been computed since #8035 and seen by nobody: a
content-free audit of counts and enums, useful for benchmarks and invisible in
the product. A receipt naming no file and no command tells a reader nothing,
which is why this one carries content.

It rides TurnDone next to Readiness — the existing precedent for structured
host state on a finished turn — and crosses eventwire, so the JSON stream and
any frontend reading the wire contract get it without a second delivery path.

A turn with nothing to judge produces no receipt at all. An empty card is
noise, and noise is what makes people stop reading receipts.

The report is held on perTurnState, which beginRunTurn zeroes at the start of
each turn: the host reads it while emitting TurnDone, before the next turn can
clear it. perTurnState moved into its own file to make room, and its comment
debt moved with it — run_loop.go essay 45 -> 32, turnstate.go 0 -> 13, exactly
the thirteen lines that changed files, with agent.go and event.go untouched.
The receipt types likewise live in their own files rather than growing two
already-oversized ones.
2026-08-09 05:37:00 -07:00
esengine 0d5258eefd bench(longrun): reach the long-session states instead of waiting for them
A twelve-hour session is not interesting because of its length. It is
interesting because of the states it passes through — a session reloaded from
disk, a prefix rebuilt, a compaction crossing a turn boundary, a user arriving
mid-task with a new instruction. -segments N reaches those directly: leg 1
starts the session, later legs resume it with --continue, unambiguous because
each task already runs in its own home and therefore its own session directory.

A resumed leg is deliberately not handed the task again. Its prompt is a bare
continuation, because a leg that restates the work would hide exactly the
degradation this exists to expose. -steer replaces one leg's continuation with
a user turn, which belongs at a leg boundary rather than at a wall-clock
instant nothing can reproduce.

The step budget is divided, never multiplied: a segmented arm gets the same
max_steps as the control, or it would win by being allowed to work longer.

Each leg writes its own metrics file. They share a work dir, so a single path
would leave the last leg's numbers standing in for the whole run and the
earlier legs' tokens would vanish — a failure invisible in any single run, so
the accounting is pinned by a stub agent that records its argv and writes the
metrics it was told to. That test also proves leg 1 starts fresh, later legs
carry --continue and never restate the task, and a failed leg stops the run
rather than resuming a session the child never finished writing.

Renaming the metrics file broke a hard-coded skip in the checkpoint
snapshotter, which would have let every later leg's file read as a change the
agent made. Harness artifacts are now matched by prefix.
2026-08-09 05:21:39 -07:00
esengine b3381e60d8 bench(longrun): price injected failure, and stop calling a retry a recovery
The fault seam landed with absolute request indices, which quietly excludes the
tasks most likely to expose a brittle harness: a task that only ever makes four
requests never reaches request seven, so it joins the unfaulted group and
nobody notices the suite is measuring less than it claims. A cadence scales
with the run — every:5:500 — and an absolute index still wins where one is
asked for, so targeted failures stay put.

That gives a run its own control group for free. Under a cadence the short
tasks never hit a fault, so the cost of failure is measured against the same
suite, model and day rather than against a separate arm.

The readout separates two things that are easy to conflate. "Retried" is the
meter seeing another request after a failure — a harness that dies on the first
429 never gets there and was never really tested. "Still solved" is the task
landing anyway. A harness can retry forever and still finish nothing, and that
is not recovery.
2026-08-09 05:07:18 -07:00
223 changed files with 12832 additions and 2190 deletions

No files matched your search

+7 -2
View File
@@ -321,7 +321,7 @@ jobs:
- name: Install Wails CLI
run: |
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0
go install "github.com/wailsapp/wails/v2/cmd/wails@$(cat "$GITHUB_WORKSPACE/.wails-version")"
- name: gofmt
run: |
@@ -449,7 +449,7 @@ jobs:
- name: Install Wails CLI
run: |
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0
go install "github.com/wailsapp/wails/v2/cmd/wails@$(cat "$GITHUB_WORKSPACE/.wails-version")"
# go:embed of frontend/dist needs a built frontend before the package
# compiles, same as the ubuntu desktop leg.
@@ -492,6 +492,11 @@ jobs:
- name: repo standards
run: go run ./tools/repolint
- name: wails version pin
run: |
bash scripts/check-wails-pin.sh
bash scripts/check-wails-pin.test.sh
# `make lint-install` reads the same file, so a local run and this job
# cannot drift onto different linter versions.
- id: golangci
+1 -1
View File
@@ -371,7 +371,7 @@ jobs:
rm -f "$RUNNER_TEMP/cert.p12"
- name: Install Wails CLI
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0
run: go install "github.com/wailsapp/wails/v2/cmd/wails@$(cat "$GITHUB_WORKSPACE/.wails-version")"
# Official Windows releases must fail closed. Forks can still exercise
# unsigned packaging without repository secrets, but esengine releases
+1
View File
@@ -0,0 +1 @@
v2.13.0
+2
View File
@@ -8,6 +8,8 @@ everything you need to get started.
- **Go 1.25+** — the project targets the latest stable Go release
- **Git** — for version control
- **Node.js** (optional) — only if you work on the desktop app (`desktop/`)
- **Wails CLI** (desktop only) — run `make wails-install` so the CLI matches
`desktop/go.mod`
## Getting started
+8 -1
View File
@@ -8,8 +8,9 @@ LDFLAGS := -s -w \
GOEXE := $(shell go env GOEXE)
# One pin for the Makefile and the CI lint job; see .github/workflows/ci.yml.
GOLANGCI_VERSION := $(shell cat .golangci-version)
WAILS_VERSION := $(shell tr -d '[:space:]' < .wails-version)
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update test desktop-test desktop-test-short desktop-test-times sdk-test sdk-test-race hooks cross clean
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update wails-install test desktop-test desktop-test-short desktop-test-times sdk-test sdk-test-race hooks cross clean
build:
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
@@ -26,6 +27,8 @@ fmt:
# particular never surface in `go vet`.
lint: lint-go
go run ./tools/repolint
bash scripts/check-wails-pin.sh
bash scripts/check-wails-pin.test.sh
lint-go:
@command -v golangci-lint >/dev/null || { echo "golangci-lint not installed; run: make lint-install"; exit 1; }
@@ -41,6 +44,10 @@ lint-install:
lint-update:
go run ./tools/repolint -update
wails-install:
bash scripts/check-wails-pin.sh
go install "github.com/wailsapp/wails/v2/cmd/wails@$(WAILS_VERSION)"
# Linting one GOOS leaves every //go:build windows and darwin file unchecked.
lint-cross:
@for t in "linux ." "darwin ." "windows ." "linux desktop" "windows desktop"; do \
+71 -4
View File
@@ -146,9 +146,74 @@ metered this way precisely because it *does* self-report: if the proxy and
`.run-metrics.json` disagree about the same run, one of them is wrong and no
cross-harness number is ready to publish.
`-faults` injects provider failures at fixed request indices through the same
proxy, which is what LongRun needs: deterministic 429/500 at the same point of
a run, replayable across harnesses.
## Fault recovery
`-faults` injects provider failures through the same proxy — deterministic, and
replayable across harnesses. Two forms:
- `3:429` — a targeted failure at an exact request.
- `every:5:500` — a cadence. **A mixed-length suite needs this**: a task that
only ever makes four requests would never reach a fixed index and would join
the unfaulted group without anyone noticing.
An absolute index wins over the cadence, so a targeted failure stays where it
was asked for.
```sh
go run ./cmd/e2ebench -meter ~/.reasonix/config.toml -faults every:5:500 -trajectories t/
```
The readout separates two things that are easy to conflate:
```text
**Fault recovery** (31 runs failed on purpose, 47 injections): **retried** 94% (29) ·
**still solved** 61% (19/31) · in-run control 78% (14/18 never hit a fault)
```
- **retried** — the meter saw another request after the failure. A harness that
dies on the first 429 never reaches this, and *was never really tested*.
- **still solved** — the task landed anyway. A harness can retry forever and
still not finish; that is not recovery.
- **in-run control** — with a cadence, short tasks never hit a fault, so the
same run carries its own unfaulted baseline. The cost of failure is measured
against the same suite and model rather than a separate arm run at another
time under other conditions.
## Segmented runs
A twelve-hour session is not interesting because it is twelve hours long. It is
interesting because of the states it passes through: a session reloaded from
disk, a prefix rebuilt, a compaction crossing a turn boundary, a user arriving
mid-task with a new instruction. `-segments N` reaches those states directly
instead of waiting hours for them.
```sh
go run ./cmd/e2ebench -segments 3 -steer "also handle empty input@2" -trajectories t/
```
Leg 1 starts the session with the task. Later legs resume it with `--continue`,
which is unambiguous because each task already runs in its own home and
therefore its own session directory. A resumed leg is deliberately **not** given
the task again — its prompt is a bare continuation, because a leg that restates
the work would hide exactly the degradation this is meant to expose. A `-steer`
entry replaces one leg's continuation with a user turn.
Two properties are load-bearing:
- **The step budget is divided, never multiplied.** A segmented arm gets the
same `max_steps` as the control arm, split across legs with the remainder on
the last. Otherwise the arm would win by being allowed to work longer.
- **Each leg writes its own metrics file.** They share a work dir, so a single
`.run-metrics.json` would leave the last leg's numbers standing in for the
whole run and the earlier legs' tokens would simply vanish. `Segments` in the
JSON records how many legs a run had.
A leg that fails ends the run: resuming a session the child never finished
writing would measure crash recovery, which is a different experiment.
Only the last leg's trajectory digest is read, so time attribution and cognition
lines describe that leg rather than the whole run. Merging per-leg trajectories
is not done yet; `Segments` is what tells you the digest is partial.
## task.toml schema
@@ -240,7 +305,9 @@ own outcome).
| `-cache` | `cold` | Suite mode: `cold` runs each task as a fresh session (the fair cross-agent comparison arm); `warm` primes the provider prefix cache with a one-step run in the same workdir first, measuring the long-lived-session steady state. Never mix arms in one report — compare them with `-mode compare cold.json warm.json`. |
| `-budget` | `800000` | Abort once total tokens cross this (`0` = no cap). Remaining tasks are reported as skipped. |
| `-meter` | *(off)* | Suite mode: route the benchmarked provider through the neutral measuring proxy, using this `config.toml` as the source. Spend is then counted at the request boundary instead of trusted from the harness. See [Neutral metering](#neutral-metering). |
| `-faults` | *(none)* | Suite mode: inject provider failures at fixed request indices, e.g. `3:429,7:500`. Requires `-meter` — the proxy is the only place a fault can be injected. |
| `-faults` | *(none)* | Suite mode: inject provider failures through the meter — absolute indices (`3:429`) and/or a cadence that scales with the run (`every:5:500`). Requires `-meter`. See [Fault recovery](#fault-recovery). |
| `-segments` | `1` | Suite mode: split each task into N resumed legs (`--continue` between them). The step budget is **divided**, never multiplied. See [Segmented runs](#segmented-runs). |
| `-steer` | *(none)* | Suite mode: deliver a user turn at a leg boundary, e.g. `"also handle empty input@2"`. Requires `-segments` to reach that leg. |
Diff-mode flags:
+25 -4
View File
@@ -84,7 +84,7 @@ func (s *snapshotter) snapshotIfChanged() {
if err := copyDir(s.src, dir); err != nil {
return
}
os.Remove(filepath.Join(dir, ".run-metrics.json"))
dropHarnessArtifacts(dir)
s.taken = append(s.taken, checkpoint{Seq: len(s.taken) + 1, ElapsedMs: elapsed, dir: dir})
}
@@ -113,7 +113,7 @@ func dirSignature(root string) uint64 {
}
return nil
}
if name == ".run-metrics.json" {
if isHarnessArtifact(name) {
return nil
}
info, err := d.Info()
@@ -352,7 +352,7 @@ func firstUsefulMutation(checkpoints []checkpoint, seedDir, finalDir string) int
// solutionFiles maps relative path → final content for every file the run
// created or changed; harness artifacts are not part of anyone's solution.
func solutionFiles(seedDir, finalDir string) map[string]string {
skip := map[string]bool{".run-metrics.json": true, "verify.sh": true}
skip := map[string]bool{"verify.sh": true}
out := map[string]string{}
_ = filepath.WalkDir(finalDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
@@ -365,7 +365,7 @@ func solutionFiles(seedDir, finalDir string) map[string]string {
return nil
}
rel, _ := filepath.Rel(finalDir, path)
if skip[filepath.Base(rel)] {
if base := filepath.Base(rel); skip[base] || isHarnessArtifact(base) {
return nil
}
final, err := os.ReadFile(path)
@@ -395,3 +395,24 @@ func attachSnapshotter(cfg suiteConfig, t task, work string, startedAt time.Time
}
return startSnapshotter(work, dir, startedAt), func() { _ = os.RemoveAll(dir) }
}
// isHarnessArtifact reports whether a work-dir entry belongs to the benchmark
// rather than to anyone's solution. Segmented runs write one metrics file per
// leg, so the name is a prefix match — a hard-coded ".run-metrics.json" would
// let every later leg's file read as a change the agent made.
func isHarnessArtifact(name string) bool {
return strings.HasPrefix(name, ".run-metrics") && strings.HasSuffix(name, ".json")
}
// dropHarnessArtifacts removes them from a snapshot copy.
func dropHarnessArtifacts(dir string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if !e.IsDir() && isHarnessArtifact(e.Name()) {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
}
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"fmt"
"strings"
)
// faultRecovery splits a run by whether the meter actually failed it. With a
// cadence, short tasks never reach a fault and form an in-run control group,
// so the cost of failure is measured against the same suite and model rather
// than against a separate arm run at a different time.
type faultRecovery struct {
faulted, faultedSolved int
unfaulted, unfaultSolved int
keptGoing int // faulted runs that issued another request
injected int
}
func gatherFaultRecovery(results []result) faultRecovery {
var f faultRecovery
for _, r := range results {
if r.Skipped || r.Meter == nil || r.Attempt > 1 {
continue
}
if r.Meter.Injected == 0 {
f.unfaulted++
if r.Passed {
f.unfaultSolved++
}
continue
}
f.faulted++
f.injected += r.Meter.Injected
if r.Meter.RequestsAfterFault > 0 {
f.keptGoing++
}
if r.Passed {
f.faultedSolved++
}
}
return f
}
// renderFaultRecovery prices injected failure. Two different things are worth
// separating: whether the harness kept talking after a failure at all, and
// whether the task still landed. A harness can retry forever and still never
// finish, and that is not recovery.
func renderFaultRecovery(results []result) string {
f := gatherFaultRecovery(results)
if f.faulted == 0 {
return ""
}
var b strings.Builder
fmt.Fprintf(&b, "**Fault recovery** (%d runs failed on purpose, %d injections): **retried** %s (%d) · **still solved** %s (%d/%d)",
f.faulted, f.injected, pct(f.keptGoing, f.faulted), f.keptGoing,
pct(f.faultedSolved, f.faulted), f.faultedSolved, f.faulted)
if f.unfaulted > 0 {
fmt.Fprintf(&b, " · in-run control %s (%d/%d never hit a fault)",
pct(f.unfaultSolved, f.unfaulted), f.unfaultSolved, f.unfaulted)
}
return b.String() + "\n\n"
}
+106
View File
@@ -0,0 +1,106 @@
package main
import (
"strings"
"testing"
)
func faultedRun(id string, injected, afterFault int, passed bool) result {
r := result{task: task{ID: id}, Attempt: 1, Passed: passed}
r.Meter = &meterUsage{Requests: 10, Injected: injected, RequestsAfterFault: afterFault}
return r
}
func TestFaultCadenceScalesWithTheRun(t *testing.T) {
script, err := parseFaultScript("every:5:500")
if err != nil {
t.Fatalf("parse: %v", err)
}
for _, index := range []int{5, 10, 15} {
if status, ok := script.statusFor(index); !ok || status != 500 {
t.Fatalf("request %d = %d/%v, want an injected 500", index, status, ok)
}
}
for _, index := range []int{1, 4, 6, 9} {
if _, ok := script.statusFor(index); ok {
t.Fatalf("request %d must be forwarded", index)
}
}
}
// An absolute index is a targeted failure; a cadence is background pressure.
// The targeted one must stay exactly where it was asked for.
func TestAbsoluteFaultWinsOverTheCadence(t *testing.T) {
script, err := parseFaultScript("10:429,every:5:500")
if err != nil {
t.Fatalf("parse: %v", err)
}
if status, _ := script.statusFor(10); status != 429 {
t.Fatalf("request 10 = %d, want the targeted 429", status)
}
if status, _ := script.statusFor(5); status != 500 {
t.Fatalf("request 5 = %d, want the cadence 500", status)
}
}
func TestFaultScriptRejectsMalformedCadence(t *testing.T) {
for _, bad := range []string{"every:5", "every:0:500", "every:x:500", "every:5:200"} {
if _, err := parseFaultScript(bad); err == nil {
t.Fatalf("%q must be rejected", bad)
}
}
}
func TestMeterCountsRequestsAfterAFault(t *testing.T) {
upstream := okUpstream()
script, _ := parseFaultScript("2:429")
m, base, stop := meterAgainst(t, upstream, script)
defer stop()
for range 4 {
post(t, base, "/chat/completions", `{"model":"x"}`).Body.Close()
}
got := m.snapshot()
if got.Injected != 1 {
t.Fatalf("injected = %d, want 1", got.Injected)
}
if got.RequestsAfterFault != 2 {
t.Fatalf("requests after fault = %d, want 2 (requests 3 and 4)", got.RequestsAfterFault)
}
}
func TestMeterCountsNoRequestsAfterFaultWhenTheHarnessGivesUp(t *testing.T) {
script, _ := parseFaultScript("2:429")
m, base, stop := meterAgainst(t, okUpstream(), script)
defer stop()
for range 2 {
post(t, base, "/chat/completions", `{"model":"x"}`).Body.Close()
}
if got := m.snapshot(); got.RequestsAfterFault != 0 {
t.Fatalf("requests after fault = %d, want 0 — nothing followed the failure", got.RequestsAfterFault)
}
}
func TestFaultRecoverySeparatesRetryingFromSolving(t *testing.T) {
got := renderFaultRecovery([]result{
faultedRun("solved-through", 2, 5, true),
faultedRun("retried-but-lost", 1, 4, false),
faultedRun("gave-up", 1, 0, false),
faultedRun("never-faulted", 0, 0, true),
})
for _, want := range []string{
"**Fault recovery** (3 runs failed on purpose, 4 injections)",
"**retried** 67% (2)",
"**still solved** 33% (1/3)",
"in-run control 100% (1/1 never hit a fault)",
} {
if !strings.Contains(got, want) {
t.Fatalf("recovery line missing %q:\n%s", want, got)
}
}
}
func TestFaultRecoveryRendersNothingWithoutInjections(t *testing.T) {
if got := renderFaultRecovery([]result{faultedRun("clean", 0, 0, true)}); got != "" {
t.Fatalf("want no section when nothing was injected, got:\n%s", got)
}
}
+28 -22
View File
@@ -120,6 +120,9 @@ type result struct {
// and token aggregates instead of being averaged in as zero, which would
// quietly understate every published per-task figure.
Unaccounted bool `json:"unaccounted"`
// Segments is how many resumed legs the run was split into (1 = a single
// leg). Above 1 the trajectory digest covers only the last leg.
Segments int `json:"segments,omitempty"`
// Partial marks accounting recovered from an in-flight snapshot after the
// agent was killed. The numbers are real but stop at the last snapshot, so
// they are counted as lower bounds rather than dropped.
@@ -227,8 +230,7 @@ func main() {
cacheArm := flag.String("cache", "cold", "suite mode: cold (fresh session per task) | warm (prefix-warming one-step run in the same workdir before the graded run)")
effort := flag.String("effort", "", "reasoning effort override passed to the agent (model-specific levels, e.g. disabled|low|high|max); empty = model default")
checkpoints := flag.Bool("checkpoints", false, "suite mode: snapshot the workdir on every change and grade each snapshot offline after the run, yielding first_correct_ms (TTFCS) and post_solve_waste_ms")
meterConfig := flag.String("meter", "", "suite mode: route the benchmarked provider through the neutral measuring proxy, using this config.toml as the source (e.g. ~/.reasonix/config.toml). Spend is then counted at the request boundary instead of trusted from the harness")
faultSpec := flag.String("faults", "", "suite mode: inject provider failures at fixed request indices, e.g. 3:429,7:500 (requires -meter)")
pressure := registerPressureFlags()
policyFlag := flag.String("policy", "", "suite mode: experiment arm — empty (baseline) | ebm (evidence-before-more-mutation nudge) | governor (exploration-phase reasoning governor) | memory-off (hide the memory store: MemoryBench counterfactual arm)")
forkCapture := flag.String("fork-capture", "", "suite mode: capture a fork bundle per task at first EBM eligibility into <dir>/<task-id>")
bundles := flag.String("bundles", "", "fork mode: directory of captured bundles (<task-id>/bundle.json)")
@@ -311,12 +313,12 @@ func main() {
return
}
meterSource, faults := meterSettings(*meterConfig, *faultSpec)
meterSource, faults, segments, steers := pressure.settings()
runSuiteMode(suiteConfig{
bin: *bin, model: *model, profile: profile, arm: arm, budget: *budget,
trajDir: *trajDir, forcePlanner: *forcePlanner, attempts: *attempts,
cacheArm: cache, effort: *effort, checkpoints: *checkpoints, policy: *policyFlag,
forkCapture: *forkCapture, meterConfig: meterSource, meterFaults: faults,
forkCapture: *forkCapture, meterConfig: meterSource, meterFaults: faults, segments: segments, steers: steers,
}, *suite, *taskFilter, *outMD, *outJSON)
}
@@ -455,7 +457,11 @@ type suiteConfig struct {
// meterConfig is the real config.toml whose provider endpoint each run is
// redirected through the neutral meter; empty leaves runs unmetered.
meterConfig string
meterFaults map[int]int
meterFaults faultScript
// segments splits each task into that many resumed legs; steers delivers a
// user turn at a leg boundary. Both are LongRun pressure, not defaults.
segments int
steers map[int]string
}
// runSuite runs each task in order until the token budget is exhausted;
@@ -503,13 +509,15 @@ func runTask(cfg suiteConfig, t task) result {
r.PlanForced = true
}
// The per-leg file names are decided in runSegments; only the directory has
// to exist before the first child starts, and the digest below reads the
// last leg's file.
trajPath := ""
if cfg.trajDir != "" {
if err := os.MkdirAll(cfg.trajDir, 0o755); err != nil {
r.Note = "trajectory dir: " + err.Error()
return r
}
trajPath = filepath.Join(cfg.trajDir, t.ID+".trajectory.jsonl")
}
work, err := os.MkdirTemp("", "e2ebench-"+t.ID+"-")
@@ -533,11 +541,6 @@ func runTask(cfg suiteConfig, t task) result {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(t.TimeoutSec)*time.Second)
defer cancel()
metricsPath := filepath.Join(work, ".run-metrics.json")
args := buildRunTaskArgs(cfg, metricsPath, trajPath, t.MaxSteps, t.Prompt)
cmd := exec.CommandContext(ctx, cfg.bin, args...)
cmd.Dir = work
extraEnv, seedNote := taskExperimentEnv(cfg, t, work)
if seedNote != "" {
r.Note = seedNote
@@ -545,27 +548,18 @@ func runTask(cfg suiteConfig, t task) result {
mtr := attachMeter(cfg, &r)
defer mtr.close()
extraEnv = append(extraEnv, mtr.env...)
if len(extraEnv) > 0 {
cmd.Env = append(os.Environ(), extraEnv...)
}
cmd.Stdout = os.Stderr // stream the run to the job log, keep stdout clean for the report
cmd.Stderr = os.Stderr
cmd.WaitDelay = 10 * time.Second // bound the wait for a stuck child after ctx timeout
startedAt := time.Now()
snap, dropSnapshots := attachSnapshotter(cfg, t, work, startedAt)
defer dropSnapshots()
runErr := cmd.Run()
runErr := runSegments(ctx, cfg, t, work, cfg.trajDir, extraEnv, &r)
r.WallMs = time.Since(startedAt).Milliseconds()
var taken []checkpoint
if snap != nil {
taken = snap.halt()
}
if m, err := readMetrics(metricsPath); err == nil {
r.runMetrics = m
}
mtr.record(&r)
if trajPath != "" {
if trajPath = lastSegmentTrajectory(cfg.trajDir, t.ID, r.Segments); trajPath != "" {
if summary, err := summarizeTrajectory(trajPath); err == nil {
r.Trajectory = summary
}
@@ -635,6 +629,18 @@ func buildRunTaskArgs(cfg suiteConfig, metricsPath, trajectoryPath string, maxSt
return append(args, prompt)
}
// buildSegmentArgs is buildRunTaskArgs for one leg: a resumed leg adds
// --continue, which is unambiguous because each task runs in its own home and
// therefore its own session directory.
func buildSegmentArgs(cfg suiteConfig, seg segment, metricsPath, trajectoryPath string) []string {
args := buildRunTaskArgs(cfg, metricsPath, trajectoryPath, seg.maxSteps, seg.prompt)
if !seg.resume {
return args
}
// The prompt is the last argument; --continue must precede it.
return append(args[:len(args)-1:len(args)-1], "--continue", args[len(args)-1])
}
// warmPrefix primes the provider prefix cache for work's session shape with a
// minimal one-step run before the graded run starts its clock. Its cost is
// deliberately untracked: the warm arm measures a long-lived session's steady
+75 -22
View File
@@ -23,9 +23,10 @@ import (
type meter struct {
upstream *url.URL
client *http.Client
faults map[int]int // 1-based request index -> status to return instead
faults faultScript
mu sync.Mutex
mu sync.Mutex
lastFault int
meterUsage
}
@@ -40,35 +41,80 @@ type meterUsage struct {
CacheMissTokens int `json:"cache_miss_tokens"`
Injected int `json:"injected_faults,omitempty"`
WithoutUsage int `json:"responses_without_usage,omitempty"`
// RequestsAfterFault counts requests issued after the first injected
// failure: the harness's own evidence that it retried rather than died.
RequestsAfterFault int `json:"requests_after_fault,omitempty"`
}
// parseFaultScript reads "3:429,7:500" — inject that status instead of
// forwarding the Nth request. Deterministic by index so a LongRun arm replays
// the same failures across harnesses.
func parseFaultScript(spec string) (map[int]int, error) {
// faultScript decides which requests fail. Absolute indices pin a failure to
// an exact point; a cadence scales with the run, which is what a mixed-length
// suite needs — a task that only ever makes four requests would never reach a
// fixed index, and would silently join the unfaulted control group.
type faultScript struct {
at map[int]int // 1-based request index -> status
everyN int
everyStatus int
}
func (f faultScript) empty() bool { return len(f.at) == 0 && f.everyN == 0 }
// statusFor reports the status to inject instead of forwarding. An absolute
// index wins over the cadence so a targeted failure stays exactly where it was
// asked for.
func (f faultScript) statusFor(index int) (int, bool) {
if status, ok := f.at[index]; ok {
return status, true
}
if f.everyN > 0 && index%f.everyN == 0 {
return f.everyStatus, true
}
return 0, false
}
// parseFaultScript reads "3:429,every:5:500": fail the 3rd request with 429 and
// every 5th with 500. Deterministic either way, so a LongRun arm replays the
// same failures across harnesses.
func parseFaultScript(spec string) (faultScript, error) {
out := faultScript{at: map[int]int{}}
if strings.TrimSpace(spec) == "" {
return nil, nil
return faultScript{}, nil
}
out := map[int]int{}
for field := range strings.SplitSeq(spec, ",") {
idx, status, ok := strings.Cut(strings.TrimSpace(field), ":")
if !ok {
return nil, fmt.Errorf("fault %q: want <request-index>:<status>", field)
field = strings.TrimSpace(field)
if rest, ok := strings.CutPrefix(field, "every:"); ok {
n, status, err := parseFaultPair(field, rest)
if err != nil {
return faultScript{}, err
}
out.everyN, out.everyStatus = n, status
continue
}
i, err := strconv.Atoi(strings.TrimSpace(idx))
if err != nil || i < 1 {
return nil, fmt.Errorf("fault %q: request index must be a positive integer", field)
index, status, err := parseFaultPair(field, field)
if err != nil {
return faultScript{}, err
}
code, err := strconv.Atoi(strings.TrimSpace(status))
if err != nil || code < 400 || code > 599 {
return nil, fmt.Errorf("fault %q: status must be 4xx or 5xx", field)
}
out[i] = code
out.at[index] = status
}
return out, nil
}
func newMeter(upstream string, faults map[int]int) (*meter, error) {
func parseFaultPair(field, pair string) (n, status int, err error) {
left, right, ok := strings.Cut(pair, ":")
if !ok {
return 0, 0, fmt.Errorf("fault %q: want <request-index>:<status> or every:<n>:<status>", field)
}
n, err = strconv.Atoi(strings.TrimSpace(left))
if err != nil || n < 1 {
return 0, 0, fmt.Errorf("fault %q: the request count must be a positive integer", field)
}
status, err = strconv.Atoi(strings.TrimSpace(right))
if err != nil || status < 400 || status > 599 {
return 0, 0, fmt.Errorf("fault %q: status must be 4xx or 5xx", field)
}
return n, status, nil
}
func newMeter(upstream string, faults faultScript) (*meter, error) {
u, err := url.Parse(strings.TrimSuffix(strings.TrimSpace(upstream), "/"))
if err != nil || u.Scheme == "" || u.Host == "" {
return nil, fmt.Errorf("meter upstream %q is not an absolute URL", upstream)
@@ -98,9 +144,16 @@ func (m *meter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
m.mu.Lock()
m.Requests++
index := m.Requests
status, faulted := m.faults[index]
if faulted {
status, faulted := m.faults.statusFor(index)
switch {
case faulted:
m.Injected++
m.lastFault = index
case m.lastFault > 0:
// Evidence the harness kept going after being failed. A harness that
// gives up on the first 429 never reaches here, and that is the
// difference between "recovered" and "was never really tested".
m.RequestsAfterFault++
}
m.mu.Unlock()
+19 -11
View File
@@ -10,7 +10,7 @@ import (
"testing"
)
func meterAgainst(t *testing.T, upstream http.Handler, faults map[int]int) (*meter, string, func()) {
func meterAgainst(t *testing.T, upstream http.Handler, faults faultScript) (*meter, string, func()) {
t.Helper()
up := httptest.NewServer(upstream)
m, err := newMeter(up.URL, faults)
@@ -38,7 +38,7 @@ func TestMeterCountsNonStreamingUsage(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"choices":[{"message":{"content":"hi"}}],"usage":{"prompt_tokens":100,"completion_tokens":20,"prompt_cache_hit_tokens":64,"prompt_cache_miss_tokens":36}}`)
})
m, base, stop := meterAgainst(t, upstream, nil)
m, base, stop := meterAgainst(t, upstream, faultScript{})
defer stop()
resp := post(t, base, "/chat/completions", `{"model":"x"}`)
@@ -64,7 +64,7 @@ func TestMeterReadsOpenAICachedTokensSpelling(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"usage":{"prompt_tokens":90,"completion_tokens":5,"prompt_tokens_details":{"cached_tokens":30}}}`)
})
m, base, stop := meterAgainst(t, upstream, nil)
m, base, stop := meterAgainst(t, upstream, faultScript{})
defer stop()
post(t, base, "/chat/completions", `{"model":"x"}`).Body.Close()
@@ -81,7 +81,7 @@ func TestMeterCountsStreamedUsageAndForwardsFrames(t *testing.T) {
io.WriteString(w, "data: {\"usage\":{\"prompt_tokens\":7,\"completion_tokens\":3}}\n\n")
io.WriteString(w, "data: [DONE]\n\n")
})
m, base, stop := meterAgainst(t, upstream, nil)
m, base, stop := meterAgainst(t, upstream, faultScript{})
defer stop()
resp := post(t, base, "/chat/completions", `{"model":"x","stream":true}`)
@@ -104,7 +104,7 @@ func TestMeterOptsStreamedRequestsIntoUsage(t *testing.T) {
w.Header().Set("Content-Type", "text/event-stream")
io.WriteString(w, "data: [DONE]\n\n")
})
_, base, stop := meterAgainst(t, upstream, nil)
_, base, stop := meterAgainst(t, upstream, faultScript{})
defer stop()
post(t, base, "/chat/completions", `{"model":"x","stream":true}`).Body.Close()
@@ -125,7 +125,7 @@ func TestMeterLeavesNonStreamedRequestsAlone(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
})
_, base, stop := meterAgainst(t, upstream, nil)
_, base, stop := meterAgainst(t, upstream, faultScript{})
defer stop()
post(t, base, "/chat/completions", `{"model":"x"}`).Body.Close()
@@ -139,7 +139,7 @@ func TestMeterReportsResponsesWithoutUsage(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"choices":[]}`)
})
m, base, stop := meterAgainst(t, upstream, nil)
m, base, stop := meterAgainst(t, upstream, faultScript{})
defer stop()
post(t, base, "/chat/completions", `{"model":"x"}`).Body.Close()
@@ -155,7 +155,7 @@ func TestMeterInjectsFaultsByRequestIndex(t *testing.T) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
})
m, base, stop := meterAgainst(t, upstream, map[int]int{2: 429})
m, base, stop := meterAgainst(t, upstream, faultScript{at: map[int]int{2: 429}})
defer stop()
for i := range 3 {
@@ -182,10 +182,10 @@ func TestParseFaultScript(t *testing.T) {
if err != nil {
t.Fatalf("parse: %v", err)
}
if got[3] != 429 || got[7] != 500 || len(got) != 2 {
if got.at[3] != 429 || got.at[7] != 500 || len(got.at) != 2 {
t.Fatalf("faults = %v", got)
}
if got, err := parseFaultScript(""); err != nil || got != nil {
if got, err := parseFaultScript(""); err != nil || !got.empty() {
t.Fatalf("empty spec = %v, %v", got, err)
}
for _, bad := range []string{"3", "0:429", "3:200", "x:429", "3:999"} {
@@ -196,7 +196,15 @@ func TestParseFaultScript(t *testing.T) {
}
func TestNewMeterRejectsRelativeUpstream(t *testing.T) {
if _, err := newMeter("/v1", nil); err == nil {
if _, err := newMeter("/v1", faultScript{}); err == nil {
t.Fatal("a relative upstream must be rejected")
}
}
// okUpstream is a minimal usage-reporting upstream for fault tests.
func okUpstream() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
io.WriteString(w, `{"usage":{"prompt_tokens":1,"completion_tokens":1}}`)
})
}
+2 -2
View File
@@ -9,13 +9,13 @@ import (
// meterSettings validates the metering flags together: a fault script has
// nowhere to be injected without the proxy, so asking for one without -meter
// is a mistake worth stopping for rather than silently ignoring.
func meterSettings(configPath, faultSpec string) (string, map[int]int) {
func meterSettings(configPath, faultSpec string) (string, faultScript) {
faults, err := parseFaultScript(faultSpec)
if err != nil {
fmt.Fprintln(os.Stderr, "faults:", err)
os.Exit(2)
}
if len(faults) > 0 && strings.TrimSpace(configPath) == "" {
if !faults.empty() && strings.TrimSpace(configPath) == "" {
fmt.Fprintln(os.Stderr, "faults: -faults needs -meter; the proxy is where a fault can be injected")
os.Exit(2)
}
+1
View File
@@ -193,6 +193,7 @@ func renderBody(results []result) string {
b.WriteString(perSolvedLine(s))
b.WriteString(requestsBySourceLine(s.bySource))
b.WriteString(renderMeterAccounting(results))
b.WriteString(renderFaultRecovery(results))
b.WriteString(renderTimeAttribution(results))
b.WriteString(renderSolveProfiles(results))
b.WriteString(renderToolSurface(results))
+110
View File
@@ -0,0 +1,110 @@
package main
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
)
// runSegments drives a task's legs and folds their accounting into one result.
// Each leg writes its own metrics file: sharing one path would let the last
// leg's numbers stand in for the whole run, and the tokens the earlier legs
// spent would simply vanish. Only the final leg's trajectory digest is kept;
// Segments records how many legs it does not cover.
func runSegments(ctx context.Context, cfg suiteConfig, t task, work, trajDir string, env []string, r *result) error {
segs := planSegments(t, cfg.segments, cfg.steers)
r.Segments = len(segs)
var runErr error
for _, seg := range segs {
metricsPath := filepath.Join(work, fmt.Sprintf(".run-metrics-%d.json", seg.index))
trajPath := segmentTrajectoryPath(trajDir, t.ID, seg, len(segs))
args := buildSegmentArgs(cfg, seg, metricsPath, trajPath)
cmd := exec.CommandContext(ctx, cfg.bin, args...)
cmd.Dir = work
if len(env) > 0 {
cmd.Env = append(os.Environ(), env...)
}
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
cmd.WaitDelay = 10 * time.Second
runErr = cmd.Run()
if m, err := readMetrics(metricsPath); err == nil {
foldSegmentMetrics(r, m, seg.index)
}
// A leg that died takes the run with it: resuming a session the child
// never finished writing would measure the harness's crash recovery,
// which is a different experiment.
if runErr != nil || ctx.Err() != nil {
break
}
}
return runErr
}
// segmentTrajectoryPath keeps one file per leg so a resumed leg cannot truncate
// the record of the one before it.
func segmentTrajectoryPath(dir, id string, seg segment, total int) string {
if dir == "" {
return ""
}
if total < 2 {
return filepath.Join(dir, id+".trajectory.jsonl")
}
return filepath.Join(dir, fmt.Sprintf("%s.seg%d.trajectory.jsonl", id, seg.index))
}
// lastSegmentTrajectory names the file whose digest represents the run. Only
// the final leg's is read; Segments in the JSON says how many were not.
func lastSegmentTrajectory(dir, id string, segments int) string {
if dir == "" {
return ""
}
if segments < 2 {
return filepath.Join(dir, id+".trajectory.jsonl")
}
return filepath.Join(dir, fmt.Sprintf("%s.seg%d.trajectory.jsonl", id, segments))
}
// foldSegmentMetrics adds one leg's spend to the run. The first leg's metrics
// establish the non-additive fields (currency, outcome); later legs contribute
// their totals, and the last leg's outcome wins because it is the one that
// ended the run.
func foldSegmentMetrics(r *result, m runMetrics, index int) {
if index == 1 {
r.runMetrics = m
return
}
r.PromptTokens += m.PromptTokens
r.CompletionTokens += m.CompletionTokens
r.CacheHitTokens += m.CacheHitTokens
r.CacheMissTokens += m.CacheMissTokens
r.Cost += m.Cost
r.Steps += m.Steps
r.Compactions += m.Compactions
r.ToolCalls += m.ToolCalls
r.ToolFailures += m.ToolFailures
r.SubagentToolCalls += m.SubagentToolCalls
r.Retries += m.Retries
r.Complete = m.Complete
if m.Outcome != "" {
r.Outcome = m.Outcome
}
for name, n := range m.ToolCallsByName {
if r.ToolCallsByName == nil {
r.ToolCallsByName = map[string]int{}
}
r.ToolCallsByName[name] += n
}
for reason, n := range m.PrefixChangeReasonCounts {
if r.PrefixChangeReasonCounts == nil {
r.PrefixChangeReasonCounts = map[string]int{}
}
r.PrefixChangeReasonCounts[reason] += n
}
accumulateSources(r.UsageBySource, m.UsageBySource)
}
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
)
// requireShellStub skips where a #!/usr/bin/env bash stub cannot be executed.
// Every test that writes its own stub must call it: exec fails silently enough
// on Windows that the assertion which follows blames the code instead.
func requireShellStub(t *testing.T) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("the stub agent is a shell script")
}
}
// fakeAgent stands in for the reasonix binary: it records every invocation's
// argv and writes the metrics file it was told to, making segment accounting
// verifiable without a provider. The failure it guards — a later leg replacing
// an earlier leg's numbers — is invisible in any single run.
func fakeAgent(t *testing.T, promptTokens, completionTokens int) (bin, argvLog string) {
t.Helper()
requireShellStub(t)
dir := t.TempDir()
argvLog = filepath.Join(dir, "argv.log")
bin = filepath.Join(dir, "fake-agent")
script := `#!/usr/bin/env bash
printf '%s\n' "$*" >> ` + argvLog + `
metrics=""
while [ $# -gt 0 ]; do
case "$1" in
--metrics) metrics="$2"; shift 2;;
*) shift;;
esac
done
[ -n "$metrics" ] && cat > "$metrics" <<JSON
{"prompt_tokens":` + strconv.Itoa(promptTokens) + `,"completion_tokens":` + strconv.Itoa(completionTokens) + `,"steps":2,"cost":0.5,"currency":"USD","complete":true,"tool_calls":3}
JSON
exit 0
`
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
return bin, argvLog
}
func TestSegmentedRunAddsEveryLegsSpend(t *testing.T) {
bin, argvLog := fakeAgent(t, 100, 20)
cfg := suiteConfig{bin: bin, segments: 3}
task := task{ID: "seg", Prompt: "fix it", MaxSteps: 9, TimeoutSec: 60}
var r result
if err := runSegments(t.Context(), cfg, task, t.TempDir(), "", nil, &r); err != nil {
t.Fatalf("runSegments: %v", err)
}
if r.Segments != 3 {
t.Fatalf("segments = %d, want 3", r.Segments)
}
if r.PromptTokens != 300 || r.CompletionTokens != 60 {
t.Fatalf("tokens = %d/%d, want 300/60 — a later leg replaced an earlier one instead of adding",
r.PromptTokens, r.CompletionTokens)
}
if r.Steps != 6 || r.ToolCalls != 9 {
t.Fatalf("steps=%d tools=%d, want 6/9 summed across legs", r.Steps, r.ToolCalls)
}
if r.Cost != 1.5 {
t.Fatalf("cost = %v, want 1.5", r.Cost)
}
if r.Currency != "USD" || !r.Complete {
t.Fatalf("non-additive fields lost: %+v", r.runMetrics)
}
argv, err := os.ReadFile(argvLog)
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(argv)), "\n")
if len(lines) != 3 {
t.Fatalf("invocations = %d, want 3", len(lines))
}
if strings.Contains(lines[0], "--continue") {
t.Fatalf("leg 1 must start a fresh session: %s", lines[0])
}
for i, line := range lines[1:] {
if !strings.Contains(line, "--continue") {
t.Fatalf("leg %d must resume: %s", i+2, line)
}
if strings.Contains(line, "fix it") {
t.Fatalf("leg %d restated the task: %s", i+2, line)
}
}
}
func TestSegmentedRunKeepsPerLegMetricsApart(t *testing.T) {
bin, _ := fakeAgent(t, 10, 1)
work := t.TempDir()
var r result
if err := runSegments(t.Context(), suiteConfig{bin: bin, segments: 2}, task{ID: "x", Prompt: "p", MaxSteps: 4, TimeoutSec: 60}, work, "", nil, &r); err != nil {
t.Fatalf("runSegments: %v", err)
}
for _, name := range []string{".run-metrics-1.json", ".run-metrics-2.json"} {
if _, err := os.Stat(filepath.Join(work, name)); err != nil {
t.Fatalf("%s missing: one shared path would have let the last leg overwrite the rest", name)
}
}
}
func TestSegmentedRunStopsAtTheFirstFailedLeg(t *testing.T) {
requireShellStub(t)
dir := t.TempDir()
bin := filepath.Join(dir, "failing-agent")
log := filepath.Join(dir, "calls.log")
script := "#!/usr/bin/env bash\necho x >> " + log + "\nexit 3\n"
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
var r result
err := runSegments(t.Context(), suiteConfig{bin: bin, segments: 3}, task{ID: "x", Prompt: "p", MaxSteps: 6, TimeoutSec: 60}, t.TempDir(), "", nil, &r)
if err == nil {
t.Fatal("a failed leg must surface as the run's error")
}
calls, _ := os.ReadFile(log)
if got := len(strings.Fields(string(calls))); got != 1 {
t.Fatalf("invocations = %d, want 1 — resuming a session the child never finished is a different experiment", got)
}
}
func TestUnsegmentedRunKeepsTheOriginalTrajectoryPath(t *testing.T) {
if got := lastSegmentTrajectory("t", "task-a", 1); got != filepath.Join("t", "task-a.trajectory.jsonl") {
t.Fatalf("path = %q, want the unsegmented name", got)
}
if got := lastSegmentTrajectory("t", "task-a", 3); got != filepath.Join("t", "task-a.seg3.trajectory.jsonl") {
t.Fatalf("path = %q, want the last leg's file", got)
}
if got := lastSegmentTrajectory("", "task-a", 3); got != "" {
t.Fatalf("path = %q, want empty when trajectories are off", got)
}
}
+117
View File
@@ -0,0 +1,117 @@
package main
import (
"flag"
"fmt"
"os"
"strconv"
"strings"
)
// segment is one leg of a task: leg 1 starts the session, later legs resume it.
// Segmenting reaches the states that only appear in a long session — reload,
// prefix reconstruction, compaction across a boundary — without waiting hours
// for them to arrive on their own.
type segment struct {
index int
maxSteps int
prompt string
resume bool
}
// continuationPrompt is deliberately empty of new instruction: a resumed leg
// must exercise the session's own memory of the task, not be handed the task
// again. A prompt that restates the work would hide exactly the degradation
// this measures.
const continuationPrompt = "Continue from where you left off."
// planSegments splits a task into count legs, dividing its step budget and
// giving the remainder to the last leg so the total never exceeds max_steps.
// A steer entry replaces a leg's continuation prompt.
func planSegments(t task, count int, steers map[int]string) []segment {
if count < 2 {
return []segment{{index: 1, maxSteps: t.MaxSteps, prompt: t.Prompt}}
}
per := t.MaxSteps / count
out := make([]segment, 0, count)
for i := 1; i <= count; i++ {
steps := per
if i == count {
steps = t.MaxSteps - per*(count-1)
}
seg := segment{index: i, maxSteps: steps, prompt: continuationPrompt, resume: true}
if i == 1 {
seg.prompt, seg.resume = t.Prompt, false
}
if steer, ok := steers[i]; ok {
seg.prompt = steer
}
out = append(out, seg)
}
return out
}
// segmentSettings validates the two LongRun pressure flags together: a steer
// aimed past the last leg would never be delivered, and silently dropping a
// user turn is exactly the kind of quiet no-op a benchmark must not have.
func segmentSettings(segments int, spec string) map[int]string {
steers, err := parseSteers(spec)
if err != nil {
fmt.Fprintln(os.Stderr, "steer:", err)
os.Exit(2)
}
for leg := range steers {
if leg > segments {
fmt.Fprintf(os.Stderr, "steer: leg %d needs -segments %d or more; the run has %d\n", leg, leg, segments)
os.Exit(2)
}
}
return steers
}
// parseSteers reads "message@2,another@3": deliver that message as leg N's
// prompt. Steering is a user turn arriving mid-task, so it belongs to a leg
// boundary rather than to a wall-clock instant nothing can reproduce.
func parseSteers(spec string) (map[int]string, error) {
if strings.TrimSpace(spec) == "" {
return nil, nil
}
out := map[int]string{}
for field := range strings.SplitSeq(spec, ",") {
message, at, ok := strings.Cut(strings.TrimSpace(field), "@")
if !ok {
return nil, fmt.Errorf("steer %q: want <message>@<segment>", field)
}
leg, err := strconv.Atoi(strings.TrimSpace(at))
if err != nil || leg < 2 {
return nil, fmt.Errorf("steer %q: the segment must be 2 or later — leg 1 carries the task itself", field)
}
if strings.TrimSpace(message) == "" {
return nil, fmt.Errorf("steer %q: the message is empty", field)
}
out[leg] = strings.TrimSpace(message)
}
return out, nil
}
// pressureFlags groups the LongRun knobs: neutral metering, injected faults,
// and segmented resume with steering. They are registered and validated
// together because they only make sense together.
type pressureFlags struct {
meter, faults, steer *string
segments *int
}
func registerPressureFlags() pressureFlags {
return pressureFlags{
meter: flag.String("meter", "", "suite mode: route the benchmarked provider through the neutral measuring proxy, using this config.toml as the source (e.g. ~/.reasonix/config.toml). Spend is then counted at the request boundary instead of trusted from the harness"),
faults: flag.String("faults", "", "suite mode: inject provider failures through the meter — absolute indices (3:429) and/or a cadence that scales with the run (every:5:500). Requires -meter; the report gains a fault-recovery readout"),
steer: flag.String("steer", "", "suite mode: deliver a user turn at a leg boundary, e.g. \"also handle empty input@2\" (requires -segments >= that leg)"),
segments: flag.Int("segments", 1, "suite mode: split each task into N resumed legs (--continue between them), reaching reload and compaction pressure without waiting hours for it. The step budget is divided, never multiplied"),
}
}
func (p pressureFlags) settings() (config string, faults faultScript, segments int, steers map[int]string) {
config, faults = meterSettings(*p.meter, *p.faults)
return config, faults, *p.segments, segmentSettings(*p.segments, *p.steer)
}
+80
View File
@@ -0,0 +1,80 @@
package main
import (
"strings"
"testing"
)
func TestPlanSegmentsIsOneLegByDefault(t *testing.T) {
task := task{ID: "x", Prompt: "fix it", MaxSteps: 12}
for _, count := range []int{0, 1} {
got := planSegments(task, count, nil)
if len(got) != 1 || got[0].resume || got[0].prompt != "fix it" || got[0].maxSteps != 12 {
t.Fatalf("count %d = %+v, want the unsegmented run", count, got)
}
}
}
// The step budget is the task's contract; splitting must not quietly hand a
// segmented arm more work than the control arm gets.
func TestPlanSegmentsPreservesTheStepBudget(t *testing.T) {
for _, steps := range []int{12, 13, 20, 7} {
got := planSegments(task{Prompt: "p", MaxSteps: steps}, 3, nil)
total := 0
for _, seg := range got {
if seg.maxSteps < 1 {
t.Fatalf("steps %d produced an empty leg: %+v", steps, got)
}
total += seg.maxSteps
}
if total != steps {
t.Fatalf("steps %d split into %d, want the budget preserved", steps, total)
}
}
}
func TestPlanSegmentsResumesEveryLegAfterTheFirst(t *testing.T) {
got := planSegments(task{Prompt: "fix it", MaxSteps: 9}, 3, nil)
if len(got) != 3 {
t.Fatalf("legs = %d, want 3", len(got))
}
if got[0].resume || got[0].prompt != "fix it" {
t.Fatalf("leg 1 = %+v, want the task itself on a fresh session", got[0])
}
for _, seg := range got[1:] {
if !seg.resume {
t.Fatalf("leg %d must resume: %+v", seg.index, seg)
}
if strings.Contains(seg.prompt, "fix it") {
t.Fatalf("leg %d restates the task, which hides the degradation being measured: %q", seg.index, seg.prompt)
}
}
}
func TestPlanSegmentsDeliversSteering(t *testing.T) {
steers, err := parseSteers("also handle empty input@2")
if err != nil {
t.Fatalf("parseSteers: %v", err)
}
got := planSegments(task{Prompt: "fix it", MaxSteps: 9}, 3, steers)
if got[1].prompt != "also handle empty input" {
t.Fatalf("leg 2 prompt = %q, want the steering message", got[1].prompt)
}
if !got[1].resume {
t.Fatalf("a steered leg still resumes: %+v", got[1])
}
if got[2].prompt != continuationPrompt {
t.Fatalf("leg 3 = %q, want the plain continuation", got[2].prompt)
}
}
func TestParseSteersRejectsLegOneAndMalformedSpecs(t *testing.T) {
for _, bad := range []string{"msg@1", "msg@0", "msg", "@2", "msg@x"} {
if _, err := parseSteers(bad); err == nil {
t.Fatalf("%q must be rejected", bad)
}
}
if got, err := parseSteers(""); err != nil || got != nil {
t.Fatalf("empty spec = %v, %v", got, err)
}
}
+2 -1
View File
@@ -35,7 +35,8 @@ vet / test ./...` skip this directory, while the import path stays under
- Go (matches the parent module).
- Node + **pnpm** (`npm i -g pnpm`).
- Wails CLI: `go install github.com/wailsapp/wails/v2/cmd/wails@latest`
- Wails CLI matching the library: run `make wails-install` from the repository
root. The target reads the shared `.wails-version` pin.
- Platform webview libs: macOS ships WebKit; Windows needs the Edge **WebView2**
runtime; Linux needs `libgtk-3-dev` plus WebKitGTK. The default build links
against **WebKitGTK 4.0**; distros that only ship **4.1** (Fedora 40+, Ubuntu
+5 -111
View File
@@ -45,7 +45,6 @@ import (
"reasonix/internal/fileref"
fileenc "reasonix/internal/fileutil/encoding"
"reasonix/internal/i18n"
"reasonix/internal/jobs"
"reasonix/internal/mcpdiag"
"reasonix/internal/mcpregistry"
"reasonix/internal/memory"
@@ -795,6 +794,9 @@ func (a *App) restoreOrBuildTabs() {
// freshly written config (including the user's default_model) is
// picked up by Load instead of falling back to built-in defaults.
_, _ = config.MigrateLegacyIfNeeded()
if err := reconcileTopicArchiveMetadataPending(a.deleteTopic); err != nil {
slog.Warn("desktop: topic archive metadata reconciliation remains pending")
}
f := loadTabsFile()
_, _ = recoverLegacyProjectSidebarRoots(f)
_, _ = config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath())
@@ -2480,32 +2482,7 @@ func removeDesktopSessionArtifacts(path string) error {
if err != nil {
return err
}
defer guard.Release()
if err := invalidateTopicDirMarkers(filepath.Dir(path)); err != nil {
return err
}
defer invalidateTopicSessionIndexForPath(path)
for _, p := range sessionOwnedArtifactPaths(path) {
if strings.TrimSpace(p) == "" {
continue
}
if err := os.RemoveAll(p); err != nil && !os.IsNotExist(err) {
return err
}
}
if err := guard.RemoveSidecarsAndRelease(); err != nil {
return err
}
if err := removeSessionDisplay(filepath.Dir(path), path); err != nil {
return err
}
if err := removeSessionPlannerDisplay(filepath.Dir(path), path); err != nil {
return err
}
if err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {
return err
}
return agent.ClearCleanupPending(path)
return removeDesktopSessionArtifactsWithGuard(path, guard)
}
// CheckpointMeta summarises one rewind point (a user turn) for the desktop.
@@ -3510,53 +3487,6 @@ func (a *App) sessionDeleteFallbackTarget(target fallbackRuntimeTarget) fallback
return target
}
func (a *App) removeTopicRuntimeBindings(topicID string) ([]removedSessionRuntime, fallbackRuntimeTarget) {
var removed []removedSessionRuntime
var fallback fallbackRuntimeTarget
a.mu.Lock()
for id, tab := range a.tabs {
if tab == nil || tab.TopicID != topicID {
continue
}
sessionDir := tabRuntimeSessionDir(tab)
sessionPath := canonicalTabSessionPath(tab.currentSessionPath())
if len(removed) == 0 {
fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}
}
removed = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))
a.markTabRemovedLocked(tab)
delete(a.tabs, id)
a.removeTabOrderLocked(id)
if a.activeTabID == id {
a.activeTabID = ""
}
}
for key, tab := range a.detachedSessions {
if tab == nil || tab.TopicID != topicID {
continue
}
sessionDir := tabRuntimeSessionDir(tab)
sessionPath := canonicalTabSessionPath(tab.currentSessionPath())
if len(removed) == 0 {
fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}
}
removed = append(removed, removedRuntimeFromTab(tab, sessionDir, sessionPath))
a.markTabRemovedLocked(tab)
delete(a.detachedSessions, key)
}
if a.activeTabID == "" && len(a.tabOrder) > 0 {
a.activeTabID = a.tabOrder[0]
}
fallback.needs = len(removed) > 0 && len(a.tabs) == 0
dir, entries, activeID, version := a.saveTabsCollectLocked()
a.mu.Unlock()
a.saveTabsWrite(dir, entries, activeID, version)
return removed, fallback
}
func removedRuntimeFromTab(tab *WorkspaceTab, dir, sessionPath string) removedSessionRuntime {
return removedSessionRuntime{
tab: tab,
@@ -3638,28 +3568,6 @@ func (a *App) destroyHandlesForSession(dir, sessionPath string, removed []remove
return destroys
}
func waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {
results := make(chan jobs.TeardownResult, len(destroys))
waits := 0
for _, destroy := range destroys {
if destroy.Wait == nil {
continue
}
waits++
go func(wait func() jobs.TeardownResult) {
results <- wait()
}(destroy.Wait)
}
timedOut := false
for range waits {
if (<-results).HasTimedOut() {
timedOut = true
}
}
return timedOut
}
func waitAllDestroyHandles(destroys []control.SessionDestroyHandle) {
for _, destroy := range destroys {
if destroy.WaitAll != nil {
@@ -6731,21 +6639,6 @@ func firstNonEmpty(values ...string) string {
return ""
}
// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used
// and Window both zero means no context-window data yet.
type ContextInfo struct {
Used int `json:"used"`
Window int `json:"window"`
SessionTokens int `json:"sessionTokens"`
CompactRatio float64 `json:"compactRatio,omitempty"`
SessionCost float64 `json:"sessionCost,omitempty"`
SessionCurrency string `json:"sessionCurrency,omitempty"`
CacheHitTokens int `json:"cacheHitTokens,omitempty"`
CacheMissTokens int `json:"cacheMissTokens,omitempty"`
Estimated bool `json:"estimated,omitempty"`
Sources map[string]usageSourceStats `json:"sources,omitempty"`
}
// ContextUsage returns the latest context-window gauge numbers.
func (a *App) ContextUsage() ContextInfo {
return a.ContextUsageForTab("")
@@ -6795,6 +6688,7 @@ func (a *App) ContextUsageForTab(tabID string) ContextInfo {
info.Used = snap.Usage.LastUsedTokens
}
info.CompactRatio = ctrl.CompactRatio()
info.Maintenance = contextMaintenanceInfo(ctrl.ContextMaintenanceSnapshot())
return info
}
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"time"
"reasonix/internal/agent"
)
// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used
// and Window both zero means no context-window data yet.
type ContextInfo struct {
Used int `json:"used"`
Window int `json:"window"`
SessionTokens int `json:"sessionTokens"`
CompactRatio float64 `json:"compactRatio,omitempty"`
SessionCost float64 `json:"sessionCost,omitempty"`
SessionCurrency string `json:"sessionCurrency,omitempty"`
CacheHitTokens int `json:"cacheHitTokens,omitempty"`
CacheMissTokens int `json:"cacheMissTokens,omitempty"`
Estimated bool `json:"estimated,omitempty"`
Sources map[string]usageSourceStats `json:"sources,omitempty"`
Maintenance *ContextMaintenanceInfo `json:"maintenance,omitempty"`
}
// ContextMaintenanceInfo is the Wails-safe current-view snapshot. Optional
// fields preserve compatibility with older desktop/front-end combinations.
type ContextMaintenanceInfo struct {
CanonicalTokens int `json:"canonicalTokens,omitempty"`
ProjectedTokens int `json:"projectedTokens,omitempty"`
SummaryTokens int `json:"summaryTokens,omitempty"`
LastSavedTokens int `json:"lastSavedTokens,omitempty"`
SnipTrigger int `json:"snipTrigger,omitempty"`
FoldTrigger int `json:"foldTrigger,omitempty"`
ForceTrigger int `json:"forceTrigger,omitempty"`
HardInputCeiling int `json:"hardInputCeiling,omitempty"`
Headroom int `json:"headroom,omitempty"`
ProjectionVersion uint64 `json:"projectionVersion,omitempty"`
Blocked bool `json:"blocked,omitempty"`
LastReceipt *ContextMaintenanceReceiptInfo `json:"lastReceipt,omitempty"`
}
type ContextMaintenanceReceiptInfo struct {
OperationID string `json:"operationId,omitempty"`
Status string `json:"status,omitempty"`
Action string `json:"action,omitempty"`
Trigger string `json:"trigger,omitempty"`
SourceProjection uint64 `json:"sourceProjection,omitempty"`
ProjectionVersion uint64 `json:"projectionVersion,omitempty"`
CoveredCount int `json:"coveredCount,omitempty"`
CoveredPrefixHash string `json:"coveredPrefixHash,omitempty"`
InputHash string `json:"inputHash,omitempty"`
OutputHash string `json:"outputHash,omitempty"`
InputTokens int `json:"inputTokens,omitempty"`
ResultTokens int `json:"resultTokens,omitempty"`
SavedTokens int `json:"savedTokens,omitempty"`
AffectedToolResults int `json:"affectedToolResults,omitempty"`
SummaryHash string `json:"summaryHash,omitempty"`
Archive string `json:"archive,omitempty"`
CacheBreak bool `json:"cacheBreak,omitempty"`
Reason string `json:"reason,omitempty"`
BlockedInputHash string `json:"blockedInputHash,omitempty"`
CreatedAt string `json:"createdAt,omitempty"`
}
func contextMaintenanceInfo(snapshot agent.ContextMaintenanceSnapshot) *ContextMaintenanceInfo {
if snapshot.CanonicalTokens == 0 && snapshot.ProjectedTokens == 0 && snapshot.LastReceipt == nil {
return nil
}
info := &ContextMaintenanceInfo{
CanonicalTokens: snapshot.CanonicalTokens, ProjectedTokens: snapshot.ProjectedTokens,
SummaryTokens: snapshot.SummaryTokens, LastSavedTokens: snapshot.LastSavedTokens,
SnipTrigger: snapshot.SnipTrigger, FoldTrigger: snapshot.FoldTrigger,
ForceTrigger: snapshot.ForceTrigger, HardInputCeiling: snapshot.HardInputCeiling,
Headroom: snapshot.Headroom, ProjectionVersion: snapshot.ProjectionVersion,
Blocked: snapshot.Blocked,
}
if source := snapshot.LastReceipt; source != nil {
info.LastReceipt = contextMaintenanceReceiptInfo(source)
}
return info
}
func contextMaintenanceReceiptInfo(source *agent.ContextMaintenanceReceipt) *ContextMaintenanceReceiptInfo {
receipt := &ContextMaintenanceReceiptInfo{
OperationID: source.OperationID, Status: source.Status, Action: source.Action, Trigger: source.Trigger,
SourceProjection: source.SourceProjection, ProjectionVersion: source.ProjectionVersion,
CoveredCount: source.CoveredCount, CoveredPrefixHash: source.CoveredPrefixHash,
InputHash: source.InputHash, OutputHash: source.OutputHash,
InputTokens: source.InputTokens, ResultTokens: source.ResultTokens, SavedTokens: source.SavedTokens,
AffectedToolResults: source.AffectedToolResults, SummaryHash: source.SummaryHash, Archive: source.Archive,
CacheBreak: source.CacheBreak, Reason: source.Reason, BlockedInputHash: source.BlockedInputHash,
}
if !source.CreatedAt.IsZero() {
receipt.CreatedAt = source.CreatedAt.Format(time.RFC3339Nano)
}
return receipt
}
@@ -0,0 +1,28 @@
package main
import (
"encoding/json"
"strings"
"testing"
)
func TestContextMaintenanceInfoUsesOptionalCamelCaseWailsFields(t *testing.T) {
b, err := json.Marshal(ContextInfo{Maintenance: &ContextMaintenanceInfo{
ProjectedTokens: 1200,
LastReceipt: &ContextMaintenanceReceiptInfo{
OperationID: "op", Action: "prune", SavedTokens: 4096,
},
}})
if err != nil {
t.Fatal(err)
}
got := string(b)
for _, want := range []string{`"maintenance"`, `"projectedTokens":1200`, `"lastReceipt"`, `"operationId":"op"`, `"savedTokens":4096`} {
if !strings.Contains(got, want) {
t.Fatalf("ContextInfo JSON missing %s: %s", want, got)
}
}
if strings.Contains(got, "operation_id") || strings.Contains(got, "saved_tokens") {
t.Fatalf("ContextInfo leaked sidecar snake_case into Wails JSON: %s", got)
}
}
+42 -14
View File
@@ -35,8 +35,9 @@ type ExternalOpenerView struct {
// ExternalOpenersView is the complete state for the Codex-style Open control.
type ExternalOpenersView struct {
Openers []ExternalOpenerView `json:"openers"`
Preferred string `json:"preferred"`
Openers []ExternalOpenerView `json:"openers"`
Preferred string `json:"preferred"`
WorkspaceOpenable bool `json:"workspaceOpenable,omitempty"`
}
type externalOpenerSpec struct {
@@ -185,6 +186,18 @@ func (a *App) ExternalOpeners() ExternalOpenersView {
return ExternalOpenersView{Openers: views, Preferred: selected.View.ID}
}
// ExternalOpenersForTab adds the tab-scoped local-workspace capability used by
// the chat-header Open control. Scope is intentionally not part of the check:
// both project tabs and Global tabs can own a real local workspace directory.
func (a *App) ExternalOpenersForTab(tabID string) ExternalOpenersView {
if _, err := a.externalOpenerWorkspacePathForTab(tabID); err != nil {
return ExternalOpenersView{Openers: []ExternalOpenerView{}}
}
view := a.ExternalOpeners()
view.WorkspaceOpenable = true
return view
}
// SetPreferredExternalOpener persists an installed, platform-owned opener id.
func (a *App) SetPreferredExternalOpener(id string) error {
specs := cachedPlatformExternalOpenerSpecs()
@@ -206,24 +219,14 @@ func (a *App) OpenWorkspaceInExternalOpener(id string) error {
// OpenWorkspaceInExternalOpenerForTab is tab-scoped so a rapid tab switch cannot
// send the wrong project to an external application.
func (a *App) OpenWorkspaceInExternalOpenerForTab(tabID, id string) error {
root, _, ok := a.workspaceTargetForTab(tabID)
if !ok {
return os.ErrNotExist
}
path, err := workspaceBaseFromRoot(root)
path, err := a.externalOpenerWorkspacePathForTab(tabID)
if err != nil {
return err
}
info, err := os.Stat(path)
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("workspace is not a directory")
}
specs := cachedPlatformExternalOpenerSpecs()
var spec externalOpenerSpec
var ok bool
if strings.TrimSpace(id) == "" {
spec, ok = resolveExternalOpener(specs, a.preferredExternalOpenerID())
} else {
@@ -235,6 +238,31 @@ func (a *App) OpenWorkspaceInExternalOpenerForTab(tabID, id string) error {
return launchPlatformExternalOpener(spec, path)
}
func (a *App) externalOpenerWorkspacePathForTab(tabID string) (string, error) {
root, _, ok := a.workspaceTargetForTab(tabID)
if !ok {
return "", os.ErrNotExist
}
// A bound tab with no root has no stable workspace to expose. Keep the legacy
// no-tab current-directory fallback, but do not turn an incomplete tab into
// an opener for the Desktop process's launch directory.
if strings.TrimSpace(root) == "" {
return "", os.ErrNotExist
}
path, err := workspaceBaseFromRoot(root)
if err != nil {
return "", err
}
info, err := os.Stat(path)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", fmt.Errorf("workspace is not a directory")
}
return path, nil
}
// OpenLocalPathInExternalOpener opens an absolute local path with one of the
// detected, platform-owned applications. It shares OpenLocalPath's safety
// checks so a markdown link cannot turn an AI-generated executable path into
+74
View File
@@ -140,6 +140,54 @@ func TestSetPreferredExternalOpenerRejectsRendererCommands(t *testing.T) {
}
}
func TestExternalOpenerWorkspaceCapabilityUsesTheTabDirectoryNotScope(t *testing.T) {
projectRoot := t.TempDir()
globalRoot := t.TempDir()
missingRoot := filepath.Join(t.TempDir(), "missing")
fileRoot := filepath.Join(t.TempDir(), "not-a-directory")
if err := os.WriteFile(fileRoot, []byte("file"), 0o600); err != nil {
t.Fatal(err)
}
app := NewApp()
app.tabs = map[string]*WorkspaceTab{
"project": {ID: "project", Scope: "project", WorkspaceRoot: projectRoot},
"global": {ID: "global", Scope: "global", WorkspaceRoot: globalRoot},
"missing": {ID: "missing", Scope: "project", WorkspaceRoot: missingRoot},
"file": {ID: "file", Scope: "global", WorkspaceRoot: fileRoot},
"empty": {ID: "empty", Scope: "global"},
}
for _, tabID := range []string{"project", "global"} {
if _, err := app.externalOpenerWorkspacePathForTab(tabID); err != nil {
t.Errorf("externalOpenerWorkspacePathForTab(%q) = %v, want available", tabID, err)
}
}
for _, tabID := range []string{"missing", "file", "empty", "unknown"} {
if _, err := app.externalOpenerWorkspacePathForTab(tabID); err == nil {
t.Errorf("externalOpenerWorkspacePathForTab(%q) succeeded, want unavailable", tabID)
}
}
}
func TestExternalOpenersForGlobalTabReportsWorkspaceCapability(t *testing.T) {
app := NewApp()
app.tabs = map[string]*WorkspaceTab{
"global": {ID: "global", Scope: "global", WorkspaceRoot: t.TempDir()},
}
view := app.ExternalOpenersForTab("global")
if !view.WorkspaceOpenable {
t.Fatal("ExternalOpenersForTab(global) workspaceOpenable = false, want true")
}
if view.Openers == nil {
t.Fatal("ExternalOpenersForTab(global) openers = nil, want a Wails-safe array")
}
unavailable := app.ExternalOpenersForTab("unknown")
if unavailable.WorkspaceOpenable || unavailable.Openers == nil {
t.Fatalf("ExternalOpenersForTab(unknown) = %+v, want unavailable with an empty Wails-safe array", unavailable)
}
}
func TestLocalSaveDestinationIsSourceDetectsFilesystemAliases(t *testing.T) {
dir := t.TempDir()
source := filepath.Join(dir, "Readme.md")
@@ -294,4 +342,30 @@ func TestExternalOpenerViewIconIsBackwardCompatible(t *testing.T) {
if !strings.Contains(string(withIcon), `"iconDataUrl":"data:image/png;base64,AA=="`) {
t.Fatalf("native icon missing from JSON contract: %s", withIcon)
}
withoutCapability, err := json.Marshal(ExternalOpenersView{Openers: []ExternalOpenerView{}, Preferred: "finder"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(withoutCapability), "workspaceOpenable") {
t.Fatalf("false workspace capability should be omitted for old readers: %s", withoutCapability)
}
withCapability, err := json.Marshal(ExternalOpenersView{
Openers: []ExternalOpenerView{},
Preferred: "finder",
WorkspaceOpenable: true,
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(withCapability), `"workspaceOpenable":true`) {
t.Fatalf("workspace capability missing from JSON contract: %s", withCapability)
}
var oldReader struct {
Openers []ExternalOpenerView `json:"openers"`
Preferred string `json:"preferred"`
}
if err := json.Unmarshal(withCapability, &oldReader); err != nil || oldReader.Preferred != "finder" || oldReader.Openers == nil {
t.Fatalf("old reader rejected additive workspace capability: reader=%+v err=%v", oldReader, err)
}
}
-2
View File
@@ -29,11 +29,9 @@
"test:all": "pnpm test:typecheck && pnpm test:updater && pnpm test:window-state && pnpm test && pnpm test:remote && pnpm test:performance"
},
"dependencies": {
"@gsap/react": "^2.1.2",
"@tanstack/react-virtual": "^3.14.9",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
"gsap": "^3.15.0",
"hast-util-to-jsx-runtime": "2.3.6",
"highlight.js": "^11.10.0",
"html-url-attributes": "3.0.1",
-22
View File
@@ -12,9 +12,6 @@ importers:
.:
dependencies:
'@gsap/react':
specifier: ^2.1.2
version: 2.1.2([email protected])([email protected])
'@tanstack/react-virtual':
specifier: ^3.14.9
version: 3.14.9([email protected]([email protected]))([email protected])
@@ -24,9 +21,6 @@ importers:
'@xterm/xterm':
specifier: 6.0.0
version: 6.0.0
gsap:
specifier: ^3.15.0
version: 3.15.0
hast-util-to-jsx-runtime:
specifier: 2.3.6
version: 2.3.6
@@ -474,12 +468,6 @@ packages:
'@noble/hashes':
optional: true
'@gsap/[email protected]':
resolution: {integrity: sha512-JqliybO1837UcgH2hVOM4VO+38APk3ECNrsuSM4MuXp+rbf+/2IG2K1YJiqfTcXQHH7XlA0m3ykniFYstfq0Iw==}
peerDependencies:
gsap: ^3.12.5
react: '>=17'
'@humanfs/[email protected]':
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
engines: {node: '>=18.18.0'}
@@ -1270,9 +1258,6 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
[email protected]:
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
[email protected]:
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
@@ -2383,11 +2368,6 @@ snapshots:
'@exodus/[email protected]': {}
'@gsap/[email protected]([email protected])([email protected])':
dependencies:
gsap: 3.15.0
react: 19.2.8
'@humanfs/[email protected]':
dependencies:
'@humanfs/types': 0.15.0
@@ -3196,8 +3176,6 @@ snapshots:
dependencies:
is-glob: 4.0.3
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -78,8 +78,8 @@ for (const path of localeChunks) {
const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
.reduce((total, path) => total + statSync(path).size, 0);
// Native Web Animations and frame-batched scrolling keep GSAP out of the eager
// graph. Preserve the resulting startup headroom instead of letting the shell
// Native Web Animations and frame-batched scrolling avoid an eager animation
// runtime. Preserve the resulting startup headroom instead of letting the shell
// drift back to the previous 2.27 MiB / 295 KiB-gzip edge.
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_200 * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
+13 -22
View File
@@ -79,7 +79,7 @@ import { WorktreeBadge } from "./components/WorktreeBadge";
import { HeartbeatPanel } from "./custom/features/heartbeat/HeartbeatPanel";
import "./custom/features/heartbeat/heartbeat.css";
import { CopyButton } from "./components/CopyButton";
import { ExternalOpener } from "./components/ExternalOpener";
import { ExternalOpener, shouldMountExternalOpener } from "./components/ExternalOpener";
import { startTerminalEventBridge } from "./lib/terminalEvents";
import { applyTerminalThemePreference } from "./lib/terminalTheme";
import { formatTerminalOutputForComposer } from "./lib/terminalOutput";
@@ -204,12 +204,15 @@ import { useViewportHeightVar, useWindowStatePersistence } from "./lib/windowSta
import { availableWorkspacePanelWidth, resolveLiveWorkspacePanelWidth, resolveWorkspacePanelWidth, workspacePanelAriaMinWidth } from "./lib/workspaceLayout";
import { createRafResizeUpdater } from "./lib/resizeDrag";
import { useGlobalShortcut } from "./lib/keyboardShortcuts";
import { useMountTransition } from "./lib/useMountTransition";
import { topicShortcutIndexFromEvent, useTopicShortcuts, type TopicShortcutEntry } from "./lib/topicShortcuts";
import { composerDraftKeyForTab } from "./lib/composerDraftKey";
import { continueDelivery } from "./lib/deliveryContinue";
import { activateGoalAndSubmitOnTab } from "./lib/goalSubmit";
import logoWordmark from "./assets/logo-wordmark.svg";
const TERMINAL_CLOSE_TRANSITION_MS = 250;
function noticePreviewMockEnabled(): boolean {
const value = browserMockScenarioParam();
return value === "notice" || value === "notices" || value === "notice-preview";
@@ -1266,7 +1269,6 @@ export default function App() {
const [workspacePanelResizing, setWorkspacePanelResizing] = useState(false);
const [liveWorkspacePanelRenderWidth, setLiveWorkspacePanelRenderWidth] = useState<number | null>(null);
const [liveTerminalHeight, setLiveTerminalHeight] = useState<number | null>(null);
const [terminalContentVisible, setTerminalContentVisible] = useState(false);
const terminalResizing = liveTerminalHeight !== null;
const workspacePanelMaximized = useLayoutStore((s) => s.workspacePanelMaximized);
const setWorkspacePanelMaximized = useLayoutStore((s) => s.setWorkspacePanelMaximized);
@@ -1274,6 +1276,10 @@ export default function App() {
const setRightDockMode = useLayoutStore((s) => s.setRightDockMode);
const terminalPanelOpen = useLayoutStore((s) => s.terminalPanelOpen);
const setTerminalPanelOpen = useLayoutStore((s) => s.setTerminalPanelOpen);
const { mounted: terminalContentVisible } = useMountTransition(
terminalPanelOpen,
TERMINAL_CLOSE_TRANSITION_MS,
);
const terminalHeight = useLayoutStore((s) => s.terminalHeight);
const setTerminalHeight = useLayoutStore((s) => s.setTerminalHeight);
const [dockRefreshKey, setDockRefreshKey] = useState(0);
@@ -2997,21 +3003,6 @@ export default function App() {
[setSavedTerminalHeight, terminalPanelOpen, terminalRenderHeight, terminalResizeMaxHeight],
);
// Manage terminal content visibility for open/close animation.
// On open: mount content immediately. On close: wait for the grid-template-rows
// transition to finish before unmounting.
const handleTerminalTransitionEnd = useCallback((event: React.TransitionEvent<HTMLDivElement>) => {
if (event.propertyName === "grid-template-rows" && !terminalPanelOpen) {
setTerminalContentVisible(false);
}
}, [terminalPanelOpen]);
useEffect(() => {
if (terminalPanelOpen) {
setTerminalContentVisible(true);
}
}, [terminalPanelOpen]);
const openWorkspacePanel = useCallback(
(mode: RightDockMode = rightDockMode) => {
closeTransientOverlays();
@@ -4360,7 +4351,6 @@ export default function App() {
.filter(Boolean)
.join(" ")}
style={layoutStyle}
onTransitionEnd={handleTerminalTransitionEnd}
>
{!appChromeHidden && (
<AppChrome
@@ -4715,8 +4705,8 @@ export default function App() {
</div>
<div className="topicbar__spacer" />
<div className="topicbar__actions">
{sidebarCreation && !sidebarImDetailConnection && activeTab?.scope === "project" && (
<ExternalOpener tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
{sidebarCreation && shouldMountExternalOpener(activeTab, Boolean(sidebarImDetailConnection)) && activeTab && (
<ExternalOpener key={activeTab.id} tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
)}
{!sidebarImDetailConnection && (
<>
@@ -4792,8 +4782,8 @@ export default function App() {
</button>
</Tooltip>
)}
{!sidebarCreation && !sidebarImDetailConnection && activeTab?.scope === "project" && (
<ExternalOpener tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
{!sidebarCreation && shouldMountExternalOpener(activeTab, Boolean(sidebarImDetailConnection)) && activeTab && (
<ExternalOpener key={activeTab.id} tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
)}
<Tooltip label={t("shortcuts.cheatsheetTitle")}>
<button
@@ -5426,6 +5416,7 @@ export default function App() {
lastTurnOutputTokens={state.lastTurnOutputTokens}
lastTurnModelMs={state.lastTurnModelMs}
lastTurnOutputEstimated={state.lastTurnOutputEstimated}
lastRequestTps={state.lastRequestTps}
turnCost={state.turnCost}
cost={state.sessionCost}
currency={state.sessionCurrency}
@@ -0,0 +1,188 @@
// Run: tsx src/__tests__/approval-animation.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { ApprovalModal } from "../components/ApprovalModal";
import { LocaleProvider } from "../lib/i18n";
let passed = 0;
let failed = 0;
type SubmittedAnswer = [allow: boolean, session: boolean, persist: boolean];
type ControllableAnimation = {
onfinish: (() => void) | null;
oncancel: (() => void) | null;
};
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) ok(true, label);
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
function flushTimers(ms = 0): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function installDom() {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
globalThis.Event = dom.window.Event;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
return dom;
}
function mockNativeAnimate(
dom: JSDOM,
implementation: (options: KeyframeAnimationOptions) => ControllableAnimation,
) {
Object.defineProperty(dom.window.Element.prototype, "animate", {
configurable: true,
value: (_frames: Keyframe[] | PropertyIndexedKeyframes | null, options: number | KeyframeAnimationOptions) => {
if (typeof options === "number") throw new TypeError("expected keyframe animation options");
return implementation(options) as unknown as Animation;
},
});
}
async function renderToolApproval(onAnswer: (...answer: SubmittedAnswer) => void) {
const root = createRoot(document.getElementById("root")!);
await act(async () => {
root.render(
<LocaleProvider>
<ApprovalModal
approval={{ id: "approval-animation", tool: "bash", subject: "echo safe" }}
onAnswer={onAnswer}
onStop={() => undefined}
/>
</LocaleProvider>,
);
await flushTimers();
});
return root;
}
async function confirmSelectedAction() {
const confirm = document.querySelector(".decision-confirm-bar__confirm") as HTMLButtonElement | null;
if (!confirm) throw new Error("approval confirm button did not render");
await act(async () => {
confirm.click();
await flushTimers();
});
}
async function cleanup(root: Root, dom: JSDOM) {
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log("\napproval shelf animation");
// A real Web Animations implementation validates easing synchronously. Keep
// the business action pending until the visual transition finishes.
{
const dom = installDom();
const answers: SubmittedAnswer[] = [];
const animations: ControllableAnimation[] = [];
let easing: string | undefined;
mockNativeAnimate(dom, (options) => {
easing = options.easing;
if (typeof easing !== "string" || easing.includes("power")) {
throw new TypeError(`${String(easing)} is not a valid CSS easing`);
}
const animation: ControllableAnimation = { onfinish: null, oncancel: null };
animations.push(animation);
return animation;
});
const root = await renderToolApproval((...answer) => answers.push(answer));
await confirmSelectedAction();
eq(easing, "cubic-bezier(0.8, 0, 0.8, 0.28)", "shelf exit passes a valid CSS easing to Element.animate");
eq(answers.length, 0, "approval waits for the shelf exit animation");
eq(animations.length, 1, "approval starts one shelf exit animation");
await act(async () => {
animations[0].onfinish?.();
animations[0].oncancel?.();
await flushTimers();
});
eq(answers.length, 1, "finish and late cancel submit the approval only once");
eq(JSON.stringify(answers[0]), JSON.stringify([true, false, false]), "finished animation preserves the selected approval");
await cleanup(root, dom);
}
// Animation cancellation must not discard a decision that the user already
// confirmed.
{
const dom = installDom();
const answers: SubmittedAnswer[] = [];
const animation: ControllableAnimation = { onfinish: null, oncancel: null };
mockNativeAnimate(dom, () => animation);
const root = await renderToolApproval((...answer) => answers.push(answer));
await confirmSelectedAction();
await act(async () => {
animation.oncancel?.();
await flushTimers();
});
eq(answers.length, 1, "cancelled shelf animation still submits the approval once");
await cleanup(root, dom);
}
// The transition is cosmetic. Even a synchronously rejecting WebView must not
// block the underlying approval RPC.
{
const dom = installDom();
const answers: SubmittedAnswer[] = [];
let attempts = 0;
mockNativeAnimate(dom, () => {
attempts += 1;
throw new TypeError("WebView rejected the animation options");
});
const root = await renderToolApproval((...answer) => answers.push(answer));
await confirmSelectedAction();
await confirmSelectedAction();
eq(attempts, 1, "a rejected animation is not retried by a second confirm");
eq(answers.length, 1, "a rejected animation falls back to one approval submission");
eq(JSON.stringify(answers[0]), JSON.stringify([true, false, false]), "animation fallback preserves the selected approval");
await cleanup(root, dom);
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -4,7 +4,6 @@ import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import gsap from "gsap";
import { ApprovalModal } from "../components/ApprovalModal";
import { activeFileReferenceToken, pickInlineFileReference } from "../components/FileReferenceMenu";
import { LocaleProvider, preloadDetectedLocale } from "../lib/i18n";
@@ -14,17 +13,6 @@ import type { WireApproval } from "../lib/types";
let passed = 0;
let failed = 0;
type GsapToOptions = { onComplete?: () => void };
const gsapForTests = (typeof gsap.to === "function" ? gsap : (gsap as unknown as { default?: typeof gsap }).default) as unknown as {
to?: (target: unknown, vars: GsapToOptions) => unknown;
};
if (typeof gsapForTests.to === "function") {
gsapForTests.to = (_target: unknown, vars: GsapToOptions) => {
vars.onComplete?.();
return {};
};
}
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
@@ -268,10 +268,9 @@ console.log("\ncomposer goal toggle");
{
const dom = installDom();
const { root, calls, rerender } = await renderComposer();
let textarea = document.querySelector("textarea") as HTMLTextAreaElement | null;
if (!textarea) throw new Error("composer textarea did not render");
eq(["spellcheck", "autocorrect", "autocapitalize"].map((name) => textarea.getAttribute(name)).join("/"), "false/off/off", "plain composer disables browser text assistance");
await rerender({ insertRequest: { id: 1, text: "ship the release notes", mode: "replace" } });
eq(textarea.value, "ship the release notes", "insert request populates the composer draft");
// The insert queues a rAF that refocuses the textarea; drain that frame
@@ -604,6 +603,7 @@ console.log("\ncomposer goal toggle");
let token = richInput?.querySelector<HTMLElement>(".composer-invocation-token");
const invocationId = token?.dataset.invocationId;
if (!richInput || !token || !invocationId) throw new Error("rich invocation did not render for paste undo selection");
eq(["spellcheck", "autocorrect", "autocapitalize"].map((name) => richInput.getAttribute(name)).join("/"), "false/off/off", "rich composer disables browser text assistance");
const afterToken = document.createRange();
afterToken.setStartAfter(token);
afterToken.collapse(true);
@@ -0,0 +1,35 @@
import { formatContextMaintenanceNotice } from "../lib/contextMaintenanceTypes";
import type { DictKey, Translator } from "../lib/i18n";
function ok(value: unknown, message: string) {
if (!value) throw new Error(message);
}
const messages: Partial<Record<DictKey, string>> = {
"context.maintenanceTitle": "上下文维护",
"context.maintenanceActionPrune": "工具结果裁剪",
"context.tokensValue": "{value} tokens",
"projectTree.status.paused": "已暂停",
"settings.typography.applied": "已应用",
"task.state.failed": "失败",
};
const translate: Translator = (key, vars) => {
const value = messages[key] ?? key;
return value.replace(/\{(\w+)\}/g, (_, name: string) => String(vars?.[name] ?? `{${name}}`));
};
const applied = formatContextMaintenanceNotice({
status: "applied",
action: "prune",
inputTokens: 120,
resultTokens: 80,
savedTokens: 40,
}, translate);
ok(applied === "上下文维护 · 工具结果裁剪 · 已应用 · 120 → 80 tokens · −40 tokens", `unexpected applied notice: ${applied}`);
ok(!applied.includes("Context") && !applied.includes("applied"), "maintenance notice leaked hardcoded English");
const blocked = formatContextMaintenanceNotice({ status: "blocked" }, translate);
ok(blocked === "上下文维护 · 已暂停", `unexpected blocked notice: ${blocked}`);
console.log("context-maintenance-notice: ok");
@@ -1,6 +1,6 @@
// Run: tsx src/__tests__/context-panel-breakdown.test.ts
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSessionCache, contextSourceRows, contextUsageRefreshKey, contextWindowStatus, formatCacheHitRate, formatMetricTokens, liveTurnUsageBreakdown } from "../components/ContextPanel";
import { cacheHitTone, contextBreakdown, contextCostDisplay, contextSessionCache, contextSourceRows, contextUsageRefreshKey, contextWindowStatus, formatCacheHitRate, formatMetricTokens, formatSharePercent, liveTurnUsageBreakdown } from "../components/ContextPanel";
import { currencySymbol, formatMoney, formatMoneyLocalized } from "../lib/money";
import type { WireUsage } from "../lib/types";
@@ -29,6 +29,8 @@ function ok(condition: boolean, label: string) {
console.log("\ncontext panel breakdown");
eq(formatSharePercent(1, 1_000), "<1%", "small non-zero source shares are not shown as zero");
const mock = contextBreakdown(42_124, 128_000, 22_134, 12_345, 7_521);
eq(
{
@@ -8,7 +8,6 @@ import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import gsap from "gsap";
import { ApprovalModal } from "../components/ApprovalModal";
import { ClearContextCard } from "../components/ClearContextCard";
import { RuntimeDecisionCard } from "../components/RuntimeDecisionCard";
@@ -26,17 +25,6 @@ const styles = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
let passed = 0;
let failed = 0;
type GsapToOptions = { onComplete?: () => void };
const gsapForTests = (typeof gsap.to === "function" ? gsap : (gsap as unknown as { default?: typeof gsap }).default) as unknown as {
to?: (target: unknown, vars: GsapToOptions) => unknown;
};
if (typeof gsapForTests.to === "function") {
gsapForTests.to = (_target: unknown, vars: GsapToOptions) => {
vars.onComplete?.();
return {};
};
}
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
@@ -0,0 +1,130 @@
// Run: tsx src/__tests__/external-opener-preference-sync.test.tsx
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { ExternalOpener, type ExternalOpenerBridge } from "../components/ExternalOpener";
import { LocaleProvider } from "../lib/i18n";
import { ToastProvider } from "../lib/toast";
import type { ExternalOpenersView } from "../lib/types";
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;
}
const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
const openers = [
{ id: "finder", name: "Finder", kind: "file-manager" as const },
{ id: "xcode", name: "Xcode", kind: "editor" as const },
];
function createHarness(bridge: ExternalOpenerBridge) {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
const renderTab = async (tabId: string) => {
await act(async () => {
root.render(
<LocaleProvider>
<ToastProvider>
<ExternalOpener key={tabId} tabId={tabId} dismissSignal={0} bridge={bridge} />
</ToastProvider>
</LocaleProvider>,
);
await flush();
});
};
const choose = async (name: string) => {
await act(async () => {
container.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
});
await act(async () => {
Array.from(container.querySelectorAll<HTMLButtonElement>('button[role="menuitemradio"]'))
.find((button) => button.textContent?.includes(name))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
});
};
const primaryLabel = () => container.querySelector<HTMLButtonElement>(".external-opener__primary")?.ariaLabel;
const unmount = async () => {
await act(async () => root.unmount());
container.remove();
};
return { container, renderTab, choose, primaryLabel, unmount };
}
console.log("\nexternal opener preference synchronization");
let preferred = "finder";
let resolveOlderLaunch: (() => void) | undefined;
const launches: string[] = [];
const completionBridge: ExternalOpenerBridge = {
async ExternalOpenersForTab() { return { openers, preferred, workspaceOpenable: true }; },
async SetPreferredExternalOpener(id) { preferred = id; },
async OpenWorkspaceInExternalOpenerForTab(tabId, id) {
launches.push(`${tabId}:${id}`);
if (tabId === "older") await new Promise<void>((resolve) => { resolveOlderLaunch = resolve; });
},
};
const completion = createHarness(completionBridge);
await completion.renderTab("older");
await completion.choose("Xcode");
await completion.renderTab("visible");
ok(completion.primaryLabel()?.includes("Finder") === true, "the visible tab initially discovers the old preference");
await act(async () => { resolveOlderLaunch?.(); await flush(); });
ok(preferred === "xcode" && completion.primaryLabel()?.includes("Xcode") === true, "an older tab completion synchronizes the visible control");
await act(async () => {
completion.container.querySelector<HTMLButtonElement>(".external-opener__primary")
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
});
ok(launches.at(-1) === "visible:xcode", "the visible primary action uses the synchronized preference");
await completion.unmount();
preferred = "finder";
let resolveSecondOlderLaunch: (() => void) | undefined;
let resolveStaleDiscovery: ((view: ExternalOpenersView) => void) | undefined;
const staleDiscoveryBridge: ExternalOpenerBridge = {
async ExternalOpenersForTab(tabId) {
if (tabId !== "visible") return { openers, preferred, workspaceOpenable: true };
return new Promise<ExternalOpenersView>((resolve) => { resolveStaleDiscovery = resolve; });
},
async SetPreferredExternalOpener(id) { preferred = id; },
async OpenWorkspaceInExternalOpenerForTab(tabId) {
if (tabId === "older") await new Promise<void>((resolve) => { resolveSecondOlderLaunch = resolve; });
},
};
const staleDiscovery = createHarness(staleDiscoveryBridge);
await staleDiscovery.renderTab("older");
await staleDiscovery.choose("Xcode");
await staleDiscovery.renderTab("visible");
await act(async () => { resolveSecondOlderLaunch?.(); await flush(); });
await act(async () => {
resolveStaleDiscovery?.({ openers, preferred: "finder", workspaceOpenable: true });
await flush();
});
ok(staleDiscovery.primaryLabel()?.includes("Xcode") === true, "a discovery started before the write cannot restore the stale preference");
await staleDiscovery.unmount();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -1,10 +1,11 @@
// Run: tsx src/__tests__/external-opener.test.tsx
import { readFileSync } from "node:fs";
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { ExternalOpener, type ExternalOpenerBridge } from "../components/ExternalOpener";
import { ExternalOpener, shouldMountExternalOpener, type ExternalOpenerBridge } from "../components/ExternalOpener";
import { LocaleProvider, t } from "../lib/i18n";
import { ToastProvider } from "../lib/toast";
import type { ExternalOpenersView } from "../lib/types";
@@ -45,7 +46,7 @@ const opened: Array<[string, string]> = [];
const nativeIcon = "data:image/png;base64,iVBORw0KGgo=";
let discoveryCalls = 0;
const bridge: ExternalOpenerBridge = {
async ExternalOpeners() {
async ExternalOpenersForTab() {
discoveryCalls += 1;
return {
openers: [
@@ -54,6 +55,7 @@ const bridge: ExternalOpenerBridge = {
...(discoveryCalls > 1 ? [{ id: "xcode", name: "Xcode", kind: "editor" as const, iconDataUrl: nativeIcon }] : []),
],
preferred: "finder",
workspaceOpenable: true,
};
},
async SetPreferredExternalOpener(id) {
@@ -66,13 +68,49 @@ const bridge: ExternalOpenerBridge = {
console.log("\nexternal opener");
const stylesSource = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
const sharedControlRule = stylesSource.match(/(?:^|\n)\.external-opener\s*\{([^}]*)\}/)?.[1] ?? "";
const sharedSegmentRule = stylesSource.match(
/\.external-opener__primary,\s*\.external-opener__menu-trigger\s*\{([^}]*)\}/,
)?.[1] ?? "";
const creationControlRule = stylesSource.match(
/\.app--creation \.topicbar__actions \.external-opener,\s*:root\[data-theme-style\] \.app--creation \.topicbar__actions \.external-opener\s*\{([^}]*)\}/,
)?.[1] ?? "";
const sharedIconRule = stylesSource.match(/(?:^|\n)\.external-opener__app-icon\s*\{([^}]*)\}/)?.[1] ?? "";
const creationIconRule = stylesSource.match(
/\.app--creation \.topicbar__actions \.external-opener__app-icon,\s*:root\[data-theme-style\] \.app--creation \.topicbar__actions \.external-opener__app-icon\s*\{([^}]*)\}/,
)?.[1] ?? "";
ok(
/height:\s*28px;/.test(sharedControlRule)
&& /border-radius:\s*7px;/.test(sharedControlRule)
&& /box-shadow:\s*none;/.test(sharedControlRule),
"uses the shared 28px / 7px topic-bar control geometry without an extra shadow",
);
ok(/height:\s*100%;/.test(sharedSegmentRule), "keeps both split-button segments within the shared control height");
ok(
/height:\s*28px;/.test(creationControlRule) && /border-radius:\s*7px;/.test(creationControlRule),
"keeps Creation topic bars on the same 28px / 7px geometry",
);
ok(
/width:\s*18px;/.test(sharedIconRule) && /height:\s*18px;/.test(sharedIconRule),
"fits Workbench application artwork to the compact topic-bar control",
);
ok(
/width:\s*15px;/.test(creationIconRule) && /height:\s*15px;/.test(creationIconRule),
"preserves the slimmer Creation application artwork size",
);
ok(shouldMountExternalOpener({ id: "tab-project", scope: "project" }, false), "mounts for a Project tab");
ok(shouldMountExternalOpener({ id: "tab-global", scope: "global" }, false), "mounts for a Global tab without guessing from scope");
ok(!shouldMountExternalOpener({ id: "tab-global", scope: "global" }, true), "stays hidden while an IM detail surface owns the header");
const container = document.getElementById("root")!;
const root = createRoot(container);
await act(async () => {
root.render(
<LocaleProvider>
<ToastProvider>
<ExternalOpener tabId="tab-project" dismissSignal={0} bridge={bridge} />
<ExternalOpener tabId="tab-global" dismissSignal={0} bridge={bridge} />
</ToastProvider>
</LocaleProvider>,
);
@@ -99,7 +137,7 @@ await act(async () => {
await flush();
});
ok(selected.join(",") === "ghostty", "persists the selected application id");
ok(JSON.stringify(opened) === JSON.stringify([["tab-project", "ghostty"]]), "opens the exact tab workspace with the selection");
ok(JSON.stringify(opened) === JSON.stringify([["tab-global", "ghostty"]]), "opens the exact Global workspace with the selection");
const primary = container.querySelector<HTMLButtonElement>('button.external-opener__primary');
await act(async () => {
@@ -122,17 +160,17 @@ await act(async () => root.unmount());
let staleResolve: ((value: ExternalOpenersView) => void) | undefined;
let raceCalls = 0;
const raceBridge: ExternalOpenerBridge = {
async ExternalOpeners() {
async ExternalOpenersForTab() {
raceCalls += 1;
if (raceCalls === 1) {
return { openers: [{ id: "finder", name: "Finder", kind: "file-manager" }], preferred: "finder" };
return { openers: [{ id: "finder", name: "Finder", kind: "file-manager" }], preferred: "finder", workspaceOpenable: true };
}
if (raceCalls === 2) {
return new Promise<ExternalOpenersView>((resolve) => {
staleResolve = resolve;
});
}
return { openers: [{ id: "xcode", name: "Xcode", kind: "editor" }], preferred: "xcode" };
return { openers: [{ id: "xcode", name: "Xcode", kind: "editor" }], preferred: "xcode", workspaceOpenable: true };
},
async SetPreferredExternalOpener() {},
async OpenWorkspaceInExternalOpenerForTab() {},
@@ -169,24 +207,190 @@ await act(async () => {
});
ok(raceContainer.textContent?.includes("Xcode") === true, "the latest overlapping discovery result wins");
await act(async () => {
staleResolve?.({ openers: [{ id: "stale", name: "Stale Editor", kind: "editor" }], preferred: "stale" });
staleResolve?.({ openers: [{ id: "stale", name: "Stale Editor", kind: "editor" }], preferred: "stale", workspaceOpenable: true });
await flush();
});
ok(raceContainer.textContent?.includes("Xcode") === true && !raceContainer.textContent?.includes("Stale Editor"), "a stale discovery cannot replace the current menu");
await act(async () => raceRoot.unmount());
raceContainer.remove();
const failureLog: string[] = [];
let failOpen = true;
let failPersist = false;
const failureBridge: ExternalOpenerBridge = {
async ExternalOpeners() {
const preferenceWrites: string[] = [];
const preferenceLaunches: string[] = [];
let resolveOlderLaunch: (() => void) | undefined;
const preferenceRaceBridge: ExternalOpenerBridge = {
async ExternalOpenersForTab() {
return {
openers: [
{ id: "finder", name: "Finder", kind: "file-manager" },
{ id: "xcode", name: "Xcode", kind: "editor" },
],
preferred: "finder",
workspaceOpenable: true,
};
},
async SetPreferredExternalOpener(id) {
preferenceWrites.push(id);
},
async OpenWorkspaceInExternalOpenerForTab(tabId, id) {
preferenceLaunches.push(`${tabId}:${id}`);
if (tabId === "older-tab") {
await new Promise<void>((resolve) => {
resolveOlderLaunch = resolve;
});
}
},
};
const preferenceContainer = document.createElement("div");
document.body.append(preferenceContainer);
const preferenceRoot = createRoot(preferenceContainer);
const renderPreferenceTab = async (
root: ReturnType<typeof createRoot>,
targetBridge: ExternalOpenerBridge,
tabId: string,
) => {
await act(async () => {
root.render(
<LocaleProvider>
<ToastProvider>
<ExternalOpener key={tabId} tabId={tabId} dismissSignal={0} bridge={targetBridge} />
</ToastProvider>
</LocaleProvider>,
);
await flush();
});
};
const choosePreference = async (targetContainer: HTMLElement, name: string) => {
await act(async () => {
targetContainer.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]')
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
});
await act(async () => {
Array.from(targetContainer.querySelectorAll<HTMLButtonElement>('button[role="menuitemradio"]'))
.find((button) => button.textContent?.includes(name))
?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await flush();
});
};
await renderPreferenceTab(preferenceRoot, preferenceRaceBridge, "older-tab");
await choosePreference(preferenceContainer, "Xcode");
await renderPreferenceTab(preferenceRoot, preferenceRaceBridge, "newer-tab");
await choosePreference(preferenceContainer, "Finder");
ok(
preferenceLaunches.join(",") === "older-tab:xcode,newer-tab:finder" && preferenceWrites.join(",") === "finder",
"a newer tab selection persists even when it matches the discovered preference",
);
await act(async () => {
resolveOlderLaunch?.();
await flush();
});
ok(preferenceWrites.join(",") === "finder", "an older tab completion cannot overwrite the latest opener preference");
await act(async () => preferenceRoot.unmount());
preferenceContainer.remove();
const failedNewerWrites: string[] = [];
let resolveOlderSuccessfulLaunch: (() => void) | undefined;
const failedNewerBridge: ExternalOpenerBridge = {
async ExternalOpenersForTab() {
return {
openers: [
{ id: "finder", name: "Finder", kind: "file-manager" },
{ id: "xcode", name: "Xcode", kind: "editor" },
],
preferred: "finder",
workspaceOpenable: true,
};
},
async SetPreferredExternalOpener(id) {
failedNewerWrites.push(id);
},
async OpenWorkspaceInExternalOpenerForTab(tabId) {
if (tabId === "older-success") {
await new Promise<void>((resolve) => {
resolveOlderSuccessfulLaunch = resolve;
});
return;
}
throw new Error("newer launch failed");
},
};
const failedNewerContainer = document.createElement("div");
document.body.append(failedNewerContainer);
const failedNewerRoot = createRoot(failedNewerContainer);
await renderPreferenceTab(failedNewerRoot, failedNewerBridge, "older-success");
await choosePreference(failedNewerContainer, "Xcode");
await renderPreferenceTab(failedNewerRoot, failedNewerBridge, "newer-failure");
await choosePreference(failedNewerContainer, "Finder");
await act(async () => {
resolveOlderSuccessfulLaunch?.();
await flush();
});
ok(
failedNewerWrites.join(",") === "xcode",
"a newer failed launch does not cancel an older successful selection",
);
await act(async () => failedNewerRoot.unmount());
failedNewerContainer.remove();
const inFlightWrites: string[] = [];
let markOlderWriteStarted: (() => void) | undefined;
const olderWriteStarted = new Promise<void>((resolve) => {
markOlderWriteStarted = resolve;
});
let resolveOlderWrite: (() => void) | undefined;
const inFlightWriteBridge: ExternalOpenerBridge = {
async ExternalOpenersForTab() {
return {
openers: [
{ id: "finder", name: "Finder", kind: "file-manager" },
{ id: "xcode", name: "Xcode", kind: "editor" },
],
preferred: "finder",
workspaceOpenable: true,
};
},
async SetPreferredExternalOpener(id) {
inFlightWrites.push(id);
markOlderWriteStarted?.();
await new Promise<void>((resolve) => {
resolveOlderWrite = resolve;
});
},
async OpenWorkspaceInExternalOpenerForTab(tabId) {
if (tabId === "newer-failure") throw new Error("newer launch failed");
},
};
const inFlightContainer = document.createElement("div");
document.body.append(inFlightContainer);
const inFlightRoot = createRoot(inFlightContainer);
await renderPreferenceTab(inFlightRoot, inFlightWriteBridge, "older-write");
await choosePreference(inFlightContainer, "Xcode");
await olderWriteStarted;
await renderPreferenceTab(inFlightRoot, inFlightWriteBridge, "newer-failure");
await choosePreference(inFlightContainer, "Finder");
await act(async () => {
resolveOlderWrite?.();
await flush();
});
ok(
inFlightWrites.join(",") === "xcode",
"an in-flight older preference write has the same result when a newer launch fails",
);
await act(async () => inFlightRoot.unmount());
inFlightContainer.remove();
const failureLog: string[] = [];
let failOpen = true;
let failPersist = false;
const failureBridge: ExternalOpenerBridge = {
async ExternalOpenersForTab() {
return {
openers: [
{ id: "finder", name: "Finder", kind: "file-manager" },
{ id: "xcode", name: "Xcode", kind: "editor" },
],
preferred: "finder",
workspaceOpenable: true,
};
},
async SetPreferredExternalOpener(id) {
@@ -247,5 +451,33 @@ ok(
await act(async () => failureRoot.unmount());
failureContainer.remove();
const unavailableContainer = document.createElement("div");
document.body.append(unavailableContainer);
const unavailableRoot = createRoot(unavailableContainer);
const unavailableBridge: ExternalOpenerBridge = {
async ExternalOpenersForTab() {
return {
openers: [{ id: "finder", name: "Finder", kind: "file-manager" }],
preferred: "finder",
workspaceOpenable: false,
};
},
async SetPreferredExternalOpener() {},
async OpenWorkspaceInExternalOpenerForTab() {},
};
await act(async () => {
unavailableRoot.render(
<LocaleProvider>
<ToastProvider>
<ExternalOpener tabId="tab-missing-workspace" dismissSignal={0} bridge={unavailableBridge} />
</ToastProvider>
</LocaleProvider>,
);
await flush();
});
ok(unavailableContainer.querySelector(".external-opener") == null, "hides when the backend reports no local workspace capability");
await act(async () => unavailableRoot.unmount());
unavailableContainer.remove();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,304 @@
// Run: tsx src/__tests__/native-motion.test.tsx
import { JSDOM } from "jsdom";
import React, { useRef } from "react";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { CSS_EASE_OUT } from "../lib/motion";
import { useCollapseAnimation } from "../lib/useCollapseAnimation";
import { useEntranceAnimation } from "../lib/useEntranceAnimation";
import { useMountTransition } from "../lib/useMountTransition";
let passed = 0;
let failed = 0;
type ControllableAnimation = {
onfinish: (() => void) | null;
oncancel: (() => void) | null;
cancelCalls: number;
cancel: () => void;
};
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) ok(true, label);
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
function flushTimers(ms = 0): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function controllableAnimation(): ControllableAnimation {
const animation: ControllableAnimation = {
onfinish: null,
oncancel: null,
cancelCalls: 0,
cancel() {
animation.cancelCalls += 1;
animation.oncancel?.();
},
};
return animation;
}
function installDom(reducedMotion = false) {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
dom.window.matchMedia = () => ({
matches: reducedMotion,
media: "(prefers-reduced-motion: reduce)",
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
});
return dom;
}
function mockNativeAnimate(
dom: JSDOM,
implementation: (
this: Element,
frames: Keyframe[] | PropertyIndexedKeyframes | null,
options: KeyframeAnimationOptions,
) => ControllableAnimation,
) {
Object.defineProperty(dom.window.Element.prototype, "animate", {
configurable: true,
value: function (frames: Keyframe[] | PropertyIndexedKeyframes | null, options: number | KeyframeAnimationOptions) {
if (typeof options === "number") throw new TypeError("expected keyframe animation options");
return implementation.call(this, frames, options) as unknown as Animation;
},
});
}
async function cleanup(root: Root, dom: JSDOM) {
await act(async () => root.unmount());
dom.window.close();
}
function CollapseHarness({
open,
onOpen,
onClose,
}: {
open: boolean;
onOpen?: () => void;
onClose?: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useCollapseAnimation(ref, open, { onOpenComplete: onOpen, onCloseComplete: onClose });
return <div id="collapse" ref={ref}>content</div>;
}
async function renderCollapse(root: Root, open: boolean, onOpen?: () => void, onClose?: () => void) {
await act(async () => {
root.render(<CollapseHarness open={open} onOpen={onOpen} onClose={onClose} />);
});
}
function EntranceHarness({ resetKey, ids }: { resetKey: string; ids: string[] }) {
const ref = useEntranceAnimation<HTMLDivElement>(resetKey, ids.length, "[data-entrance]", ids);
return (
<div ref={ref}>
{ids.map((id) => <div key={id} id={`entry-${id}`} data-entrance={id} />)}
</div>
);
}
function DeferredEntranceHarness({
resetKey,
seedIds,
visibleIds,
}: {
resetKey: string;
seedIds: string[];
visibleIds: string[];
}) {
const ref = useEntranceAnimation<HTMLDivElement>(resetKey, visibleIds.length, "[data-entrance]", seedIds);
return (
<div ref={ref}>
{visibleIds.map((id) => <div key={id} id={`entry-${id}`} data-entrance={id} />)}
</div>
);
}
function MountHarness({ open, duration }: { open: boolean; duration: number }) {
const { mounted } = useMountTransition(open, duration);
return mounted ? <div id="mounted" /> : null;
}
console.log("\nnative motion fallbacks");
// Valid native motion waits for completion and settles exactly once.
{
const dom = installDom();
const animations: ControllableAnimation[] = [];
let easing: string | undefined;
mockNativeAnimate(dom, (_frames, options) => {
easing = options.easing;
const animation = controllableAnimation();
animations.push(animation);
return animation;
});
const root = createRoot(document.getElementById("root")!);
let opened = 0;
await renderCollapse(root, false, () => { opened += 1; });
await renderCollapse(root, true, () => { opened += 1; });
eq(easing, CSS_EASE_OUT, "collapse passes a CSS easing to Element.animate");
eq(opened, 0, "collapse waits for native completion");
await act(async () => {
animations[0].onfinish?.();
animations[0].oncancel?.();
});
eq(opened, 1, "finish and late cancel settle collapse only once");
eq((document.getElementById("collapse") as HTMLElement).style.height, "auto", "finished open state uses auto height");
await cleanup(root, dom);
}
// A synchronously rejecting WebView must expose the requested final state.
{
const dom = installDom();
mockNativeAnimate(dom, () => {
throw new TypeError("WebView rejected height animation");
});
const root = createRoot(document.getElementById("root")!);
let opened = 0;
await renderCollapse(root, false, () => { opened += 1; });
await renderCollapse(root, true, () => { opened += 1; });
eq(opened, 1, "rejected collapse animation runs its completion fallback");
eq((document.getElementById("collapse") as HTMLElement).style.height, "auto", "rejected collapse animation exposes content");
await cleanup(root, dom);
}
// Direction reversal cancels the old animation without firing its stale callback.
{
const dom = installDom();
const animations: ControllableAnimation[] = [];
mockNativeAnimate(dom, () => {
const animation = controllableAnimation();
animations.push(animation);
return animation;
});
const root = createRoot(document.getElementById("root")!);
let opened = 0;
let closed = 0;
await renderCollapse(root, false, () => { opened += 1; }, () => { closed += 1; });
await renderCollapse(root, true, () => { opened += 1; }, () => { closed += 1; });
await renderCollapse(root, false, () => { opened += 1; }, () => { closed += 1; });
eq(animations[0].cancelCalls, 1, "direction reversal cancels the previous animation");
eq(opened, 0, "superseded open callback is suppressed");
await act(async () => animations[1].oncancel?.());
eq(closed, 1, "unexpected cancellation settles the active direction");
eq((document.getElementById("collapse") as HTMLElement).style.height, "0px", "cancelled close reaches zero height");
await cleanup(root, dom);
}
// A stable reset key animates appends; a real reset pre-seeds restored rows.
{
const dom = installDom();
const calls: { options: KeyframeAnimationOptions; animation: ControllableAnimation }[] = [];
mockNativeAnimate(dom, (_frames, options) => {
const animation = controllableAnimation();
calls.push({ options, animation });
return animation;
});
const root = createRoot(document.getElementById("root")!);
await act(async () => root.render(<EntranceHarness resetKey="tab-a" ids={["a"]} />));
await act(async () => root.render(<EntranceHarness resetKey="tab-a" ids={["a", "b"]} />));
await act(async () => { await flushTimers(25); });
eq(calls.length, 1, "stable entrance reset key animates an appended row");
eq(calls[0].options.easing, CSS_EASE_OUT, "entrance animation uses the shared CSS easing");
await act(async () => calls[0].animation.onfinish?.());
eq((document.getElementById("entry-b") as HTMLElement).style.opacity, "1", "finished entrance exposes the row");
await act(async () => root.render(<EntranceHarness resetKey="tab-b" ids={["a", "b", "c"]} />));
await act(async () => { await flushTimers(25); });
eq(calls.length, 1, "changed reset key pre-seeds restored rows without animation");
await act(async () => root.render(<EntranceHarness resetKey="tab-b" ids={["a", "b", "c", "d"]} />));
await act(async () => { await flushTimers(25); });
eq(calls.length, 2, "append after a reset animates normally");
await cleanup(root, dom);
}
// Virtual rows can mount after the initial scan. Model-backed seed IDs keep
// restored rows inert while still allowing a genuinely appended row to enter.
{
const dom = installDom();
const calls: string[] = [];
mockNativeAnimate(dom, function () {
calls.push((this as unknown as Element).getAttribute?.("data-entrance") ?? "");
return controllableAnimation();
});
const root = createRoot(document.getElementById("root")!);
await act(async () => root.render(
<DeferredEntranceHarness resetKey="tab-a" seedIds={["history"]} visibleIds={[]} />,
));
await act(async () => root.render(
<DeferredEntranceHarness resetKey="tab-a" seedIds={["history", "new"]} visibleIds={["history", "new"]} />,
));
await act(async () => { await flushTimers(25); });
eq(calls.length, 1, "deferred virtual history does not animate with a new append");
eq(calls[0], "new", "only the genuinely appended virtual row animates");
await cleanup(root, dom);
}
// One rejected entrance must fail open without aborting the timer callback.
{
const dom = installDom();
let attempts = 0;
mockNativeAnimate(dom, () => {
attempts += 1;
throw new TypeError("WebView rejected entrance animation");
});
const root = createRoot(document.getElementById("root")!);
await act(async () => root.render(<EntranceHarness resetKey="tab-a" ids={["a"]} />));
await act(async () => root.render(<EntranceHarness resetKey="tab-a" ids={["a", "b", "c"]} />));
await act(async () => { await flushTimers(25); });
eq(attempts, 2, "a rejected entrance does not prevent later entries from attempting motion");
eq((document.getElementById("entry-b") as HTMLElement).style.opacity, "1", "rejected entrance exposes the first row");
eq((document.getElementById("entry-c") as HTMLElement).style.opacity, "1", "rejected entrance exposes later rows");
await cleanup(root, dom);
}
// Terminal-style conditional content unmounts on a bounded timer even if no
// transitionend event is delivered.
{
const dom = installDom();
const root = createRoot(document.getElementById("root")!);
await act(async () => root.render(<MountHarness open={true} duration={20} />));
ok(Boolean(document.getElementById("mounted")), "transition content mounts while open");
await act(async () => root.render(<MountHarness open={false} duration={20} />));
ok(Boolean(document.getElementById("mounted")), "transition content remains mounted during close delay");
await act(async () => { await flushTimers(30); });
ok(!document.getElementById("mounted"), "transition content unmounts without transitionend");
await cleanup(root, dom);
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -18,6 +18,7 @@ import {
projectTreeTopicMenuOffersPin,
projectTreeDedupedExactTime,
} from "../components/ProjectTree";
import { projectTreeTrashingTopics } from "../lib/projectTreeArchive";
import type { ProjectNode } from "../lib/types";
let passed = 0;
@@ -35,6 +36,14 @@ function eq(a: unknown, b: unknown, label: string) {
console.log("\nproject tree runtime sessions");
const noTrashingTopics = new Set<string>();
const topicATrashing = projectTreeTrashingTopics(noTrashingTopics, "topic-a", true);
const twoTopicsTrashing = projectTreeTrashingTopics(topicATrashing, "topic-b", true);
eq([...topicATrashing], ["topic-a"], "archive pending state is keyed by topic");
eq([...twoTopicsTrashing], ["topic-a", "topic-b"], "another topic remains independently actionable");
eq([...projectTreeTrashingTopics(twoTopicsTrashing, "topic-a", false)], ["topic-b"], "settling one archive leaves the other pending");
eq(projectTreeTrashingTopics(topicATrashing, "topic-a", true) === topicATrashing, true, "same pending state preserves Set identity");
const testT = (key: string, vars?: Record<string, string | number>) => {
if (key === "history.turnOne") return `${vars?.n ?? 1} turn`;
if (key === "history.turnOther") return `${vars?.n ?? 0} turns`;
@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import { coalescesQuery, invalidateSharedQuery, resetQueryCoalescing, shareQuery } from "../lib/queryCoalesce";
function deferred<T>() {
let resolve!: (v: T) => void;
let reject!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
return { promise, resolve, reject };
}
async function identicalCallsInFlightShareOneAnswer() {
resetQueryCoalescing();
let runs = 0;
const d = deferred<string>();
const run = () => { runs++; return d.promise; };
const a = shareQuery("ContextPanel", ["tab-1"], run);
const b = shareQuery("ContextPanel", ["tab-1"], run);
d.resolve("panel");
assert.equal(await a, "panel");
assert.equal(await b, "panel");
assert.equal(runs, 1, "a duplicate inside the window must not reach the backend");
}
async function differentArgumentsStayIndependent() {
resetQueryCoalescing();
let runs = 0;
const run = async () => { runs++; return runs; };
await shareQuery("MetaForTab", ["tab-1"], run);
await shareQuery("MetaForTab", ["tab-2"], run);
assert.equal(runs, 2, "another tab is another question");
}
// A stale answer is worse than a slow one: a failure must not be inherited.
async function rejectionIsNotSharedOnward() {
resetQueryCoalescing();
let runs = 0;
const first = shareQuery("ListTabs", [], async () => { runs++; throw new Error("boom"); });
await assert.rejects(first);
const second = await shareQuery("ListTabs", [], async () => { runs++; return "ok"; });
assert.equal(second, "ok");
assert.equal(runs, 2, "the next caller retries instead of inheriting the failure");
}
async function invalidationStartsAFreshRead() {
resetQueryCoalescing();
let runs = 0;
const run = async () => ++runs;
assert.equal(await shareQuery("MetaForTab", ["tab-1"], run), 1);
invalidateSharedQuery("MetaForTab", ["tab-1"]);
assert.equal(await shareQuery("MetaForTab", ["tab-1"], run), 2);
}
async function settledTabListsAreNotShared() {
resetQueryCoalescing();
let runs = 0;
const run = async () => ++runs;
assert.equal(await shareQuery("ListTabs", [], run), 1);
assert.equal(await shareQuery("ListTabs", [], run), 2, "tab mutations must be visible after the in-flight burst");
}
// Anything that mutates or starts work must never be collapsed.
function onlyReadOnlyQueriesAreCoalesced() {
for (const readOnly of ["ListProjectTree", "ContextPanel", "MetaForTab", "BalanceForTab"]) {
assert.equal(coalescesQuery(readOnly), true, `${readOnly} should coalesce`);
}
for (const mutating of ["Send", "SetActiveTab", "OpenGlobalTab", "Cancel", "ApproveTool"]) {
assert.equal(coalescesQuery(mutating), false, `${mutating} must never be coalesced`);
}
}
const tests: Array<[string, () => unknown]> = [
["identical in-flight calls share one answer", identicalCallsInFlightShareOneAnswer],
["different arguments stay independent", differentArgumentsStayIndependent],
["a rejection is not shared onward", rejectionIsNotSharedOnward],
["external state boundaries invalidate settled answers", invalidationStartsAFreshRead],
["settled tab lists do not cross mutations", settledTabListsAreNotShared],
["only read-only queries are coalesced", onlyReadOnlyQueriesAreCoalesced],
];
let failed = 0;
for (const [name, fn] of tests) {
try {
await fn();
console.log(`ok ${name}`);
} catch (err) {
failed++;
console.error(`FAIL ${name}\n ${(err as Error).message}`);
}
}
if (failed > 0) process.exit(1);
@@ -52,7 +52,9 @@ console.log("\nstatus bar workspace");
{ id: "demo", label: "demo", host: "192.0.2.10", port: 22, user: "dev", identityFile: "", proxyJump: "", defaultWorkspace: "~/app", serveInstall: "auto", useSSHConfig: false },
];
const stopped = renderStatusBar({ workspacePath: "/workspace/repo", workspaceName: "repo", remoteHosts });
ok(stopped.includes("SSH · Disconnected"), "configured SSH entry remains visible while disconnected");
ok(stopped.includes("SSH · Disconnected"), "disconnected SSH entry keeps its full accessible status");
ok(stopped.includes('statusbar__remote--idle'), "disconnected SSH entry uses the compact idle treatment");
ok(stopped.includes('<span class="statusbar__remote-label">SSH</span>'), "disconnected SSH entry renders only the compact SSH label");
ok(stopped.indexOf("SSH · Disconnected") < stopped.indexOf("workspace/repo"), "window-level SSH entry leads the status bar");
const connected = renderStatusBar({
@@ -62,6 +64,8 @@ console.log("\nstatus bar workspace");
remoteStatuses: { demo: { hostId: "demo", state: "connected" } },
});
ok(connected.includes("demo · Connected"), "SSH entry includes host and connected state text");
ok(connected.includes('statusbar__remote-state-dot'), "connected SSH entry renders a state dot");
ok(connected.includes('<span class="statusbar__remote-label">demo</span>'), "connected SSH entry renders the host without redundant state text");
const failed = renderStatusBar({
workspacePath: "/workspace/repo",
@@ -69,6 +73,7 @@ console.log("\nstatus bar workspace");
remoteStatuses: { demo: { hostId: "demo", state: "stopped", error: "handshake failed" } },
});
ok(failed.includes("demo · Connection failed"), "SSH entry keeps a recoverable failure summary visible");
ok(failed.includes('<span class="statusbar__remote-label">demo · Connection failed</span>'), "failed SSH entry keeps the failure visible in the status bar");
ok(!failed.includes("handshake failed"), "status entry keeps raw connection diagnostics out of primary chrome");
const degraded = renderStatusBar({
@@ -204,6 +209,24 @@ console.log("\nstatus bar workspace");
lastTurnOutputEstimated: true,
});
ok(estimated.includes("≈20 t/s"), "fallback TPS is visibly marked as estimated");
const perRequest = renderStatusBar({
items: ["turn_tps"],
lastRequestTps: 35,
lastTurnOutputTokens: 100,
lastTurnModelMs: 5_000,
});
ok(perRequest.includes("35 t/s"), "per-request TPS wins over the completed turn value");
const slowRequest = renderStatusBar({
items: ["turn_tps"], lastRequestTps: 1 / 3, lastTurnOutputTokens: 100, lastTurnModelMs: 5_000,
});
ok(slowRequest.includes("&lt;1 t/s") && !slowRequest.includes("20 t/s"), "sub-one request TPS replaces the stale turn fallback");
const unavailable = renderStatusBar({
items: ["turn_tps"], lastRequestTps: null, lastTurnOutputTokens: 100, lastTurnModelMs: 5_000,
});
ok(unavailable.includes('stat__value--empty">-</b>') && !unavailable.includes("20 t/s"), "unmeasured latest requests clear the stale turn fallback");
}
{
@@ -9,7 +9,6 @@ import { registerHooks } from "node:module";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import gsap from "gsap";
import { ToolCard } from "../components/ToolCard";
import { LocaleProvider } from "../lib/i18n";
import { setReasoningSummaryEnabled } from "../lib/reasoningSummaryPreference";
@@ -26,28 +25,6 @@ registerHooks({
type ToolItem = Extract<Item, { kind: "tool" }>;
// jsdom has no layout engine: stub the GSAP tween surface the collapse hook
// touches so layout effects complete synchronously. Under tsx the imported
// binding is a CJS interop object, so the stubs must go onto that object
// itself (the hook imports the same binding).
type GsapToOptions = { onComplete?: () => void };
const gsapForTests = gsap as unknown as {
to: (target: unknown, vars: GsapToOptions) => unknown;
fromTo: (target: unknown, from: unknown, vars: GsapToOptions) => unknown;
set: (target: unknown, vars: unknown) => unknown;
killTweensOf: (target: unknown) => void;
};
gsapForTests.to = (_target: unknown, vars: GsapToOptions) => {
vars.onComplete?.();
return {};
};
gsapForTests.fromTo = (_target: unknown, _from: unknown, vars: GsapToOptions) => {
vars.onComplete?.();
return {};
};
gsapForTests.set = () => ({});
gsapForTests.killTweensOf = () => {};
let passed = 0;
let failed = 0;
@@ -9,7 +9,6 @@ import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import gsap from "gsap";
import { ToolCard } from "../components/ToolCard";
import { LocaleProvider } from "../lib/i18n";
import { historyMessagesToItems, initialState, reducer, type Item } from "../lib/useController";
@@ -17,25 +16,6 @@ import type { HistoryMessage, WireShellExecution } from "../lib/types";
type ToolItem = Extract<Item, { kind: "tool" }>;
// jsdom has no layout engine: stub GSAP the same way as subagent-progress-card.
type GsapToOptions = { onComplete?: () => void };
const gsapForTests = gsap as unknown as {
to: (target: unknown, vars: GsapToOptions) => unknown;
fromTo: (target: unknown, from: unknown, vars: GsapToOptions) => unknown;
set: (target: unknown, vars: unknown) => unknown;
killTweensOf: (target: unknown) => void;
};
gsapForTests.to = (_target: unknown, vars: GsapToOptions) => {
vars.onComplete?.();
return {};
};
gsapForTests.fromTo = (_target: unknown, _from: unknown, vars: GsapToOptions) => {
vars.onComplete?.();
return {};
};
gsapForTests.set = () => ({});
gsapForTests.killTweensOf = () => {};
let passed = 0;
let failed = 0;
@@ -66,7 +66,7 @@ export async function createTranscriptHarness(options: TranscriptHarnessOptions
Object.defineProperty(dom.window, "matchMedia", {
configurable: true,
value: () => ({
matches: true, // prefers-reduced-motion: keep GSAP tweens out of the assertions
matches: true, // prefers-reduced-motion: keep visual transitions out of the assertions
media: "(prefers-reduced-motion: reduce)",
onchange: null,
addEventListener() {},
@@ -137,27 +137,10 @@ export async function createTranscriptHarness(options: TranscriptHarnessOptions
}
};
// GSAP's CSS plugin cannot run against jsdom; the assertions are about
// state-driven DOM, so the animation hooks are stubbed out.
const server = await createServer({
appType: "custom",
logLevel: "silent",
server: { middlewareMode: true },
plugins: [
{
name: "stub-animation-hooks",
enforce: "pre",
load(id) {
if (id.endsWith("/src/lib/useGSAPCollapse.ts")) {
return "export function useGSAPCollapse() {}";
}
if (id.endsWith("/src/lib/useEntranceAnimation.ts")) {
return "export function useEntranceAnimation() { return { current: null }; }";
}
return undefined;
},
},
],
});
const { Transcript } = await server.ssrLoadModule("/src/components/Transcript.tsx");
const { LocaleProvider } = await server.ssrLoadModule("/src/lib/i18n.tsx");
@@ -362,5 +362,133 @@ function ev(s: typeof initialState, e: WireEvent) {
}
}
// --- 7. lastRequestTps pairs the closed interval with the usage tokens ---
{
const originalNow = Date.now;
let now = 50_000;
Date.now = () => now;
try {
let s = ev({ ...initialState }, { kind: "turn_started" } as WireEvent);
now = 50_100;
s = ev(s, { kind: "text", text: "abcd" } as WireEvent);
now = 51_100;
// The message event closes the interval BEFORE the usage event arrives.
s = ev(s, { kind: "message", text: "abcd" } as WireEvent);
now = 51_200;
s = ev(s, { kind: "usage", usage: {
promptTokens: 130, completionTokens: 30, totalTokens: 160,
contextPromptTokens: 100, contextCompletionTokens: 20,
cacheHitTokens: 0, cacheMissTokens: 100, source: "executor",
} } as WireEvent);
eq(s.lastRequestTps, 20, "sampling recovery pairs the interval with latest-attempt tokens");
now = 52_000;
s = ev(s, { kind: "text", text: "more" } as WireEvent);
now = 52_050;
s = ev(s, { kind: "message", text: "more" } as WireEvent);
now = 52_100;
s = ev(s, { kind: "usage", usage: {
promptTokens: 5, completionTokens: 50, totalTokens: 55,
cacheHitTokens: 0, cacheMissTokens: 5, source: "subagent",
} } as WireEvent);
eq(s.lastRequestTps, 20, "non-executor usage neither computes nor consumes the pending interval");
now = 52_300;
s = ev(s, { kind: "usage", usage: {
promptTokens: 10, completionTokens: 30, totalTokens: 40,
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
} } as WireEvent);
eq(s.lastRequestTps, null, "intervals under the 500ms gate clear stale request TPS");
now = 53_000;
s = ev(s, { kind: "text", text: "second" } as WireEvent);
now = 54_000;
s = ev(s, { kind: "message", text: "second" } as WireEvent);
now = 54_100;
s = ev(s, { kind: "usage", usage: {
promptTokens: 10, completionTokens: 30, totalTokens: 40,
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
} } as WireEvent);
eq(s.lastRequestTps, 30, "a later executor usage refreshes the request TPS");
now = 55_000;
s = ev(s, { kind: "text", text: "direct" } as WireEvent);
now = 56_000;
s = ev(s, { kind: "usage", usage: {
promptTokens: 10, completionTokens: 40, totalTokens: 50,
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
} } as WireEvent);
eq(s.lastRequestTps, 40, "usage measures an interval still open at arrival");
now = 57_000;
s = ev(s, { kind: "turn_done" } as WireEvent);
eq(s.lastRequestTps, 40, "request TPS persists across turn boundaries");
now = 58_000;
s = ev(s, { kind: "turn_started" } as WireEvent);
now = 58_100;
s = ev(s, { kind: "usage", usage: {
promptTokens: 10, completionTokens: 8, totalTokens: 18,
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
} } as WireEvent);
eq(s.lastRequestTps, null, "usage without a provider interval clears stale request TPS");
now = 58_200;
s = ev(s, { kind: "tool_dispatch", tool: { id: "final-only", name: "read_file", args: "{}", readOnly: true } } as WireEvent);
eq(s.lastRequestTps, null, "a final-only tool dispatch after usage cannot resurrect stale TPS");
now = 59_000;
s = ev(s, { kind: "text", text: "toolcall" } as WireEvent);
now = 60_000;
s = ev(s, { kind: "tool_dispatch", tool: { id: "t1", name: "read_file", args: "{}", readOnly: true } } as WireEvent);
now = 60_100;
s = ev(s, { kind: "usage", usage: {
promptTokens: 10, completionTokens: 25, totalTokens: 35,
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
} } as WireEvent);
eq(s.lastRequestTps, 25, "tool_dispatch closes the interval the next executor usage pairs with");
now = 61_000;
s = ev(s, { kind: "tool_dispatch", tool: { id: "t2", name: "write_file", readOnly: false, partial: true, argChars: 600 } } as WireEvent);
now = 62_000;
s = ev(s, { kind: "usage", usage: {
promptTokens: 10, completionTokens: 30, totalTokens: 40,
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
} } as WireEvent);
eq(s.lastRequestTps, 30, "usage closes the interval started by a partial tool dispatch");
now = 62_100;
s = ev(s, { kind: "tool_dispatch", tool: { id: "t2", name: "write_file", args: "{}", readOnly: false } } as WireEvent);
eq(s.lastRequestTps, 30, "the later full tool dispatch preserves the measured request TPS");
now = 63_000;
s = ev(s, { kind: "text", text: "closing" } as WireEvent);
now = 64_000;
s = ev(s, { kind: "message", text: "closing" } as WireEvent);
now = 64_100;
s = ev(s, { kind: "tool_dispatch", tool: { id: "t3", name: "write_file", readOnly: false, partial: true, argChars: 300 } } as WireEvent);
now = 64_700;
s = ev(s, { kind: "tool_dispatch", tool: { id: "t3", name: "write_file", args: "{}", readOnly: false } } as WireEvent);
now = 64_800;
s = ev(s, { kind: "usage", usage: {
promptTokens: 10, completionTokens: 30, totalTokens: 40,
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
} } as WireEvent);
// The partial restart begins a new interval; the full dispatch closes it
// and overwrites the message-stashed pending with its own (≥500ms) tail.
eq(s.lastRequestTps, 50, "a full dispatch overwrites a message-stashed pending with its own tail close");
now = 65_000;
s = ev(s, { kind: "text", text: "slow" } as WireEvent);
now = 68_000;
s = ev(s, { kind: "message", text: "slow" } as WireEvent);
now = 68_100;
s = ev(s, { kind: "usage", usage: {
promptTokens: 10, completionTokens: 1, totalTokens: 11,
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
} } as WireEvent);
eq(s.lastRequestTps, 1 / 3, "slow measurable requests retain their raw sub-one TPS");
} finally {
Date.now = originalNow;
}
}
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,64 @@
// Run: tsx src/__tests__/workspace-path-copy.test.tsx
import { workspacePathCopyMenuItems } from "../lib/workspacePathCopyMenuItems";
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;
}
const clipboardWrites: string[] = [];
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
clipboard: {
writeText: async (value: string) => {
clipboardWrites.push(value);
},
},
},
});
let closeCalls = 0;
let resolveCalls = 0;
let scopeCurrent = true;
const items = workspacePathCopyMenuItems({
path: "src/",
resolveAbsolutePath: async () => {
resolveCalls += 1;
return "/repo/src";
},
isScopeCurrent: () => scopeCurrent,
close: () => {
closeCalls += 1;
},
relativeLabel: "Copy relative path",
absoluteLabel: "Copy absolute path",
});
console.log("\nworkspace path copy menu");
ok(items.length === 2, "builds both path-copy actions");
ok(items[0]?.label === "Copy relative path", "exposes the localized relative-path label");
ok(items[1]?.label === "Copy absolute path", "exposes the localized absolute-path label");
items[0]?.onSelect();
await new Promise((resolve) => setTimeout(resolve, 0));
ok(clipboardWrites[0] === "src", "folder relative paths omit the tree-only trailing slash");
ok(closeCalls === 1, "relative-path selection closes the menu");
items[1]?.onSelect();
await new Promise((resolve) => setTimeout(resolve, 0));
ok(resolveCalls === 1, "absolute-path selection uses backend resolution");
ok(clipboardWrites[1] === "/repo/src", "copies the backend-resolved absolute path");
scopeCurrent = false;
items[1]?.onSelect();
await new Promise((resolve) => setTimeout(resolve, 0));
ok(resolveCalls === 2 && clipboardWrites.length === 2, "drops stale absolute-path results after the workspace scope changes");
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -10,7 +10,7 @@ import {
PromptHeaderAction,
PromptShelf,
} from "./PromptShelf";
import { DUR_FAST } from "../lib/gsapAnimations";
import { animateElementExit, DUR_FAST } from "../lib/motion";
import {
FileReferenceMenu,
insertTextAtSelection,
@@ -18,27 +18,6 @@ import {
useFileReferenceMenu,
} from "./FileReferenceMenu";
function animateShelfExit(
el: HTMLDivElement,
options: { opacity: number; y: number; duration: number; ease: string; onComplete: () => void },
) {
if (typeof el.animate === "function") {
const animation = el.animate(
[
{ opacity: 1, transform: "translateY(0)" },
{ opacity: options.opacity, transform: `translateY(${options.y}px)` },
],
{
duration: options.duration * 1000,
easing: options.ease === "power2.out" ? "cubic-bezier(0.2, 0.72, 0.2, 1)" : options.ease,
},
);
animation.onfinish = options.onComplete;
return;
}
options.onComplete();
}
function requiresFreshHumanApproval(tool: string): boolean {
return tool === "remember" || tool === "forget" || tool === "exit_plan_mode" || tool === "sandbox_escape" || tool === "config_write";
}
@@ -322,9 +301,8 @@ export function ApprovalModal({
const onRevisionActiveChangeRef = useRef(onRevisionActiveChange);
const revisionActiveRef = useRef(false);
onRevisionActiveChangeRef.current = onRevisionActiveChange;
// When consecutive approvals arrive, animate the old card out before
// the new one slides in. GSAP fromTo on the shelf wrapper avoids the
// jarring pop when the API cycles through 4+ pending approvals.
// When consecutive approvals arrive, animate the old card out before the
// new one slides in so a queue of pending approvals does not visibly pop.
const closingRef = useRef(false);
const fileMenu = useFileReferenceMenu(revisionText, cwd, tabId, workspaceScopeKey);
@@ -334,11 +312,10 @@ export function ApprovalModal({
setSubmitting(true);
const el = shelfRef.current;
if (el) {
animateShelfExit(el, {
animateElementExit(el, {
opacity: 0,
y: 8,
duration: DUR_FAST,
ease: "power2.in",
onComplete: fn,
});
} else {
+1 -1
View File
@@ -4503,7 +4503,7 @@ export function Composer({
id="composer-input"
ref={taRef}
className="composer__input"
aria-label={t("composer.placeholder")}
aria-label={t("composer.placeholder")} spellCheck={false} autoCorrect="off" autoCapitalize="off"
value={text}
onInputCapture={(e) => {
pendingNativeInputTypeRef.current = (e.nativeEvent as InputEvent).inputType;
@@ -100,7 +100,7 @@ export function cacheHitTone(hitTokens: number, missTokens: number): MetricTone
return "warn";
}
function formatSharePercent(value: number, total: number): string {
export function formatSharePercent(value: number, total: number): string {
if (total <= 0 || value <= 0) return "-";
const pct = (value / total) * 100;
if (pct > 0 && pct < 1) return "<1%";
@@ -276,6 +276,16 @@ function sourceLabel(source: string, t: Translator): string {
}
}
function maintenanceActionLabel(action: string | undefined, t: Translator): string {
switch (action) {
case "snip": return t("context.maintenanceActionSnip");
case "prune": return t("context.maintenanceActionPrune");
case "summary": return t("summary.detail");
case "native_tool_clear": return t("context.maintenanceActionNative");
default: return t("common.none");
}
}
function sourceCost(stats: UsageSourceStats): number {
return stats.sessionCost && stats.sessionCost > 0 ? stats.sessionCost : stats.sessionCostUsd ?? 0;
}
@@ -451,6 +461,13 @@ export function ContextPanel({
const compactLabelPct = Math.max(6, Math.min(94, compactMarkerPct));
const usageSummary = t("context.windowUsageSummary", { used: usedLabel, window: windowLabel, pct: usagePct });
const compactSummary = t("context.windowCompactRemaining", { used: usedLabel, window: windowLabel, tokens: compactRemainingLabel, pct: compactPct });
const maintenance = context?.maintenance;
const lastMaintenance = maintenance?.lastReceipt;
const maintenanceStatus = maintenance?.blocked
? t("projectTree.status.paused")
: lastMaintenance?.status === "applied"
? t("settings.typography.applied")
: t("remote.server.state.ready");
const activeAnalysisView: UsageAnalysisView = showSourceUsageRows ? analysisView : "type";
const tokenTypeRows = [
{ key: "prompt", label: t("context.prompt"), value: breakdown.promptTokens },
@@ -549,6 +566,25 @@ export function ContextPanel({
</div>
</div>
</section>
{maintenance && (
<section className="context-panel__section context-panel__session-section">
<SectionHeading title={t("context.maintenanceTitle")} meta={maintenanceStatus} />
<div className="context-panel__session-metrics">
<div className="context-panel__summary-rows">
<MiniStat label={t("context.maintenanceProjected")} value={formatOptionalTokens(maintenance.projectedTokens)} tone="accent" />
<MiniStat label={t("history.title")} value={formatOptionalTokens(maintenance.canonicalTokens)} />
<MiniStat label={t("summary.detail")} value={formatOptionalTokens(maintenance.summaryTokens)} />
<MiniStat label={t("context.maintenanceLastSaved")} value={formatOptionalTokens(maintenance.lastSavedTokens)} tone={maintenance.lastSavedTokens ? "good" : undefined} />
<MiniStat label={t("context.maintenanceHeadroom")} value={formatOptionalTokens(maintenance.headroom)} wide />
<MiniStat label={t("context.maintenanceLastAction")} value={maintenanceActionLabel(lastMaintenance?.action, t)} />
<MiniStat label={t("context.maintenanceSnipAt")} value={formatOptionalTokens(maintenance.snipTrigger)} />
<MiniStat label={t("context.maintenanceFoldAt")} value={formatOptionalTokens(maintenance.foldTrigger)} />
<MiniStat label={t("context.maintenanceForceAt")} value={formatOptionalTokens(maintenance.forceTrigger)} />
<MiniStat label={t("context.maintenanceHardAt")} value={formatOptionalTokens(maintenance.hardInputCeiling)} />
</div>
</div>
</section>
)}
<section className="context-panel__section context-panel__session-section">
<SectionHeading title={t("context.sessionMetrics")} />
<div className="context-panel__session-metrics">
@@ -613,11 +649,10 @@ export function ContextPanel({
</div>
<div className="context-panel__source-legend">
{sourceUsageRows.map((row) => {
const sharePct = sourceTotalTokens > 0 ? (sourceTokenTotal(row) / sourceTotalTokens) * 100 : 0;
return (
<span key={row.source}>
<i className={`context-panel__source-dot context-panel__source-tone--${sourceTone(row.source)}`} aria-hidden="true" />
{sourceLabel(row.label, t)} {sharePct > 0 ? `${sharePct.toFixed(0)}%` : "-"}
{sourceLabel(row.label, t)} {formatSharePercent(sourceTokenTotal(row), sourceTotalTokens)}
</span>
);
})}
@@ -37,7 +37,7 @@ export function ExtensionCard({ item, tabId }: { item: ExtensionItem; tabId?: st
};
return (
<div className="extension-card" data-entrance="true" data-extension-surface={item.surfaceKey}>
<div className="extension-card" data-entrance={item.id} data-extension-surface={item.surfaceKey}>
<div className="extension-card__header">
<Puzzle size={14} className="extension-card__icon" aria-hidden="true" />
{card.title ? <span className="extension-card__title">{card.title}</span> : null}
@@ -4,15 +4,59 @@ import { Check, ChevronDown, Code2, Folder, SquareTerminal } from "lucide-react"
import { app as desktopApp } from "../lib/bridge";
import { t } from "../lib/i18n";
import { useToast } from "../lib/toast";
import type { ExternalOpenerView, ExternalOpenersView } from "../lib/types";
import type { ExternalOpenerView, ExternalOpenersView, TabMeta } from "../lib/types";
import { Tooltip } from "./Tooltip";
export interface ExternalOpenerBridge {
ExternalOpeners(): Promise<ExternalOpenersView>;
ExternalOpenersForTab(tabID: string): Promise<ExternalOpenersView>;
SetPreferredExternalOpener(id: string): Promise<void>;
OpenWorkspaceInExternalOpenerForTab(tabID: string, id: string): Promise<void>;
}
type ExternalOpenerPreferenceCoordinator = {
nextIntent: number;
latestSuccessfulIntent: number;
writeTail: Promise<void>;
preferred: string;
revision: number;
listeners: Set<(preferred: string) => void>;
};
const externalOpenerPreferenceCoordinators = new WeakMap<ExternalOpenerBridge, ExternalOpenerPreferenceCoordinator>();
function preferenceCoordinatorFor(bridge: ExternalOpenerBridge): ExternalOpenerPreferenceCoordinator {
const current = externalOpenerPreferenceCoordinators.get(bridge);
if (current) return current;
const created: ExternalOpenerPreferenceCoordinator = {
nextIntent: 0,
latestSuccessfulIntent: 0,
writeTail: Promise.resolve(),
preferred: "",
revision: 0,
listeners: new Set(),
};
externalOpenerPreferenceCoordinators.set(bridge, created);
return created;
}
function persistPreferredExternalOpener(
bridge: ExternalOpenerBridge,
id: string,
coordinator: ExternalOpenerPreferenceCoordinator,
intent: number,
): Promise<boolean> {
const write = coordinator.writeTail.then(async () => {
if (intent !== coordinator.latestSuccessfulIntent) return false;
await bridge.SetPreferredExternalOpener(id);
coordinator.preferred = id;
coordinator.revision += 1;
for (const listener of coordinator.listeners) listener(id);
return true;
});
coordinator.writeTail = write.then(() => undefined, () => undefined);
return write;
}
function fallbackOpenerIcon(opener: ExternalOpenerView) {
if (opener.kind === "file-manager") return <Folder size={15} strokeWidth={1.9} />;
if (opener.kind === "terminal") return <SquareTerminal size={15} strokeWidth={1.9} />;
@@ -42,7 +86,20 @@ function errorText(error: unknown): string {
}
function normalizeOpeners(next: ExternalOpenersView): ExternalOpenersView {
return { openers: Array.isArray(next.openers) ? next.openers : [], preferred: next.preferred ?? "" };
return {
openers: Array.isArray(next.openers) ? next.openers : [],
preferred: next.preferred ?? "",
workspaceOpenable: next.workspaceOpenable === true,
};
}
type ResolvedExternalOpeners = ExternalOpenersView & { tabId: string };
export function shouldMountExternalOpener(
tab: Pick<TabMeta, "id" | "scope"> | null | undefined,
imDetailVisible: boolean,
): boolean {
return !imDetailVisible && Boolean(tab?.id);
}
export function ExternalOpener({
@@ -59,30 +116,46 @@ export function ExternalOpener({
const discoveryRequestRef = useRef(0);
const mountedRef = useRef(true);
const busyRef = useRef(false);
const [state, setState] = useState<ExternalOpenersView>({ openers: [], preferred: "" });
const [state, setState] = useState<ResolvedExternalOpeners>({
openers: [],
preferred: "",
workspaceOpenable: false,
tabId: "",
});
const [menuOpen, setMenuOpen] = useState(false);
const [busy, setBusy] = useState(false);
const refreshOpeners = useCallback(async () => {
const request = ++discoveryRequestRef.current;
const preferenceCoordinator = preferenceCoordinatorFor(bridge);
const preferenceRevision = preferenceCoordinator.revision;
try {
const next = normalizeOpeners(await bridge.ExternalOpeners());
if (mountedRef.current && request === discoveryRequestRef.current) setState(next);
const next = normalizeOpeners(await bridge.ExternalOpenersForTab(tabId));
if (preferenceCoordinator.revision !== preferenceRevision && preferenceCoordinator.preferred) {
next.preferred = preferenceCoordinator.preferred;
}
if (mountedRef.current && request === discoveryRequestRef.current) setState({ ...next, tabId });
} catch (error) {
if (mountedRef.current && request === discoveryRequestRef.current) {
console.error("Failed to discover external openers", error);
}
}
}, [bridge]);
}, [bridge, tabId]);
useEffect(() => {
mountedRef.current = true;
const preferenceCoordinator = preferenceCoordinatorFor(bridge);
const syncPreferred = (preferred: string) => {
if (mountedRef.current) setState((current) => ({ ...current, preferred }));
};
preferenceCoordinator.listeners.add(syncPreferred);
void refreshOpeners();
return () => {
mountedRef.current = false;
discoveryRequestRef.current += 1;
preferenceCoordinator.listeners.delete(syncPreferred);
};
}, [refreshOpeners]);
}, [bridge, refreshOpeners]);
useEffect(() => setMenuOpen(false), [dismissSignal, tabId]);
@@ -103,13 +176,17 @@ export function ExternalOpener({
}, [menuOpen]);
const selected = useMemo(
() => state.openers.find((opener) => opener.id === state.preferred) ?? state.openers[0],
[state],
() => state.tabId === tabId
? state.openers.find((opener) => opener.id === state.preferred) ?? state.openers[0]
: undefined,
[state, tabId],
);
const openIn = useCallback(
async (opener: ExternalOpenerView, persist: boolean) => {
if (busyRef.current) return;
const preferenceCoordinator = persist ? preferenceCoordinatorFor(bridge) : undefined;
const preferenceIntent = preferenceCoordinator ? ++preferenceCoordinator.nextIntent : 0;
busyRef.current = true;
discoveryRequestRef.current += 1;
setBusy(true);
@@ -123,10 +200,19 @@ export function ExternalOpener({
showToast(t("externalOpener.failed", { name: opener.name, error: errorText(error) }), "error");
return;
}
if (persist && opener.id !== state.preferred) {
if (preferenceCoordinator) {
preferenceCoordinator.latestSuccessfulIntent = Math.max(
preferenceCoordinator.latestSuccessfulIntent,
preferenceIntent,
);
try {
await bridge.SetPreferredExternalOpener(opener.id);
setState((current) => ({ ...current, preferred: opener.id }));
const persisted = await persistPreferredExternalOpener(
bridge,
opener.id,
preferenceCoordinator,
preferenceIntent,
);
if (persisted && mountedRef.current) setState((current) => ({ ...current, preferred: opener.id }));
} catch (error) {
showToast(t("externalOpener.persistFailed", { name: opener.name, error: errorText(error) }), "error");
}
@@ -136,10 +222,10 @@ export function ExternalOpener({
setBusy(false);
}
},
[bridge, showToast, state.preferred, tabId],
[bridge, showToast, tabId],
);
if (!selected) return null;
if (state.tabId !== tabId || state.workspaceOpenable !== true || !selected) return null;
const openLabel = t("externalOpener.openIn", { name: selected.name });
return (
@@ -39,7 +39,7 @@ export function FloatingMenu({
const pos = useMemo(() => clampFloatingMenuPosition(x, y, width, estimatedHeight), [estimatedHeight, width, x, y]);
if (typeof document === "undefined") return null;
// Portal to <body>: the menu is position:fixed, but a transformed ancestor
// (e.g. .workspace-preview carries a residual GSAP transform) would otherwise
// (e.g. .workspace-preview carries a residual transform) would otherwise
// become its containing block and push the fixed coordinates off-screen.
// Rendering at the body root keeps fixed positioning relative to the viewport.
return createPortal(
+2 -2
View File
@@ -12,7 +12,7 @@ import { replaySubmitTextPreservingSelectedContext } from "../lib/editReplay";
import { useT } from "../lib/i18n";
import { ImageViewer } from "./ImageViewer";
import { Tooltip } from "./Tooltip";
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
import { useCollapseAnimation } from "../lib/useCollapseAnimation";
import { displayReasoningText, STREAMING_REASONING_WINDOW_STEP_CHARS, STREAMING_REASONING_WINDOW_STEP_LINES } from "../lib/reasoningDisplay";
import { ReasoningSummary } from "./ReasoningSummary";
import { historyEntryIdForItemId } from "../lib/transcriptRows";
@@ -919,7 +919,7 @@ function ReasoningPanel({
: "";
const label = isReasoningRunning ? t("msg.thinkingRunning") : t("msg.thinking");
const meta = isReasoningRunning ? "" : reasoningDurationLabel(item.reasoningDurationMs, t);
useGSAPCollapse(reasoningBodyRef, reasoningOpen);
useCollapseAnimation(reasoningBodyRef, reasoningOpen);
return (
<div className="reasoning">
+24 -24
View File
@@ -13,6 +13,7 @@ import type { ProjectNode, ProjectTopicStatus } from "../lib/types";
import { topicActivityTime } from "../lib/session";
import { getLocale, useT, type DictKey, type Translator } from "../lib/i18n";
import { PROJECT_COLOR_OPTIONS, projectColorValue } from "../lib/projectColors";
import { useProjectTreeArchiveState } from "../lib/projectTreeArchive";
import { topicShortcutLabel, type TopicShortcutEntry } from "../lib/topicShortcuts";
import type { ShortcutPlatform } from "../lib/keyboardShortcuts";
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
@@ -650,7 +651,7 @@ export function ProjectTree({
const [hoverCard, setHoverCard] = useState<{ key: string; card: ProjectTreeTopicHoverCard; left: number; top: number } | null>(null);
const hoverCardTimerRef = useRef<number | null>(null);
const creatingRef = useRef(false);
const trashingRef = useRef(false);
const { trashingTopics, beginTrashingTopic, endTrashingTopic } = useProjectTreeArchiveState();
const clickTimerRef = useRef<ProjectTreePendingTopicOpen | null>(null);
useEffect(() => {
return () => {
@@ -694,23 +695,14 @@ export function ProjectTree({
});
}, []);
// Keyed by the data, not the selection: a switch cannot change the tree.
const refresh = useCallback(async () => {
try {
const nodes = await app.ListProjectTree();
const list = asArray(nodes);
setTree(list);
setExpanded((prev) => {
const next = new Set(prev);
const collapsed = manuallyCollapsedRef.current;
for (const key of defaultExpandedProjectTreeKeys(list, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath)) {
if (!collapsed.has(key)) next.add(key);
}
return next;
});
setTree(asArray(await app.ListProjectTree()));
} catch {
/* bridge unavailable */
}
}, [activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath]);
}, []);
useEffect(() => {
manuallyCollapsedRef.current = manuallyCollapsed;
@@ -727,6 +719,15 @@ export function ProjectTree({
void refresh();
}, [refresh, refreshSignal]);
// Following the active topic is a view concern over the tree already held.
useEffect(() => {
const collapsed = manuallyCollapsedRef.current;
const keys = defaultExpandedProjectTreeKeys(tree, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath).filter((key) => !collapsed.has(key));
// Returning prev unchanged keeps a switch that expands nothing new from
// re-rendering the tree at all.
setExpanded((prev) => (keys.every((key) => prev.has(key)) ? prev : new Set([...prev, ...keys])));
}, [tree, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath]);
const markNodeRead = useCallback((node: ProjectNode) => {
const key = projectTreeReadActivityKey(node);
const activityAt = topicActivityAt(node);
@@ -1042,8 +1043,7 @@ export function ProjectTree({
};
const trashTopic = async (topicId: string) => {
if (trashingRef.current) return;
trashingRef.current = true;
if (!beginTrashingTopic(topicId)) return;
try {
await app.TrashTopic(topicId);
setMenuTopic(null);
@@ -1054,10 +1054,9 @@ export function ProjectTree({
} catch (err) {
showToast(err instanceof Error ? err.message : String(err), "error");
} finally {
trashingRef.current = false;
endTrashingTopic(topicId);
}
};
const setTopicPinned = async (topicId: string, pinned: boolean) => {
try {
await app.SetTopicPinned(topicId, pinned);
@@ -1323,6 +1322,7 @@ export function ProjectTree({
const showSideTime = sideTimeVisible && !showWaitingPill;
const unread = projectTreeTopicHasUnreadActivity(node, readActivity, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
const topicId = node.topicId ?? "";
const topicTrashing = trashingTopics.has(topicId);
const imSource = scope === "global" && topicId ? imTopicSources[topicId] : undefined;
const imSourceLabel = imSource?.label || "";
const imSourceTitle = imSourceLabel ? t("msg.fromIm", { source: imSourceLabel }) : "";
@@ -1361,9 +1361,9 @@ export function ProjectTree({
},
{
key: "trash",
icon: <Archive size={13} />,
icon: <Archive className={topicTrashing ? "project-tree__archive-spinner" : undefined} size={13} />,
label: confirmAction?.topicId === topicId && confirmAction.action === "trash" ? t("history.confirmMoveToTrash") : t("history.moveToTrash"),
disabled: archiveBlocked,
disabled: archiveBlocked || topicTrashing,
danger: true,
onSelect: () => {
if (confirmAction?.topicId === topicId && confirmAction.action === "trash") void trashTopic(topicId);
@@ -1529,17 +1529,17 @@ export function ProjectTree({
</Tooltip>
<Tooltip label={t("projectTree.archiveTopic")} side="top" className="project-tree__topic-action-slot">
<button
className="project-tree__topic-action project-tree__topic-action--archive"
className={`project-tree__topic-action project-tree__topic-action--archive${topicTrashing ? " project-tree__topic-action--busy" : ""}`}
type="button"
aria-label={t("projectTree.archiveTopic")}
disabled={archiveBlocked}
aria-busy={topicTrashing} disabled={archiveBlocked || topicTrashing}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void trashTopic(topicId);
}}
>
<Archive size={15} aria-hidden="true" />
<Archive className={topicTrashing ? "project-tree__archive-spinner" : undefined} size={15} aria-hidden="true" />
</button>
</Tooltip>
</span>
@@ -1750,11 +1750,11 @@ export function ProjectTree({
},
{
key: "archive-active-topic",
icon: <Archive size={13} />,
icon: <Archive className={activeTopicId && trashingTopics.has(activeTopicId) ? "project-tree__archive-spinner" : undefined} size={13} />,
label: activeTopicId && confirmAction?.topicId === activeTopicId && confirmAction.action === "trash"
? t("history.confirmMoveToTrash")
: t("projectTree.archiveConversation"),
disabled: !activeTopicInProject || !activeTopicId || activeTopicArchiveBlocked,
disabled: !activeTopicInProject || !activeTopicId || activeTopicArchiveBlocked || Boolean(activeTopicId && trashingTopics.has(activeTopicId)),
danger: true,
onSelect: () => {
if (!activeTopicId) return;
@@ -1,7 +1,7 @@
import { memo, useRef, useState } from "react";
import { ChevronRight } from "lucide-react";
import { useT } from "../lib/i18n";
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
import { useCollapseAnimation } from "../lib/useCollapseAnimation";
import type { Item } from "../lib/useController";
import { ToolCard } from "./ToolCard";
@@ -17,7 +17,7 @@ export const ReadOnlyBatch = memo(function ReadOnlyBatch({ items, subcalls, tabI
const t = useT();
const [open, setOpen] = useState(false);
const bodyRef = useRef<HTMLDivElement>(null);
useGSAPCollapse(bodyRef, open);
useCollapseAnimation(bodyRef, open);
const readCount = items.filter((it) => it.name === "read_file" || it.name === "ls").length;
const searchCount = items.filter((it) => it.name === "grep" || it.name === "glob" || it.name === "web_fetch").length;
@@ -917,7 +917,7 @@ export const RichComposerInput = forwardRef<RichComposerInputHandle, {
id="composer-input"
ref={rootRef}
className="composer__rich-input"
contentEditable={!disabled}
contentEditable={!disabled} spellCheck={false} autoCorrect="off" autoCapitalize="off"
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
+14 -17
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Activity, ChevronsUpDown, CircleDollarSign, CircleGauge, Database, FileOutput, Folder, Gauge, GitBranch, HardDrive, Layers, Percent, Puzzle, RefreshCw, Server, Settings, Square, Unplug, Wallet, Zap } from "lucide-react";
import { Activity, CircleDollarSign, CircleGauge, Database, FileOutput, Folder, Gauge, GitBranch, HardDrive, Layers, Percent, Puzzle, RefreshCw, Server, Settings, Square, Unplug, Wallet, Zap } from "lucide-react";
import { AnchoredPopover } from "./AnchoredPopover";
import { RemoteConnectionErrorDialog } from "./RemoteConnectionErrorDialog";
import { Tooltip } from "./Tooltip";
@@ -66,14 +66,8 @@ function formatTurnCount(turns: number | undefined, t: Translator): string {
if (typeof turns !== "number" || turns < 0) return "-";
return t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns });
}
function formatTps(outputTokens?: number, modelMs?: number, estimated = false): string | null {
if (!outputTokens || outputTokens <= 0) return null;
if (!modelMs || modelMs <= 0) return null;
const elapsedSec = modelMs / 1000;
if (elapsedSec < 0.001) return null;
const tps = outputTokens / elapsedSec;
function formatTps(tps?: number | null, estimated = false): string | null {
if (!tps || tps <= 0) return null;
const prefix = estimated ? "≈" : "";
if (tps < 1) return `${prefix}<1 t/s`;
return `${prefix}${Math.round(tps)} t/s`;
@@ -176,6 +170,7 @@ export function StatusBar({
lastTurnOutputTokens,
lastTurnModelMs,
lastTurnOutputEstimated = false,
lastRequestTps,
turnCost,
cost,
currency,
@@ -209,6 +204,7 @@ export function StatusBar({
lastTurnOutputTokens?: number;
lastTurnModelMs?: number;
lastTurnOutputEstimated?: boolean;
lastRequestTps?: number | null; // Null means the latest request was not measurable.
turnCost?: number;
cost?: number;
currency?: string;
@@ -255,7 +251,9 @@ export function StatusBar({
const tokenLabel = markEstimated(formatTokenCount(sessionTokens), sessionEstimated);
const turnTokenLabel = markEstimated(formatTokenCount(turnTokens), turnEstimated);
const balanceLabel = balance?.available && balance.display ? balance.display : "-";
const tpsLabel = formatTps(lastTurnOutputTokens, lastTurnModelMs, lastTurnOutputEstimated);
const tpsLabel = lastRequestTps === undefined
? formatTps(lastTurnOutputTokens && lastTurnModelMs ? lastTurnOutputTokens / (lastTurnModelMs / 1_000) : null, lastTurnOutputEstimated)
: formatTps(lastRequestTps);
const formatUsageToken = (value: number) => `${usage?.estimated ? "≈" : ""}${value.toLocaleString()}`;
const outputTokensLabel = usage && typeof usage.completionTokens === "number"
? formatUsageToken(usage.completionTokens)
@@ -673,25 +671,24 @@ function RemoteStatusBarChip({
const worstHost = hosts.find((host) => host.id === worst.hostId) ?? hosts[0];
const triggerState = isRemoteTerminalFailure(worst) ? "error" : worst.state;
const triggerStatus = isRemoteTerminalFailure(worst) ? t("remote.status.failed") : t(`remote.status.${worst.state}`);
const triggerLabel = worst.state === "stopped" && !worst.error
? t("remote.statusBar.disconnected")
: t("remote.statusBar.summary", { host: worstHost.label, status: triggerStatus });
const idleDisconnected = worst.state === "stopped" && !worst.error;
const triggerLabel = idleDisconnected ? t("remote.statusBar.disconnected") : t("remote.statusBar.summary", { host: worstHost.label, status: triggerStatus });
const triggerText = idleDisconnected ? "SSH" : triggerState === "connected" ? worstHost.label : triggerLabel;
return (
<span className="statusbar__remote-wrap">
<button
ref={triggerRef}
type="button"
className={`statusbar__remote remote-chip remote-chip--${triggerState}`}
className={`statusbar__remote remote-chip remote-chip--${triggerState}${idleDisconnected ? " statusbar__remote--idle" : ""}`}
onClick={() => setOpen((value) => !value)}
aria-label={triggerLabel}
aria-haspopup="dialog"
aria-expanded={open}
title={triggerLabel}
>
<Server size={11} aria-hidden="true" />
<span>{triggerLabel}</span>
<ChevronsUpDown size={10} aria-hidden="true" />
{triggerState === "connected" ? <span className="statusbar__remote-state-dot" aria-hidden="true" /> : <Server size={11} aria-hidden="true" />}
<span className="statusbar__remote-label">{triggerText}</span>
</button>
<AnchoredPopover
open={open}
+3 -3
View File
@@ -6,7 +6,7 @@ import { useT } from "../lib/i18n";
import { diffsFor, languageForToolArgs, subjectOf, summarize, summarizeFileDiff } from "../lib/tools";
import { useShellExpand } from "../lib/shellExpand";
import { app } from "../lib/bridge";
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
import { useCollapseAnimation } from "../lib/useCollapseAnimation";
import { isTerminalSubagentPhase, type Item, type SubagentPhase } from "../lib/useController";
import type { Translator } from "../lib/i18n";
import { ReadOnlyBatch } from "./ReadOnlyBatch";
@@ -294,9 +294,9 @@ export const ToolCard = memo(function ToolCard({ item, subcalls, tabId, displayN
? `${shellName} ${item.status}${shellSummary || summary ? ` ${shellSummary || summary}` : ""}`
: undefined;
// GSAP-driven collapse/expand for tool body
// Native collapse/expand for the tool body.
const toolBodyRef = useRef<HTMLDivElement>(null);
useGSAPCollapse(toolBodyRef, open);
useCollapseAnimation(toolBodyRef, open);
return (
<div className={`tool${quiet ? " tool--quiet" : ""}${isSubagent ? " tool--subagent" : ""}${open && hasBody ? " tool--open" : ""}`} data-entrance={item.id} data-shell={isShellCard ? execution?.shell || "bash" : undefined}>
@@ -1,7 +1,7 @@
import { memo, useRef, useState } from "react";
import { ChevronRight } from "lucide-react";
import { useT } from "../lib/i18n";
import { useGSAPCollapse } from "../lib/useGSAPCollapse";
import { useCollapseAnimation } from "../lib/useCollapseAnimation";
import type { Item } from "../lib/useController";
import { ToolCard } from "./ToolCard";
@@ -121,7 +121,7 @@ export const ToolGroup = memo(function ToolGroup({
const t = useT();
const [open, setOpen] = useState(false);
const bodyRef = useRef<HTMLDivElement>(null);
useGSAPCollapse(bodyRef, open);
useCollapseAnimation(bodyRef, open);
if (items.length === 0) return null;
+11 -12
View File
@@ -14,7 +14,7 @@ import { ReadOnlyBatch } from "./ReadOnlyBatch";
import { ToolGroup } from "./ToolGroup";
import { getProcessFoldPreference, onProcessFoldPreferenceChange, type ProcessFoldPreference } from "../lib/processFoldPreference";
import { STEER_NOTICE_PREFIX, isSteerNoticeText } from "../lib/useController";
import { useEntranceAnimation } from "../lib/useEntranceAnimation";
import { useTranscriptEntranceAnimation } from "../lib/useEntranceAnimation";
import { useScrollManager } from "../lib/useScrollManager";
import { compactQuestionText, lastQuestionTurn, questionAnchorId, questionTurnsById, scrollVersion, type QuestionAnchor } from "../lib/transcriptGrouping";
import {
@@ -145,7 +145,7 @@ function InlineAssistantReasoning({ item }: { item: AssistantItem }) {
// The transcript is a single flat virtual list (block-level rows: user
// message, process-fold header, tool batch, answer, notice, turn actions, …)
// rendered by @tanstack/react-virtual over the scroll container. Overscan of 8
// rows keeps offscreen Markdown/ToolCard/Mermaid/GSAP instances unmounted.
// rows keeps offscreen Markdown, ToolCard, Mermaid, and animation instances unmounted.
// anchorTo: "end" makes the virtualizer compensate prepends (older history
// pages), fold toggles and async height drift against the stable row keys, so
// the reading position does not jump; measurement (measureElement) owns all
@@ -442,8 +442,7 @@ export function Transcript({
setCreationScrollbarHot(true);
}, [SCROLLBAR_HOT_ZONE_PX, SCROLLBAR_MIN_THUMB_PX, creationMode, scrollRef, setCreationScrollbarHot, syncCreationScrollbarMetrics]);
const sessionKey = useMemo(() => `${items[0]?.id ?? ""}|${items[items.length - 1]?.id ?? ""}`, [items]);
const entranceRef = useEntranceAnimation<HTMLDivElement>(sessionKey, items.length);
const entranceRef = useTranscriptEntranceAnimation<HTMLDivElement>(tabId, revealSignal, items);
// Lease the markdown parse worker for as long as a transcript surface is
// mounted; the last release terminates the thread (it re-spawns lazily).
@@ -562,7 +561,7 @@ export function Transcript({
};
}, [items.length, scheduleRepinIfWasPinned]);
// Footer height changes → smooth scroll repin with GSAP.
// Footer height changes → frame-batched scroll repin.
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
@@ -782,7 +781,7 @@ export function Transcript({
case "phase":
return (
<div className="turn-collapse__body">
<PhaseCard text={row.item.text} />
<PhaseCard id={row.item.id} text={row.item.text} />
</div>
);
case "process-notice":
@@ -810,7 +809,7 @@ export function Transcript({
);
case "notice":
if (isSteerNoticeText(row.item.text)) {
return <SteerCard text={row.item.text} />;
return <SteerCard id={row.item.id} text={row.item.text} />;
}
return (
<NoticeCard
@@ -1135,17 +1134,17 @@ function QuestionJumpBar({ questions, onJump }: { questions: QuestionAnchor[]; o
type CompactionItem = Extract<Item, { kind: "compaction" }>;
function PhaseCard({ text }: { text: string }) {
return <div className="phase" data-entrance="true"><ProcessPhaseIcon size={12} /><span>{text}</span></div>;
function PhaseCard({ id, text }: { id: string; text: string }) {
return <div className="phase" data-entrance={id}><ProcessPhaseIcon size={12} /><span>{text}</span></div>;
}
// A mid-turn steer is the user's own message, so it renders on the user side
// of the transcript instead of disappearing into the work fold.
function SteerCard({ text }: { text: string }) {
function SteerCard({ id, text }: { id: string; text: string }) {
const t = useT();
const body = text.startsWith(STEER_NOTICE_PREFIX) ? text.slice(STEER_NOTICE_PREFIX.length) : text;
return (
<div className="steer-line" data-entrance="true">
<div className="steer-line" data-entrance={id}>
<div className="steer-line__bubble" title={t("transcript.steer")}>
<span className="steer-line__icon" aria-hidden="true">↪</span>
<span className="steer-line__text">{body}</span>
@@ -1194,7 +1193,7 @@ export function NoticeCard({ item, onAction, actionDisabled = false }: { item: N
const t = useT();
const StatusIcon = item.level === "warn" ? TriangleAlert : Info;
return (
<div className={`notice-line notice-line--${item.level}${item.variant ? ` notice-line--${item.variant}` : ""}`} data-entrance="true">
<div className={`notice-line notice-line--${item.level}${item.variant ? ` notice-line--${item.variant}` : ""}`} data-entrance={item.id}>
<StatusIcon className="notice-line__icon" size={14} aria-hidden="true" />
<div className="notice-line__text">
{item.decisionReceipt ? (
@@ -60,6 +60,7 @@ import { workspaceGitStatusLabel } from "../lib/workspaceChanges";
import { formatWorkspaceReference, WORKSPACE_REF_DRAG_TYPE } from "../lib/workspaceDrag";
import { formatSelectionReference, languageFor } from "../lib/selectedTextContext";
import { cleanGitDiff } from "../lib/diff";
import { WORKSPACE_CONTEXT_MENU_FILE_HEIGHT, WORKSPACE_CONTEXT_MENU_REF_HEIGHT, workspacePathCopyMenuItems } from "../lib/workspacePathCopyMenuItems";
import { CodeViewer } from "./CodeViewer";
import { DiffView } from "./DiffView";
import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu";
@@ -75,8 +76,6 @@ const WORKSPACE_TREE_RAIL_WIDTH = 44;
const WORKSPACE_PREVIEW_MIN_WIDTH = 140;
const WORKSPACE_PREVIEW_TARGET_WIDTH = 360;
const WORKSPACE_DUAL_PANEL_TARGET_WIDTH = WORKSPACE_TREE_DEFAULT_WIDTH + WORKSPACE_PREVIEW_TARGET_WIDTH;
const WORKSPACE_CONTEXT_MENU_FILE_HEIGHT = 136;
const WORKSPACE_CONTEXT_MENU_REF_HEIGHT = 92;
const WORKSPACE_CONTEXT_MENU_SELECTION_HEIGHT = 48;
const WORKSPACE_MAX_PREVIEW_TABS = 5;
@@ -2123,6 +2122,7 @@ export function WorkspacePanel({
onSelect: () => void addTreeFileToChat(),
},
]),
...workspacePathCopyMenuItems({ path: treeMenu.path, resolveAbsolutePath: () => app.ResolveWorkspacePathForTab(workspaceTabId, treeMenu.path), isScopeCurrent: () => currentWorkspaceScopeKeyRef.current === workspaceScopeKey, close: () => setTreeMenu(null), relativeLabel: t("workspace.copyRelativePath"), absoluteLabel: t("workspace.copyAbsolutePath") }),
{
icon: <FolderOpen size={14} />,
label: t("workspace.revealInFileManager"),
+10 -10
View File
@@ -1,8 +1,6 @@
// bridge is the single seam between the React app and the Go kernel. In the Wails
// shell it calls the bound App methods (window.go.main.App.*) and subscribes to
// the runtime event stream (window.runtime.EventsOn). In a plain browser (`pnpm
// dev` outside the shell) those globals are absent, so it falls back to a mock
// that streams a canned turn through the same contract — letting the whole UI be
// bridge is the seam between React and the Go kernel. The Wails shell calls bound
// App methods and subscribes to runtime events; in a plain browser (`pnpm dev`),
// a mock streams a canned turn through the same contract so the whole UI can be
// developed and laid out without rebuilding the Go side.
// @ts-ignore `wails generate module` creates this locally; fresh checkouts keep
@@ -11,6 +9,7 @@ import type * as GeneratedApp from "../../wailsjs/go/main/App";
import type { InvocationRequest } from "./invocationDisplay";
import { addBreadcrumb } from "./breadcrumbs";
import { maybeShare } from "./queryCoalesce";
import { t } from "./i18n";
import { providerIsConfigured, providerRequiresKey, removeProviderAccessesForMock } from "./providerModels";
import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems } from "./statusBarItems";
@@ -387,7 +386,8 @@ export interface AppBindings {
WorkspaceGitCommitDetail(tabID: string, hash: string, path: string): Promise<GitCommitDetailView>;
OpenWorkspacePath(rel: string): Promise<void>;
OpenWorkspacePathForTab(tabID: string, rel: string): Promise<void>;
ExternalOpeners(): Promise<ExternalOpenersView>;
ResolveWorkspacePathForTab(tabID: string, rel: string): Promise<string>;
ExternalOpeners(): Promise<ExternalOpenersView>; ExternalOpenersForTab(tabID: string): Promise<ExternalOpenersView>;
SetPreferredExternalOpener(id: string): Promise<void>;
OpenWorkspaceInExternalOpener(id: string): Promise<void>;
OpenWorkspaceInExternalOpenerForTab(tabID: string, id: string): Promise<void>; OpenLocalPathInExternalOpener(path: string, id: string): Promise<void>; SaveLocalPathAs(path: string): Promise<string>;
@@ -1008,12 +1008,11 @@ export const app: AppBindings = new Proxy({} as AppBindings, {
const v = (target as unknown as Record<string, unknown>)[String(prop)];
if (typeof v !== "function") return v;
return (...args: unknown[]) => {
const method = String(prop);
const crumb = bridgeBreadcrumb(method);
const method = String(prop), crumb = bridgeBreadcrumb(method);
const startedAt = crumb ? (typeof performance !== "undefined" ? performance.now() : Date.now()) : 0;
if (crumb) addBreadcrumb("bridge", crumb);
try {
const result = (v as (...a: unknown[]) => unknown).apply(target, args);
const result = maybeShare(method, args, () => (v as (...a: unknown[]) => unknown).apply(target, args));
if (result && typeof (result as Promise<unknown>).then === "function") {
return (result as Promise<unknown>).then(
(value) => {
@@ -3982,6 +3981,7 @@ function makeMockApp(): AppBindings {
async OpenWorkspacePathForTab(_tabID: string, rel: string) {
await this.OpenWorkspacePath(rel);
},
async ResolveWorkspacePathForTab(_tabID: string, rel: string) { return `${cwd.replace(/[\\/]+$/, "")}/${rel.replace(/^[/\\]+/, "").replace(/[\\/]+$/, "")}`; },
async ExternalOpeners() {
return {
openers: [
@@ -3992,7 +3992,7 @@ function makeMockApp(): AppBindings {
],
preferred: "vscode",
} as ExternalOpenersView;
},
}, async ExternalOpenersForTab(_tabID: string) { return { ...(await this.ExternalOpeners()), workspaceOpenable: true }; },
async SetPreferredExternalOpener(_id: string) {},
async OpenWorkspaceInExternalOpener(_id: string) {},
async OpenWorkspaceInExternalOpenerForTab(_tabID: string, id: string) {
@@ -0,0 +1,75 @@
import type { Translator } from "./i18n";
export type ContextMaintenanceStatus = "planned" | "applied" | "noop" | "blocked" | "failed";
export type ContextMaintenanceAction = "snip" | "prune" | "summary" | "native_tool_clear" | "noop";
export interface WireContextMaintenance {
status?: ContextMaintenanceStatus;
action?: ContextMaintenanceAction;
trigger?: string;
operationId?: string;
inputTokens?: number;
resultTokens?: number;
savedTokens?: number;
affectedToolResults?: number;
projectionVersion?: number;
cacheBreak?: boolean;
reason?: string;
}
export interface ContextMaintenanceReceipt extends WireContextMaintenance {
sourceProjection?: number;
coveredCount?: number;
coveredPrefixHash?: string;
inputHash?: string;
outputHash?: string;
summaryHash?: string;
archive?: string;
blockedInputHash?: string;
createdAt?: string;
}
export interface ContextMaintenanceInfo {
canonicalTokens?: number;
projectedTokens?: number;
summaryTokens?: number;
lastSavedTokens?: number;
snipTrigger?: number;
foldTrigger?: number;
forceTrigger?: number;
hardInputCeiling?: number;
headroom?: number;
projectionVersion?: number;
blocked?: boolean;
lastReceipt?: ContextMaintenanceReceipt;
}
function maintenanceActionLabel(action: ContextMaintenanceAction | undefined, t: Translator): string | undefined {
switch (action) {
case "snip": return t("context.maintenanceActionSnip");
case "prune": return t("context.maintenanceActionPrune");
case "summary": return t("summary.detail");
case "native_tool_clear": return t("context.maintenanceActionNative");
default: return undefined;
}
}
export function formatContextMaintenanceNotice(m: WireContextMaintenance, t: Translator): string {
const parts = [t("context.maintenanceTitle")];
const action = maintenanceActionLabel(m.action, t);
if (action) parts.push(action);
switch (m.status) {
case "blocked": parts.push(t("projectTree.status.paused")); break;
case "failed": parts.push(t("task.state.failed")); break;
case "applied": parts.push(t("settings.typography.applied")); break;
}
if (typeof m.inputTokens === "number" && typeof m.resultTokens === "number") {
parts.push(t("context.tokensValue", {
value: `${m.inputTokens.toLocaleString()} → ${m.resultTokens.toLocaleString()}`,
}));
}
if (typeof m.savedTokens === "number" && m.savedTokens > 0) {
parts.push(`−${t("context.tokensValue", { value: m.savedTokens.toLocaleString() })}`);
}
return parts.join(" · ");
}
@@ -1,21 +0,0 @@
// Shared GSAP animation configuration.
// Mirrors the CSS token system (--dur-fast/--dur-base/--dur-slow/--ease-out)
// so JS-driven animations stay in sync with the CSS transition layer.
/** 120ms — color/border hovers, tooltips. */
export const DUR_FAST = 0.12;
/** 180ms — popovers, menus, small enters. Matches CSS --dur-base. */
export const DUR_BASE = 0.18;
/** 340ms — drawers, modals, panel slides. Matches CSS --dur-slow. */
export const DUR_SLOW = 0.34;
/** "power2.out" approximates the app-wide CSS cubic-bezier(0.2, 0.72, 0.2, 1). */
export const EASE_OUT = "power2.out";
/** Returns true when the user has requested reduced motion at the OS level. */
export function prefersReducedMotion(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
+55
View File
@@ -0,0 +1,55 @@
// Shared timing configuration for CSS and Web Animations API transitions.
// The curves mirror the desktop CSS motion tokens.
/** 120ms — color/border hovers, tooltips. */
export const DUR_FAST = 0.12;
/** 180ms — popovers, menus, small enters. Matches CSS --dur-base. */
export const DUR_BASE = 0.18;
/** 340ms — drawers, modals, panel slides. Matches CSS --dur-slow. */
export const DUR_SLOW = 0.34;
/** Fast-out/decelerate curve used by expanding and entrance animations. */
export const CSS_EASE_OUT = "cubic-bezier(0.2, 0.72, 0.2, 1)";
/** Accelerating curve used by short exit animations. */
export const CSS_EASE_IN = "cubic-bezier(0.8, 0, 0.8, 0.28)";
/** Runs a short exit transition without letting cosmetic failures block work. */
export function animateElementExit(
el: HTMLElement,
options: { opacity: number; y: number; duration: number; onComplete: () => void },
) {
let completed = false;
const complete = () => {
if (completed) return;
completed = true;
options.onComplete();
};
if (typeof el.animate !== "function") {
complete();
return;
}
let animation: Animation;
try {
animation = el.animate(
[
{ opacity: 1, transform: "translateY(0)" },
{ opacity: options.opacity, transform: `translateY(${options.y}px)` },
],
{ duration: options.duration * 1000, easing: CSS_EASE_IN },
);
} catch {
complete();
return;
}
animation.onfinish = complete;
animation.oncancel = complete;
}
/** Returns true when the user has requested reduced motion at the OS level. */
export function prefersReducedMotion(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
@@ -0,0 +1,26 @@
import { useCallback, useRef, useState } from "react";
export function projectTreeTrashingTopics(previous: Set<string>, topicId: string, trashing: boolean): Set<string> {
const id = topicId.trim();
if (!id || previous.has(id) === trashing) return previous;
const next = new Set(previous);
if (trashing) next.add(id);
else next.delete(id);
return next;
}
export function useProjectTreeArchiveState() {
const topicsRef = useRef<Set<string>>(new Set());
const [topics, setTopics] = useState<Set<string>>(new Set());
const begin = useCallback((topicId: string) => {
if (topicsRef.current.has(topicId)) return false;
topicsRef.current = projectTreeTrashingTopics(topicsRef.current, topicId, true);
setTopics(topicsRef.current);
return true;
}, []);
const end = useCallback((topicId: string) => {
topicsRef.current = projectTreeTrashingTopics(topicsRef.current, topicId, false);
setTopics(topicsRef.current);
}, []);
return { trashingTopics: topics, beginTrashingTopic: begin, endTrashingTopic: end };
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Coalesces identical read-only backend queries that a single UI transition
* fires more than once.
*
* Switching a session fans out ~20-28 bridge calls, and measurement showed a
* third of them are duplicates within the same transition: independent hooks
* each ask for the context panel, the project tree, the tab meta. Deduping at
* the call sites would mean threading a cache through a dozen hooks; deduping
* at the bridge is one place and cannot be forgotten by the next hook.
*
* Only queries on the allowlist are coalesced, and only for the few
* milliseconds a transition takes: a stale answer is worse than a slow one, so
* nothing here outlives the burst it exists to collapse.
*/
/** How long an in-flight or just-settled answer may be shared. */
const WINDOW_MS = 200;
/**
* COALESCED lists read-only queries whose answer cannot change within one UI
* transition. Anything that mutates, starts work, or reads a value the user is
* actively editing stays off this list.
*/
const COALESCED = new Set([
"ListProjectTree",
"ListTabs",
"ContextPanel",
"MetaForTab",
"EffortForTab",
"JobsForTab",
"BackgroundRuntimes",
"BalanceForTab",
]);
type Entry = { promise: Promise<unknown>; at: number };
const inflight = new Map<string, Entry>();
function now(): number {
return typeof performance !== "undefined" ? performance.now() : Date.now();
}
export function coalescesQuery(method: string): boolean {
return COALESCED.has(method);
}
/**
* maybeShare is the bridge's single entry point: it decides whether a call is
* shareable and shares it, so the proxy never has to know the allowlist.
*/
export function maybeShare(method: string, args: unknown[], run: () => unknown): unknown {
if (!coalescesQuery(method)) return run();
return shareQuery(method, args, async () => run());
}
/**
* shareQuery returns the in-flight answer for an identical call made inside the
* window, or runs it. A rejection is never shared beyond its own settlement:
* the next caller retries rather than inheriting a stale failure. Callers that
* cross an external state boundary must explicitly invalidate that query.
*/
export function shareQuery<T>(method: string, args: unknown[], run: () => Promise<T>): Promise<T> {
const key = `${method}|${safeKey(args)}`;
const at = now();
const hit = inflight.get(key);
if (hit && at - hit.at < WINDOW_MS) return hit.promise as Promise<T>;
const promise = run();
inflight.set(key, { promise, at });
void promise.then(
() => {
if (method === "ListTabs") {
forget(key, promise);
return;
}
// A settled answer stays shareable for the rest of the window: the
// duplicates this exists for arrive within a few milliseconds of it.
setTimeout(() => forget(key, promise), WINDOW_MS);
},
() => forget(key, promise),
);
return promise;
}
function forget(key: string, promise: Promise<unknown>): void {
if (inflight.get(key)?.promise === promise) inflight.delete(key);
}
/** Arguments are identifiers and flags; anything unserialisable disables the share. */
function safeKey(args: unknown[]): string {
try {
return JSON.stringify(args);
} catch {
return `\u0000${Math.random()}`;
}
}
/** Drop a settled burst answer at an external state boundary (for example turn_done). */
export function invalidateSharedQuery(method: string, args: unknown[]): void {
inflight.delete(`${method}|${safeKey(args)}`);
}
/** Test seam: forget everything remembered so far. */
export function resetQueryCoalescing(): void {
inflight.clear();
}
+7 -2
View File
@@ -2,6 +2,8 @@
// One event channel carries every kind; `kind` discriminates the payload.
import type { Todo } from "./tools";
import type { ContextMaintenanceInfo, WireContextMaintenance } from "./contextMaintenanceTypes";
export type { ContextMaintenanceInfo, ContextMaintenanceReceipt, WireContextMaintenance } from "./contextMaintenanceTypes";
export type EventKind =
| "turn_started"
@@ -25,7 +27,8 @@ export type EventKind =
| "guardian_assessment"
| "extension_surface"
| "extension_status"
| "stream_attempt";
| "stream_attempt"
| "context_maintenance";
export type StreamAttemptAction = "begin" | "discard" | "commit";
@@ -292,6 +295,7 @@ export interface WireEvent {
approval?: WireApproval;
ask?: WireAsk;
compaction?: WireCompaction;
maintenance?: WireContextMaintenance;
guardian?: WireGuardian;
decisionReceipt?: WireDecisionReceipt;
extension?: WireExtensionSurface;
@@ -789,6 +793,7 @@ export interface ContextInfo {
cacheMissTokens?: number;
estimated?: boolean;
sources?: Record<string, UsageSourceStats>;
maintenance?: ContextMaintenanceInfo;
}
export interface Meta {
@@ -2128,7 +2133,7 @@ export interface ExternalOpenerView {
export interface ExternalOpenersView {
openers: ExternalOpenerView[];
preferred: string;
preferred: string; workspaceOpenable?: boolean;
}
// Auto-updater payloads (desktop/updater.go). UpdateInfo drives the update banner;
@@ -0,0 +1,96 @@
import { useLayoutEffect, useRef } from "react";
import { CSS_EASE_OUT, DUR_BASE, prefersReducedMotion } from "./motion";
/**
* Animates a container's height between 0 and its scrollHeight whenever
* `open` flips. The visual transition always fails open: an unavailable or
* rejecting Web Animations implementation must still apply the requested
* final state and completion callback.
*/
export function useCollapseAnimation(
ref: React.RefObject<HTMLElement | null>,
open: boolean,
opts?: {
duration?: number;
/** Called after the open animation completes or falls back. */
onOpenComplete?: () => void;
/** Called after the close animation completes or falls back. */
onCloseComplete?: () => void;
/** When closing, use this height as the starting point instead of
* measuring scrollHeight after content has changed. */
prevHeight?: number;
},
) {
const prevOpen = useRef<boolean | null>(null);
const onOpenRef = useRef(opts?.onOpenComplete);
const onCloseRef = useRef(opts?.onCloseComplete);
onOpenRef.current = opts?.onOpenComplete;
onCloseRef.current = opts?.onCloseComplete;
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
// Initial history can contain hundreds of collapsed rows. Seed the final
// state directly before paint instead of constructing animations on mount.
if (prevOpen.current === null) {
prevOpen.current = open;
el.style.height = open ? "auto" : "0px";
return;
}
if (prevOpen.current === open) return;
prevOpen.current = open;
const configuredDuration = opts?.duration ?? DUR_BASE;
const duration = prefersReducedMotion()
? 0
: (Number.isFinite(configuredDuration) && configuredDuration >= 0 ? configuredDuration : DUR_BASE) * 1000;
let superseded = false;
let settled = false;
const finish = () => {
if (superseded || settled) return;
settled = true;
el.style.height = open ? "auto" : "0px";
if (open) onOpenRef.current?.();
else onCloseRef.current?.();
};
if (duration === 0 || typeof el.animate !== "function") {
finish();
return;
}
const keyframes = open
? [{ height: "0px" }, { height: `${el.scrollHeight}px` }]
: [
{
height: `${opts?.prevHeight && opts.prevHeight > 0
? opts.prevHeight
: Math.max(el.getBoundingClientRect().height, el.scrollHeight)}px`,
},
{ height: "0px" },
];
let animation: Animation;
try {
animation = el.animate(keyframes, { duration, easing: CSS_EASE_OUT });
} catch {
finish();
return;
}
animation.onfinish = finish;
animation.oncancel = finish;
return () => {
// A dependency change supersedes this direction. Suppress the old final
// state/callback before cancellation so the next effect owns settlement.
superseded = true;
try {
animation.cancel();
} catch {
// Cancellation is cleanup-only; the next render already owns state.
}
};
}, [open, ref]);
}
+47 -38
View File
@@ -8,7 +8,9 @@ import { asArray } from "./array";
import { addBreadcrumb } from "./breadcrumbs";
import { app, onEvent, onReady, onRuntimeRebuilt, onTabMeta, onTopicActivation } from "./bridge";
import { invalidateCache } from "./composerHistory";
import { formatContextMaintenanceNotice } from "./contextMaintenanceTypes";
import { formatGuardianAssessmentNotice } from "./guardianEvents";
import { invalidateSharedQuery } from "./queryCoalesce";
import { createRafBatch } from "./rafBatch";
import { aliasActivationRequest, noteActivationRequested, noteActivationSettled, noteActivationStarted } from "./sessionDiagnostics";
import { applyLiveSegments, coalesceStreamDeltas, completeLiveReasoning, type StreamDeltaEntry, type StreamSegment } from "./streamDeltaBatch";
@@ -414,6 +416,9 @@ interface State {
lastTurnWaitAccumMs: number;
lastTurnModelMs: number;
lastTurnOutputEstimated: boolean;
// Per-request rate (null when unmeasurable) and pending interval are tab-local.
lastRequestTps?: number | null;
pendingRequestModelMs?: number;
promptWaitStartedAt?: number;
// promptEventClock() reading taken when the CURRENT pending prompt first
// arrived. Orders the prompt against reconciliation snapshots so a snapshot
@@ -1126,7 +1131,7 @@ function endPromptWaitIfIdle(s: State, now = Date.now()): State {
return endPromptWait(s, now);
}
function resetTurnTiming(now = Date.now()): Pick<State, "turnStartAt" | "turnDoneAt" | "turnWaitAccumMs" | "promptWaitStartedAt" | "turnTokens" | "turnTotalTokens" | "turnOutputTokens" | "turnOutputChars" | "turnOutputCharsAtUsage" | "turnOutputEstimated" | "turnModelActiveAt" | "turnModelActiveMs" | "turnCost" | "turnArgChars"> {
function resetTurnTiming(now = Date.now()): Pick<State, "turnStartAt" | "turnDoneAt" | "turnWaitAccumMs" | "promptWaitStartedAt" | "turnTokens" | "turnTotalTokens" | "turnOutputTokens" | "turnOutputChars" | "turnOutputCharsAtUsage" | "turnOutputEstimated" | "turnModelActiveAt" | "turnModelActiveMs" | "turnCost" | "turnArgChars" | "pendingRequestModelMs"> {
return {
turnStartAt: now,
turnDoneAt: 0,
@@ -1139,7 +1144,7 @@ function resetTurnTiming(now = Date.now()): Pick<State, "turnStartAt" | "turnDon
turnOutputCharsAtUsage: 0,
turnOutputEstimated: false,
turnModelActiveAt: undefined,
turnModelActiveMs: 0,
turnModelActiveMs: 0, pendingRequestModelMs: undefined,
turnCost: 0,
turnArgChars: 0,
};
@@ -1156,13 +1161,11 @@ function beginTurnModelActivity(s: State, now = Date.now()): State {
: { ...s, turnModelActiveAt: now };
}
function endTurnModelActivity(s: State, now = Date.now()): State {
function endTurnModelActivity(s: State, now = Date.now(), stashForUsage = false): State {
if (!s.turnModelActiveAt || s.turnModelActiveAt <= 0) return s;
return {
...s,
turnModelActiveAt: undefined,
turnModelActiveMs: Math.max(0, s.turnModelActiveMs) + Math.max(0, now - s.turnModelActiveAt),
};
const closedMs = Math.max(0, now - s.turnModelActiveAt);
return { ...s, turnModelActiveAt: undefined, turnModelActiveMs: Math.max(0, s.turnModelActiveMs) + closedMs,
pendingRequestModelMs: stashForUsage ? closedMs : s.pendingRequestModelMs };
}
function snapshotCompletedTurnTelemetry(s: State, now = Date.now()): State {
@@ -1485,10 +1488,10 @@ function applyEvent(s: State, e: WireEvent): State {
existingAssistant && existingAssistant.text.trim() === "" && existingAssistant.reasoning.trim() === "" && !existingAssistant.memoryCitations?.length
? s.items.filter((it) => !(it.kind === "assistant" && it.id === existingAssistant.id))
: s.items;
return { ...endTurnModelActivity(s), items, live: undefined, currentAssistant: undefined, turnOutputCharsAtUsage: 0 };
return { ...endTurnModelActivity(s, Date.now(), true), items, live: undefined, currentAssistant: undefined, turnOutputCharsAtUsage: 0 };
}
const now = Date.now();
const settled = endTurnModelActivity(s, now);
const settled = endTurnModelActivity(s, now, true);
const { items, id, seq } = ensureAssistant(settled);
const streamedChars = settled.live?.id === id ? settled.live.text.length + settled.live.reasoning.length : 0;
const turnOutputChars = Math.max(0, settled.turnOutputChars - streamedChars + text.length + reasoning.length);
@@ -1552,7 +1555,7 @@ function applyEvent(s: State, e: WireEvent): State {
items: [...activeState.items, { kind: "tool", id, name: t.name, args: "", readOnly: t.readOnly, resolvedName: t.resolvedName, capabilityId: t.capabilityId, status: "running", argChars: t.argChars || undefined, parentId: t.parentId, subagentProgress: SUBAGENT_PROGRESS_TOOLS.has(t.name) ? freshSubagentProgress() : undefined }],
}, id, false, undefined, { attemptId: t.attemptId, parentId: t.parentId, partial: true });
}
const settled = t.parentId ? s : endTurnModelActivity(s);
const settled = t.parentId ? s : endTurnModelActivity(s, Date.now(), true);
const id = t.id || `tool${s.seq}`;
const idx = settled.items.findIndex((it) => it.kind === "tool" && it.id === id);
if (idx >= 0) {
@@ -1661,18 +1664,16 @@ function applyEvent(s: State, e: WireEvent): State {
// Only executor usage belongs to the foreground model stream. Planner,
// subagent, and auxiliary usage still contributes to session totals and
// usageSeq, but must not close or inflate the executor TPS interval.
const settled = updateContextGauge ? endTurnModelActivity(s) : s;
// Prefer Context* (latest attempt) over billable aggregates when multi-
// attempt sampling recovery folds several provider calls into one Usage.
// Matches Controller.ContextSnapshot: latest prompt + completion.
const settled = updateContextGauge ? endTurnModelActivity(s, Date.now(), true) : s;
const hasRequestContext = (e.usage?.contextPromptTokens ?? 0) > 0 || (e.usage?.contextCompletionTokens ?? 0) > 0;
const requestModelMs = updateContextGauge ? (settled.pendingRequestModelMs ?? 0) : 0;
const requestTokens = updateContextGauge ? (hasRequestContext ? (e.usage?.contextCompletionTokens ?? 0) : (e.usage?.completionTokens ?? 0)) : 0;
const lastRequestTps = updateContextGauge ? (requestTokens > 0 && requestModelMs >= 500 ? requestTokens / (requestModelMs / 1000) : null) : s.lastRequestTps;
// Context* is the latest sampling attempt; other token fields are billable aggregates.
let used = settled.context.used;
if (e.usage && settled.context.window && updateContextGauge) {
const hasContext =
(e.usage.contextPromptTokens ?? 0) > 0 || (e.usage.contextCompletionTokens ?? 0) > 0;
used = hasContext
? (e.usage.contextPromptTokens ?? 0) + (e.usage.contextCompletionTokens ?? 0)
: (e.usage.promptTokens ?? 0) + (e.usage.completionTokens ?? 0);
}
if (e.usage && settled.context.window && updateContextGauge) used = hasRequestContext
? (e.usage.contextPromptTokens ?? 0) + (e.usage.contextCompletionTokens ?? 0)
: (e.usage.promptTokens ?? 0) + (e.usage.completionTokens ?? 0);
const turnTokens = settled.turnTokens + (e.usage?.completionTokens ?? 0);
const turnOutputTokens = updateContextGauge
? settled.turnOutputTokens + (e.usage?.completionTokens ?? 0)
@@ -1693,10 +1694,15 @@ function applyEvent(s: State, e: WireEvent): State {
const usage = updateContextGauge ? e.usage : settled.usage;
// The completed round's usage now accounts for the streamed tool-call
// arguments, so drop the live estimate rather than double-count it.
return { ...settled, usage, context: { ...settled.context, used, sessionTokens }, turnTokens, turnOutputTokens, turnOutputCharsAtUsage, turnOutputEstimated, turnTotalTokens, turnCost, turnArgChars: updateContextGauge ? 0 : settled.turnArgChars, sessionTokens, sessionCost, sessionCurrency, usageSeq: settled.usageSeq + 1 };
return { ...settled, usage, context: { ...settled.context, used, sessionTokens }, turnTokens, turnOutputTokens, turnOutputCharsAtUsage, turnOutputEstimated, turnTotalTokens, turnCost, turnArgChars: updateContextGauge ? 0 : settled.turnArgChars, sessionTokens, sessionCost, sessionCurrency, usageSeq: settled.usageSeq + 1, lastRequestTps, pendingRequestModelMs: updateContextGauge ? undefined : settled.pendingRequestModelMs };
}
case "notice":
return appendNoticeToState(s, e.level ?? "info", e.text ?? "", e.detail, e.code, e.decisionReceipt);
case "context_maintenance": {
const m = e.maintenance;
if (!m || m.status === "noop") return s;
return appendNoticeToState(s, m.status === "failed" ? "warn" : "info", formatContextMaintenanceNotice(m, t), m.reason);
}
case "phase":
return { ...s, seq: s.seq + 1, items: [...s.items, { kind: "phase", id: `p${s.seq}`, text: e.text ?? "" }] };
case "compaction_started":
@@ -2267,6 +2273,7 @@ const noticeCodeKeys: Record<string, DictKey> = {
session_recovery_depth_cap: "recovery.noticeKeptCurrent",
session_shutdown_recovery_forked: "recovery.noticeSavedCopy",
decision_receipt: "notice.decisionReceiptTitle",
context_editing_fallback: "notice.contextEditingFallback",
};
// localizedNoticeText localizes a notice's main copy by its stable code first,
@@ -2665,8 +2672,9 @@ export function useController() {
if (!streamDeltaOnly) bump();
}
}, [bump, notifyLiveListeners]);
const clearBalanceForTab = useCallback((tabId: string): void => {
invalidateSharedQuery("BalanceForTab", [tabId]);
invalidateSharedQuery("MetaForTab", [tabId]);
const seq = (balanceRefreshSeqByTab.current.get(tabId) ?? 0) + 1;
balanceRefreshSeqByTab.current.set(tabId, seq);
dispatchTo(tabId, { type: "balance", balance: { available: false, display: "" } });
@@ -2765,10 +2773,8 @@ export function useController() {
return metaRefreshSeq.current.get(tabId) === seq;
}, []);
const bumpSessionLoadSeq = useCallback((tabId: string): number => {
// A session transition changes the meaning of every tab-scoped Meta field.
// Invalidate requests that started against the previous session before
// resetting or hydrating the visible state.
bumpMetaRefreshSeq(tabId);
invalidateSharedQuery("MetaForTab", [tabId]);
const seq = (sessionLoadSeq.current.get(tabId) ?? 0) + 1;
sessionLoadSeq.current.set(tabId, seq);
return seq;
@@ -2814,8 +2820,9 @@ export function useController() {
}, [dispatchTo, loadMetaForTab]);
const refreshMetaForTab = useCallback(async (tabId: string): Promise<void> => {
const sessionSeq = sessionLoadSeq.current.get(tabId) ?? 0;
const meta = await refreshMetaOnlyForTab(tabId);
const meta = await loadMetaForTab(tabId);
if (meta === undefined || (sessionLoadSeq.current.get(tabId) ?? 0) !== sessionSeq) return;
dispatchTo(tabId, { type: "meta", meta });
const [context, effort] = await Promise.all([
app.ContextUsageForTab(tabId).catch(() => undefined),
app.EffortForTab(tabId).catch(() => undefined),
@@ -2823,7 +2830,7 @@ export function useController() {
if ((sessionLoadSeq.current.get(tabId) ?? 0) !== sessionSeq) return;
if (context !== undefined) dispatchTo(tabId, { type: "context", context });
if (effort !== undefined) dispatchTo(tabId, { type: "effort", effort });
}, [dispatchTo, refreshMetaOnlyForTab]);
}, [dispatchTo, loadMetaForTab]);
const bumpCheckpointRefreshSeq = useCallback((tabId: string): number => {
const seq = (checkpointRefreshSeq.current.get(tabId) ?? 0) + 1;
checkpointRefreshSeq.current.set(tabId, seq);
@@ -3368,14 +3375,15 @@ export function useController() {
textBatch.drain();
dispatchTo(targetTabId, { type: "event", e });
}
if (e.kind === "turn_done" || e.kind === "context_maintenance") {
void app.ContextUsageForTab(targetTabId).then((context) => dispatchTo(targetTabId, { type: "context", context })).catch(() => {});
}
if (e.kind === "turn_done") {
app
.ContextUsageForTab(targetTabId)
.then((context) => dispatchTo(targetTabId, { type: "context", context }))
.catch(() => {});
invalidateSharedQuery("BalanceForTab", [targetTabId]);
void refreshBalanceForTab(targetTabId);
app.EffortForTab(targetTabId).then((effort) => dispatchTo(targetTabId, { type: "effort", effort })).catch(() => {});
void refreshCheckpoints(targetTabId);
invalidateSharedQuery("MetaForTab", [targetTabId]);
void refreshMetaForTab(targetTabId);
}
if (e.kind === "turn_done" || e.kind === "notice") {
@@ -3401,18 +3409,20 @@ export function useController() {
// rebuild (settings-wide) affects every known tab.
const offRebuilt = onRuntimeRebuilt((rebuiltTabId, runtimeEpoch) => {
if (rebuiltTabId) {
invalidateSharedQuery("MetaForTab", [rebuiltTabId]);
if (runtimeEpoch) runtimeEpochByTabRef.current.set(rebuiltTabId, runtimeEpoch);
dispatchTo(rebuiltTabId, { type: "controller_rebuilt" });
} else {
if (runtimeEpoch) {
for (const id of Array.from(statesRef.current.keys())) runtimeEpochByTabRef.current.set(id, runtimeEpoch);
}
for (const id of Array.from(statesRef.current.keys())) dispatchTo(id, { type: "controller_rebuilt" });
for (const id of Array.from(statesRef.current.keys())) {
invalidateSharedQuery("MetaForTab", [id]);
dispatchTo(id, { type: "controller_rebuilt" });
}
}
});
const offTopicActivation = onTopicActivation(handleTopicActivationEvent);
// tab:meta carries a full refreshed Meta after the backend's background
// refresh of the expensive fields (git branch, image-input capability) —
// those arrive empty in the first MetaForTab response now. Merge it like a
@@ -3437,7 +3447,6 @@ export function useController() {
// otherwise a session left mid-confirmation shows "waiting" with no modal
// and no way to stop (#3844).
void app.ReplayPendingPrompts().catch(() => {});
return () => {
textBatch.drain();
for (const timer of cancelReconcileTimers.current.values()) {
@@ -4087,9 +4096,9 @@ export function useController() {
const restoreSession = useCallback((path: string) => app.RestoreSession(path).catch(() => {}).finally(() => invalidateCache()), []);
const purgeTrashedSession = useCallback((path: string) => app.PurgeTrashedSession(path).catch(() => {}).finally(() => invalidateCache()), []);
const renameSession = useCallback((path: string, title: string) => app.RenameSession(path, title).catch(() => {}).finally(() => invalidateCache()), []);
const refreshMeta = useCallback(async () => {
if (!activeTabId) return;
invalidateSharedQuery("MetaForTab", [activeTabId]);
await refreshMetaForTab(activeTabId);
}, [activeTabId, refreshMetaForTab]);
@@ -1,7 +1,5 @@
import { useEffect, useRef } from "react";
import { DUR_SLOW, prefersReducedMotion } from "./gsapAnimations";
const CSS_EASE_OUT = "cubic-bezier(0.2, 0.72, 0.2, 1)";
import { useEffect, useMemo, useRef } from "react";
import { CSS_EASE_OUT, DUR_SLOW, prefersReducedMotion } from "./motion";
// Animates each data-entrance element in once. First mount (and every
// resetKey change) pre-seeds the seen set so restored history never animates;
@@ -10,9 +8,18 @@ export function useEntranceAnimation<T extends HTMLElement>(
resetKey?: unknown,
deps?: unknown,
selector = "[data-entrance]",
seedIds: readonly string[] = [],
) {
const ref = useRef<T | null>(null);
// Virtualized history rows may mount after the first DOM scan. Seed their
// model IDs up front so a later append never mistakes restored rows for new
// content and animates the whole viewport.
const seen = useRef(new Set<string>());
const seeded = useRef(false);
if (!seeded.current) {
seen.current = new Set(seedIds);
seeded.current = true;
}
const timerRef = useRef<number | null>(null);
const timerAnimations = useRef<Animation[]>([]);
const firstRun = useRef(true);
@@ -21,7 +28,7 @@ export function useEntranceAnimation<T extends HTMLElement>(
// Reset on session switch.
if (prevResetKey.current !== resetKey) {
prevResetKey.current = resetKey;
seen.current = new Set();
seen.current = new Set(seedIds);
firstRun.current = true;
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
@@ -68,26 +75,35 @@ export function useEntranceAnimation<T extends HTMLElement>(
timerRef.current = window.setTimeout(() => {
timerRef.current = null;
const animations = entries.map((entry, index) => {
if (typeof entry.animate !== "function") {
entry.style.opacity = "1";
entry.style.transform = "";
return null;
}
const animation = entry.animate(
[
{ opacity: 0, transform: "translateY(12px)" },
{ opacity: 1, transform: "translateY(0)" },
],
{
duration: DUR_SLOW * 1000,
easing: CSS_EASE_OUT,
delay: index * itemsStagger(entries.length) * 1000,
},
);
animation.onfinish = () => {
const settle = () => {
entry.style.opacity = "1";
entry.style.transform = "";
};
if (typeof entry.animate !== "function") {
settle();
return null;
}
let animation: Animation;
try {
animation = entry.animate(
[
{ opacity: 0, transform: "translateY(12px)" },
{ opacity: 1, transform: "translateY(0)" },
],
{
duration: DUR_SLOW * 1000,
easing: CSS_EASE_OUT,
delay: index * itemsStagger(entries.length) * 1000,
},
);
} catch {
// Entrance motion is cosmetic. Keep later entries running and expose
// this entry immediately if a WebView rejects the animation.
settle();
return null;
}
animation.onfinish = settle;
animation.oncancel = settle;
return animation;
});
timerAnimations.current = animations.filter((animation): animation is Animation => animation !== null);
@@ -95,7 +111,13 @@ export function useEntranceAnimation<T extends HTMLElement>(
return () => {
if (timerRef.current !== null) clearTimeout(timerRef.current);
for (const animation of timerAnimations.current) animation.cancel();
for (const animation of timerAnimations.current) {
try {
animation.cancel();
} catch {
// Cancellation is cleanup-only; each entry already has a final style.
}
}
timerAnimations.current = [];
};
// Only re-scan when deps change — NOT on every render.
@@ -104,6 +126,18 @@ export function useEntranceAnimation<T extends HTMLElement>(
return ref;
}
export function useTranscriptEntranceAnimation<T extends HTMLElement>(
tabId: string | undefined,
revealSignal: unknown,
items: readonly { id: string }[],
) {
const seedIds = useMemo(() => items.map((item) => item.id), [items]);
// A tail append preserves this key; a surface switch, reveal, or history
// prepend resets it and pre-seeds every model ID before virtual rows mount.
const resetKey = `${tabId ?? ""}|${String(revealSignal)}|${items[0]?.id ?? ""}`;
return useEntranceAnimation<T>(resetKey, items.length, "[data-entrance]", seedIds);
}
function itemsStagger(count: number): number {
if (count <= 1) return 0;
if (count <= 3) return 0.06;
@@ -1,99 +0,0 @@
import { useLayoutEffect, useRef } from "react";
import { DUR_BASE, prefersReducedMotion } from "./gsapAnimations";
const CSS_EASE_OUT = "cubic-bezier(0.2, 0.72, 0.2, 1)";
/**
* useGSAPCollapse — animate a container's height between 0 and its
* scrollHeight whenever `open` flips. Replaces the old CSS max-height
* hack with a precise pixel-level GSAP tween.
*
* Usage:
* const ref = useRef<HTMLDivElement>(null);
* useGSAPCollapse(ref, open);
* return <div ref={ref}>{children}</div>;
*
* The container should have `overflow: hidden` in CSS. No extra wrapper
* elements needed.
*/
export function useGSAPCollapse(
ref: React.RefObject<HTMLElement | null>,
open: boolean,
opts?: {
duration?: number;
ease?: string;
/** Called after the open animation completes. */
onOpenComplete?: () => void;
/** Called after the close animation completes. */
onCloseComplete?: () => void;
/** When closing, use this height as the starting point instead of
* measuring scrollHeight (which may have already shrunk due to
* content being conditionally removed). */
prevHeight?: number;
},
) {
const prevOpen = useRef<boolean | null>(null);
const animationRef = useRef<Animation | null>(null);
const onOpenRef = useRef(opts?.onOpenComplete);
const onCloseRef = useRef(opts?.onCloseComplete);
onOpenRef.current = opts?.onOpenComplete;
onCloseRef.current = opts?.onCloseComplete;
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
// Skip the very first render — we don't want to animate from 0→auto
// on mount. Use a direct style write (no GSAP overhead) for the
// initial state so 400+ collapsed items don't each go through
// gsap.set property resolution.
if (prevOpen.current === null) {
prevOpen.current = open;
el.style.height = open ? "auto" : "0px";
return;
}
// No change — nothing to do.
if (prevOpen.current === open) return;
prevOpen.current = open;
const reduced = prefersReducedMotion();
const dur = reduced ? 0.001 : (opts?.duration ?? DUR_BASE);
const ease = opts?.ease && opts.ease !== "power2.out" ? opts.ease : CSS_EASE_OUT;
animationRef.current?.cancel();
animationRef.current = null;
const finish = () => {
el.style.height = open ? "auto" : "0px";
if (open) onOpenRef.current?.();
else onCloseRef.current?.();
};
if (reduced || typeof el.animate !== "function") {
finish();
return;
}
if (open) {
const targetHeight = el.scrollHeight;
const animation = el.animate(
[{ height: "0px" }, { height: `${targetHeight}px` }],
{ duration: dur * 1000, easing: ease },
);
animationRef.current = animation;
animation.onfinish = finish;
} else {
// Close: if caller provided a pre-swap height use it as the start,
// otherwise measure the current (already-swapped) scrollHeight.
const startHeight = opts?.prevHeight && opts.prevHeight > 0
? opts.prevHeight
: Math.max(el.getBoundingClientRect().height, el.scrollHeight);
const animation = el.animate(
[{ height: `${startHeight}px` }, { height: "0px" }],
{ duration: dur * 1000, easing: ease },
);
animationRef.current = animation;
animation.onfinish = finish;
}
return () => animationRef.current?.cancel();
}, [open, ref]);
}
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { prefersReducedMotion } from "./motion";
// useMountTransition keeps a conditionally-rendered overlay mounted long enough
// to play an exit animation. Callers flip `open`; the hook returns whether the
@@ -16,11 +17,6 @@ import { useEffect, useRef, useState } from "react";
// global reduced-motion rule that zeroes every animation.
export type MountStatus = "open" | "closing";
function prefersReducedMotion(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
export function useMountTransition(
open: boolean,
duration: number,
+1 -1
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, TouchEvent as ReactTouchEvent, WheelEvent as ReactWheelEvent } from "react";
import { DUR_FAST, prefersReducedMotion } from "./gsapAnimations";
import { DUR_FAST, prefersReducedMotion } from "./motion";
import { isEditableTarget } from "./keyboardShortcuts";
const BOTTOM_THRESHOLD_PX = 80;
@@ -0,0 +1,48 @@
import { Copy } from "lucide-react";
import type { FloatingMenuItem } from "../components/FloatingMenu";
import { writeClipboardText } from "./clipboard";
export const WORKSPACE_CONTEXT_MENU_FILE_HEIGHT = 204;
export const WORKSPACE_CONTEXT_MENU_REF_HEIGHT = 160;
export function workspacePathCopyMenuItems({
path,
resolveAbsolutePath,
isScopeCurrent,
close,
relativeLabel,
absoluteLabel,
}: {
path: string;
resolveAbsolutePath: () => Promise<string>;
isScopeCurrent: () => boolean;
close: () => void;
relativeLabel: string;
absoluteLabel: string;
}): FloatingMenuItem[] {
return [
{
icon: <Copy size={14} />,
label: relativeLabel,
onSelect: () => {
const relativePath = path.replace(/\/+$/, "");
close();
if (relativePath) void writeClipboardText(relativePath);
},
},
{
icon: <Copy size={14} />,
label: absoluteLabel,
onSelect: () => {
close();
void resolveAbsolutePath()
.then((absolutePath) => {
if (absolutePath && isScopeCurrent()) return writeClipboardText(absolutePath);
})
.catch(() => {
// The path may disappear between opening the menu and selecting it.
});
},
},
];
}
+16 -1
View File
@@ -255,6 +255,8 @@ export const en = {
"workspace.addFileReferenceToChat": "Add file reference",
"workspace.addFolderReferenceToChat": "Add folder reference",
"workspace.addFileContentToChat": "Add file contents",
"workspace.copyRelativePath": "Copy relative path",
"workspace.copyAbsolutePath": "Copy absolute path",
"workspace.viewMode": "Workspace view",
"workspace.filesTab": "Files",
"workspace.changedTab": "Changes",
@@ -2876,6 +2878,7 @@ export const en = {
"notice.mcpServersFailed": "Some MCP servers failed to start; run /mcp for details.",
"notice.guardianModelMissing": "Guardian was disabled because its model was not found.",
"notice.guardianStartFailed": "Guardian was disabled because it could not start.",
"notice.contextEditingFallback": "Using local context maintenance.",
"questionNav.label": "Question navigation",
"questionNav.progress": "Question {current} / {total}",
"questionNav.jump": "Jump to question {n}",
@@ -3052,6 +3055,18 @@ export const en = {
"context.windowUsed": "Used context",
"context.windowCompactThreshold": "Compression threshold",
"context.windowCompactHeadroom": "Before compression",
"context.maintenanceTitle": "Current context composition",
"context.maintenanceProjected": "Projected context",
"context.maintenanceLastSaved": "Last tool saving",
"context.maintenanceHeadroom": "Hard-limit headroom",
"context.maintenanceLastAction": "Last maintenance",
"context.maintenanceSnipAt": "Tool cleanup at",
"context.maintenanceFoldAt": "Summary at",
"context.maintenanceForceAt": "Force convergence at",
"context.maintenanceHardAt": "Hard input limit",
"context.maintenanceActionSnip": "Tool result snip",
"context.maintenanceActionPrune": "Tool result prune",
"context.maintenanceActionNative": "Native tool clearing",
"context.sessionMetrics": "Session metrics",
"context.runtimeMetrics": "Runtime",
"context.costMetrics": "Cost",
@@ -3095,7 +3110,7 @@ export const en = {
"context.sourceExecutor": "Main",
"context.sourcePlanner": "Planner",
"context.sourceSubagent": "Subagent",
"context.sourceCompaction": "Compaction",
"context.sourceCompaction": "Summary call cost",
"context.sourceClassifier": "Classifier",
"context.sourceTitle": "Title",
"context.sourceRequests": "{count} req",
+16 -1
View File
@@ -175,6 +175,8 @@ export const zhTW: Record<DictKey, string> = {
"workspace.addFileReferenceToChat": "新增檔案引用",
"workspace.addFolderReferenceToChat": "新增資料夾引用",
"workspace.addFileContentToChat": "新增檔案內容",
"workspace.copyRelativePath": "複製相對路徑",
"workspace.copyAbsolutePath": "複製絕對路徑",
"workspace.viewMode": "工作區視圖",
"workspace.filesTab": "檔案",
"workspace.changedTab": "變更",
@@ -1989,6 +1991,7 @@ export const zhTW: Record<DictKey, string> = {
"notice.mcpServersFailed": "部分 MCP 服務啟動失敗,可執行 /mcp 查看詳情。",
"notice.guardianModelMissing": "Guardian 已停用:未找到對應模型。",
"notice.guardianStartFailed": "Guardian 啟動失敗,已停用。",
"notice.contextEditingFallback": "改用本機維護。",
"questionNav.label": "問題導航",
"questionNav.progress": "問題 {current} / {total}",
"questionNav.jump": "跳轉到問題 {n}",
@@ -3138,6 +3141,18 @@ export const zhTW: Record<DictKey, string> = {
"context.windowUsed": "已用上下文",
"context.windowCompactThreshold": "壓縮閾值",
"context.windowCompactHeadroom": "壓縮前餘量",
"context.maintenanceTitle": "上下文構成",
"context.maintenanceProjected": "傳送視圖",
"context.maintenanceLastSaved": "裁剪節省",
"context.maintenanceHeadroom": "硬限餘量",
"context.maintenanceLastAction": "維護操作",
"context.maintenanceSnipAt": "工具線",
"context.maintenanceFoldAt": "摘要線",
"context.maintenanceForceAt": "強制線",
"context.maintenanceHardAt": "輸入上限",
"context.maintenanceActionSnip": "裁短",
"context.maintenanceActionPrune": "裁剪",
"context.maintenanceActionNative": "原生清理",
"context.sessionMetrics": "會話指標",
"context.runtimeMetrics": "執行指標",
"context.costMetrics": "成本",
@@ -3159,7 +3174,7 @@ export const zhTW: Record<DictKey, string> = {
"context.sourceExecutor": "主模型",
"context.sourcePlanner": "規劃器",
"context.sourceSubagent": "子代理",
"context.sourceCompaction": "壓縮摘要",
"context.sourceCompaction": "摘要呼叫開銷",
"context.sourceClassifier": "計畫分類",
"context.sourceTitle": "標題產生",
"context.sourceRequests": "{count} 次",
+16 -1
View File
@@ -256,6 +256,8 @@ export const zh: Record<DictKey, string> = {
"workspace.addFileReferenceToChat": "添加文件引用",
"workspace.addFolderReferenceToChat": "添加文件夹引用",
"workspace.addFileContentToChat": "添加文件内容",
"workspace.copyRelativePath": "复制相对路径",
"workspace.copyAbsolutePath": "复制绝对路径",
"workspace.viewMode": "工作区视图",
"workspace.filesTab": "文件",
"workspace.changedTab": "改动",
@@ -2879,6 +2881,7 @@ export const zh: Record<DictKey, string> = {
"notice.mcpServersFailed": "部分 MCP 服务启动失败,可运行 /mcp 查看详情。",
"notice.guardianModelMissing": "Guardian 已停用:未找到对应模型。",
"notice.guardianStartFailed": "Guardian 启动失败,已停用。",
"notice.contextEditingFallback": "改用本地维护。",
"questionNav.label": "问题导航",
"questionNav.progress": "问题 {current} / {total}",
"questionNav.jump": "跳转到问题 {n}",
@@ -3055,6 +3058,18 @@ export const zh: Record<DictKey, string> = {
"context.windowUsed": "已用上下文",
"context.windowCompactThreshold": "压缩阈值",
"context.windowCompactHeadroom": "压缩前余量",
"context.maintenanceTitle": "上下文构成",
"context.maintenanceProjected": "发送视图",
"context.maintenanceLastSaved": "裁剪节省",
"context.maintenanceHeadroom": "硬限余量",
"context.maintenanceLastAction": "维护操作",
"context.maintenanceSnipAt": "工具线",
"context.maintenanceFoldAt": "摘要线",
"context.maintenanceForceAt": "强制线",
"context.maintenanceHardAt": "输入上限",
"context.maintenanceActionSnip": "裁短",
"context.maintenanceActionPrune": "裁剪",
"context.maintenanceActionNative": "原生清理",
"context.sessionMetrics": "会话指标",
"context.runtimeMetrics": "运行指标",
"context.costMetrics": "成本",
@@ -3098,7 +3113,7 @@ export const zh: Record<DictKey, string> = {
"context.sourceExecutor": "主模型",
"context.sourcePlanner": "规划器",
"context.sourceSubagent": "子代理",
"context.sourceCompaction": "压缩摘要",
"context.sourceCompaction": "摘要调用开销",
"context.sourceClassifier": "计划分类",
"context.sourceTitle": "标题生成",
"context.sourceRequests": "{count} 次",
+39 -12
View File
@@ -3734,7 +3734,7 @@ a[href] {
margin: 2px auto;
}
/* expanded padding — parent --open class drives it while GSAP handles height */
/* Expanded padding — the parent --open class drives it while native motion handles height. */
.readonly-batch--open .readonly-batch__body,
.turn-collapse--open .turn-collapse__body,
.tool--open .tool__body {
@@ -22948,11 +22948,11 @@ body > .mermaid-diagram--fullscreen {
position: relative;
display: inline-flex;
align-items: stretch;
height: 32px;
height: 28px;
border: 1px solid var(--border);
border-radius: 10px;
border-radius: 7px;
background: var(--bg-elev);
box-shadow: 0 1px 1px color-mix(in srgb, #000 9%, transparent);
box-shadow: none;
overflow: visible;
--wails-draggable: no-drag;
}
@@ -22964,6 +22964,7 @@ body > .mermaid-diagram--fullscreen {
.external-opener__primary-wrap {
display: inline-flex;
height: 100%;
}
.external-opener__primary,
@@ -22971,7 +22972,7 @@ body > .mermaid-diagram--fullscreen {
display: inline-flex;
align-items: center;
justify-content: center;
height: 30px;
height: 100%;
padding: 0;
border: none;
background: transparent;
@@ -22980,13 +22981,13 @@ body > .mermaid-diagram--fullscreen {
.external-opener__primary {
width: 38px;
border-radius: 9px 0 0 9px;
border-radius: 6px 0 0 6px;
}
.external-opener__menu-trigger {
width: 27px;
border-left: 1px solid var(--border-soft);
border-radius: 0 9px 9px 0;
border-radius: 0 6px 6px 0;
}
.external-opener__primary:hover:not(:disabled),
@@ -23010,8 +23011,8 @@ body > .mermaid-diagram--fullscreen {
display: inline-flex;
align-items: center;
justify-content: center;
width: 23px;
height: 23px;
width: 18px;
height: 18px;
}
.external-opener__app-icon img {
@@ -30016,9 +30017,16 @@ body > .mermaid-diagram--fullscreen {
}
}
.project-tree__topic-action--busy svg,
.project-tree__archive-spinner {
animation: project-tree-spin 0.9s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.sidebar--workbench .project-tree__topic-state--thinking,
.sidebar--workbench .project-tree__topic-state--streaming {
.sidebar--workbench .project-tree__topic-state--streaming,
.project-tree__topic-action--busy svg,
.project-tree__archive-spinner {
animation: none;
}
}
@@ -30776,7 +30784,7 @@ body > .mermaid-diagram--fullscreen {
:root[data-theme-style] .app--creation .topicbar__actions .external-opener {
height: 28px;
border: none;
border-radius: 8px;
border-radius: 7px;
background: transparent;
box-shadow: none;
}
@@ -37349,6 +37357,7 @@ body > .mermaid-diagram--fullscreen {
display: inline-flex;
align-items: center;
max-width: 190px;
min-height: 24px;
gap: 4px;
padding: 1px 7px;
cursor: pointer;
@@ -37356,15 +37365,33 @@ body > .mermaid-diagram--fullscreen {
font: inherit;
white-space: nowrap;
}
.statusbar__remote > span {
.statusbar__remote-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.statusbar__remote-state-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
flex: 0 0 7px;
}
.statusbar__remote-wrap {
display: inline-flex;
min-width: 0;
}
@media (max-width: 820px) {
.statusbar__remote--idle {
width: 24px;
min-width: 24px;
justify-content: center;
padding-inline: 4px;
}
.statusbar__remote--idle .statusbar__remote-label {
display: none;
}
}
.remote-switcher {
z-index: var(--z-local-popover);
width: min(390px, calc(100vw - 16px));
+43 -8
View File
@@ -87,6 +87,9 @@ func repairExistingWindowsShortcuts(paths []string, launcher string, repair func
}
func repairWindowsShortcut(shortcutPath, launcher string) (bool, error) {
root := filepath.Dir(filepath.Clean(strings.TrimSpace(launcher)))
versionedLayout := installlayout.HasCurrent(root)
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); err != nil {
@@ -130,7 +133,7 @@ func repairWindowsShortcut(shortcutPath, launcher string) (bool, error) {
}
iconLocation := iconValue.ToString()
_ = iconValue.Clear()
repointTarget, fixIcon := repairWindowsShortcutPlan(target, iconLocation, launcher)
repointTarget, fixIcon := repairWindowsShortcutPlan(target, iconLocation, launcher, versionedLayout)
if !repointTarget && !fixIcon {
return false, nil
}
@@ -205,13 +208,32 @@ func reasonixWindowsVersionedTarget(target, launcher string) bool {
// repairWindowsShortcutPlan decides which owned-shortcut properties need
// rewriting. repointTarget is true when TargetPath points into a versioned
// directory the updater can delete; fixIcon is true when IconLocation points
// at the versioned desktop binary instead of the stable launcher.
func repairWindowsShortcutPlan(target, iconLocation, launcher string) (repointTarget, fixIcon bool) {
return reasonixWindowsVersionedTarget(target, launcher), reasonixWindowsStaleIcon(iconLocation, launcher)
// directory the updater can delete or at a legacy root-level desktop binary
// after the versioned layout is active; fixIcon applies the same policy to
// IconLocation. Flat installs keep their live root-level binary untouched.
func repairWindowsShortcutPlan(target, iconLocation, launcher string, versionedLayout bool) (repointTarget, fixIcon bool) {
repointTarget = reasonixWindowsVersionedTarget(target, launcher) ||
reasonixWindowsFlatDesktopTarget(target, launcher, versionedLayout)
return repointTarget, reasonixWindowsStaleIcon(iconLocation, launcher, versionedLayout)
}
func reasonixWindowsStaleIcon(iconLocation, launcher string) bool {
// reasonixWindowsFlatDesktopTarget reports whether target points at the legacy
// root-level desktop binary after versioned layout activation or after the file
// disappeared. A valid current.json is the commit point, so a leftover flat
// binary is not a live flat install when best-effort cleanup could not remove it.
func reasonixWindowsFlatDesktopTarget(target, launcher string, versionedLayout bool) bool {
flat := filepath.Join(filepath.Dir(filepath.Clean(strings.TrimSpace(launcher))), "reasonix-desktop.exe")
if !strings.EqualFold(filepath.Clean(strings.TrimSpace(target)), flat) {
return false
}
if versionedLayout {
return true
}
_, err := os.Lstat(flat)
return os.IsNotExist(err)
}
func reasonixWindowsStaleIcon(iconLocation, launcher string, versionedLayout bool) bool {
iconPath := strings.TrimSpace(iconLocation)
if comma := strings.LastIndex(iconPath, ","); comma >= 0 {
if _, err := strconv.Atoi(strings.TrimSpace(iconPath[comma+1:])); err == nil {
@@ -228,8 +250,21 @@ func reasonixWindowsStaleIcon(iconLocation, launcher string) bool {
return false
}
parts := strings.Split(rel, string(filepath.Separator))
return len(parts) == 3 && strings.EqualFold(parts[0], "versions") &&
strings.EqualFold(parts[2], "reasonix-desktop.exe")
if len(parts) == 3 && strings.EqualFold(parts[0], "versions") &&
strings.EqualFold(parts[2], "reasonix-desktop.exe") {
return true
}
// Legacy root-level icon: stale once the versioned layout is committed, even
// if best-effort cleanup left the old binary behind. Without current.json,
// require the file to be gone so a live flat install keeps its working icon.
if len(parts) == 1 && strings.EqualFold(parts[0], "reasonix-desktop.exe") {
if versionedLayout {
return true
}
_, err := os.Lstat(filepath.Clean(iconPath))
return os.IsNotExist(err)
}
return false
}
func notifyWindowsShortcutChanged(path string) {
+67 -5
View File
@@ -73,9 +73,13 @@ func TestReasonixWindowsShortcutTargetRequiresCurrentInstall(t *testing.T) {
}
}
func TestReasonixWindowsStaleIconOnlyMatchesVersionedDesktop(t *testing.T) {
root := filepath.Join(`C:\Program Files, Inc`, "Reasonix")
func TestReasonixWindowsStaleIcon(t *testing.T) {
root := filepath.Join(t.TempDir(), "Program Files, Inc", "Reasonix")
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
launcher := filepath.Join(root, "reasonix-launcher.exe")
flat := filepath.Join(root, "reasonix-desktop.exe")
tests := []struct {
name string
icon string
@@ -83,6 +87,7 @@ func TestReasonixWindowsStaleIconOnlyMatchesVersionedDesktop(t *testing.T) {
}{
{name: "versioned", icon: filepath.Join(root, "versions", "v1.19.3", "reasonix-desktop.exe") + ",0", want: true},
{name: "quoted versioned", icon: `"` + filepath.Join(root, "versions", "v1.19.3", "reasonix-desktop.exe") + `", 0`, want: true},
{name: "legacy root-level (file gone)", icon: flat + ",0", want: true},
{name: "stable launcher", icon: launcher + ",0", want: false},
{name: "custom icon", icon: filepath.Join(root, "custom.ico") + ",0", want: false},
{name: "other install", icon: filepath.Join(`D:\Apps`, "Reasonix", "versions", "v1.19.3", "reasonix-desktop.exe") + ",0", want: false},
@@ -90,11 +95,55 @@ func TestReasonixWindowsStaleIconOnlyMatchesVersionedDesktop(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := reasonixWindowsStaleIcon(tt.icon, launcher); got != tt.want {
if got := reasonixWindowsStaleIcon(tt.icon, launcher, false); got != tt.want {
t.Fatalf("reasonixWindowsStaleIcon(%q, %q) = %v, want %v", tt.icon, launcher, got, tt.want)
}
})
}
if err := os.WriteFile(flat, []byte("binary"), 0o600); err != nil {
t.Fatal(err)
}
if reasonixWindowsStaleIcon(flat+",0", launcher, false) {
t.Fatal("live flat icon = stale, want healthy")
}
if !reasonixWindowsStaleIcon(flat+",0", launcher, true) {
t.Fatal("versioned layout with leftover flat icon = healthy, want stale")
}
}
func TestReasonixWindowsFlatDesktopTargetNeedsMissingFile(t *testing.T) {
root := t.TempDir()
launcher := filepath.Join(root, "reasonix-launcher.exe")
flat := filepath.Join(root, "reasonix-desktop.exe")
// No file on disk: the legacy root-level target dangles after the
// versioned-layout migration and must be repointed.
if !reasonixWindowsFlatDesktopTarget(flat, launcher, false) {
t.Fatalf("missing flat desktop target = false, want true")
}
// A live flat install still has the binary; its shortcut must be left alone.
if err := os.WriteFile(flat, []byte("binary"), 0o600); err != nil {
t.Fatal(err)
}
if reasonixWindowsFlatDesktopTarget(flat, launcher, false) {
t.Fatalf("existing flat desktop target = true, want false")
}
if !reasonixWindowsFlatDesktopTarget(flat, launcher, true) {
t.Fatalf("versioned layout with leftover flat desktop target = false, want true")
}
// Non-flat targets never match.
for _, target := range []string{
launcher,
filepath.Join(root, "Reasonix.exe"),
filepath.Join(root, "versions", "v1.19.3", "reasonix-desktop.exe"),
} {
if reasonixWindowsFlatDesktopTarget(target, launcher, true) {
t.Fatalf("reasonixWindowsFlatDesktopTarget(%q) = true, want false", target)
}
}
}
func TestReasonixWindowsVersionedTarget(t *testing.T) {
@@ -125,7 +174,7 @@ func TestReasonixWindowsVersionedTarget(t *testing.T) {
}
func TestRepairWindowsShortcutPlan(t *testing.T) {
root := filepath.Join(`C:\Program Files`, "Reasonix")
root := t.TempDir()
launcher := filepath.Join(root, "reasonix-launcher.exe")
versioned := filepath.Join(root, "versions", "v1.19.3", "reasonix-desktop.exe")
tests := []struct {
@@ -139,14 +188,27 @@ func TestRepairWindowsShortcutPlan(t *testing.T) {
{name: "versioned target + clean icon", target: versioned, icon: launcher + ",0", wantRepoint: true, wantFixIcon: false},
{name: "stable target + versioned icon", target: launcher, icon: versioned + ",0", wantRepoint: false, wantFixIcon: true},
{name: "stable target + clean icon", target: launcher, icon: launcher + ",0", wantRepoint: false, wantFixIcon: false},
{name: "flat target + flat icon (both gone)", target: filepath.Join(root, "reasonix-desktop.exe"), icon: filepath.Join(root, "reasonix-desktop.exe") + ",0", wantRepoint: true, wantFixIcon: true},
{name: "flat target + clean icon", target: filepath.Join(root, "reasonix-desktop.exe"), icon: launcher + ",0", wantRepoint: true, wantFixIcon: false},
{name: "custom target + custom icon", target: filepath.Join(root, "custom.ico"), icon: filepath.Join(root, "custom.ico") + ",0", wantRepoint: false, wantFixIcon: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repoint, fixIcon := repairWindowsShortcutPlan(tt.target, tt.icon, launcher)
repoint, fixIcon := repairWindowsShortcutPlan(tt.target, tt.icon, launcher, false)
if repoint != tt.wantRepoint || fixIcon != tt.wantFixIcon {
t.Fatalf("repairWindowsShortcutPlan(%q, %q) = (%v, %v), want (%v, %v)", tt.target, tt.icon, repoint, fixIcon, tt.wantRepoint, tt.wantFixIcon)
}
})
}
flat := filepath.Join(root, "reasonix-desktop.exe")
if err := os.WriteFile(flat, []byte("binary"), 0o600); err != nil {
t.Fatal(err)
}
if repoint, fixIcon := repairWindowsShortcutPlan(flat, flat+",0", launcher, false); repoint || fixIcon {
t.Fatalf("live flat plan = (%v, %v), want (false, false)", repoint, fixIcon)
}
if repoint, fixIcon := repairWindowsShortcutPlan(flat, flat+",0", launcher, true); !repoint || !fixIcon {
t.Fatalf("versioned layout leftover plan = (%v, %v), want (true, true)", repoint, fixIcon)
}
}
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/agent"
)
func removeDesktopSessionArtifactsWithGuard(path string, guard *agent.SessionRemovalGuard) error {
if strings.TrimSpace(path) == "" {
return nil
}
if guard == nil {
return fmt.Errorf("session removal guard is required")
}
defer guard.Release()
if err := invalidateTopicDirMarkers(filepath.Dir(path)); err != nil {
return err
}
defer invalidateTopicSessionIndexForPath(path)
for _, artifact := range sessionOwnedArtifactPaths(path) {
if strings.TrimSpace(artifact) == "" {
continue
}
if err := os.RemoveAll(artifact); err != nil && !os.IsNotExist(err) {
return err
}
}
if err := guard.RemoveSidecarsAndRelease(); err != nil {
return err
}
if err := removeSessionDisplay(filepath.Dir(path), path); err != nil {
return err
}
if err := removeSessionPlannerDisplay(filepath.Dir(path), path); err != nil {
return err
}
if err := agent.DeleteSubagentsByParent(filepath.Dir(path), agent.BranchID(path)); err != nil {
return err
}
return agent.ClearCleanupPending(path)
}
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"time"
"reasonix/internal/control"
"reasonix/internal/jobs"
)
// Interactive removal returns promptly; durable cleanup-pending markers let
// delayed cleanup safely own non-cooperative jobs after this grace expires.
const desktopSessionRemovalGrace = time.Second
const desktopSessionRemovalWatchdog = desktopSessionRemovalGrace + 250*time.Millisecond
func waitDestroyHandles(destroys []control.SessionDestroyHandle) bool {
return waitDestroyHandleBatches([][]control.SessionDestroyHandle{destroys})[0]
}
func waitDestroyHandleBatches(batches [][]control.SessionDestroyHandle) []bool {
timedOut := make([]bool, len(batches))
if len(batches) == 0 {
return timedOut
}
type batchResult struct {
index int
timedOut bool
}
deadline := time.Now().Add(desktopSessionRemovalWatchdog)
results := make(chan batchResult, len(batches))
for i, destroys := range batches {
go func() {
results <- batchResult{index: i, timedOut: waitDestroyHandlesUntil(destroys, deadline)}
}()
}
for range batches {
result := <-results
timedOut[result.index] = result.timedOut
}
return timedOut
}
func waitDestroyHandlesUntil(destroys []control.SessionDestroyHandle, deadline time.Time) bool {
results := make(chan jobs.TeardownResult, len(destroys))
waits := 0
for _, destroy := range destroys {
wait := destroy.Wait
if destroy.WaitFor != nil {
remaining := min(max(time.Until(deadline), time.Duration(0)), desktopSessionRemovalGrace)
wait = func() jobs.TeardownResult { return destroy.WaitFor(remaining) }
}
if wait == nil {
continue
}
waits++
go func(wait func() jobs.TeardownResult) { results <- wait() }(wait)
}
if waits == 0 {
return false
}
remaining := time.Until(deadline)
if remaining <= 0 {
return true
}
timer := time.NewTimer(remaining)
defer timer.Stop()
timedOut := false
for range waits {
select {
case result := <-results:
timedOut = timedOut || result.HasTimedOut()
case <-timer.C:
return true
}
}
return timedOut
}
+107
View File
@@ -0,0 +1,107 @@
package main
import (
"testing"
"time"
"reasonix/internal/control"
"reasonix/internal/jobs"
)
func TestWaitDestroyHandlesPrefersBoundedWait(t *testing.T) {
requested := make(chan time.Duration, 1)
unboundedCalled := make(chan struct{}, 1)
timedOut := waitDestroyHandles([]control.SessionDestroyHandle{{
Wait: func() jobs.TeardownResult {
unboundedCalled <- struct{}{}
return jobs.TeardownResult{}
},
WaitFor: func(grace time.Duration) jobs.TeardownResult {
requested <- grace
return jobs.TeardownResult{}
},
}})
if timedOut {
t.Fatal("bounded wait reported an unexpected timeout")
}
select {
case grace := <-requested:
if grace != desktopSessionRemovalGrace {
t.Fatalf("bounded wait grace = %s, want %s", grace, desktopSessionRemovalGrace)
}
default:
t.Fatal("bounded wait was not called")
}
select {
case <-unboundedCalled:
t.Fatal("waitDestroyHandles used the unbounded wait when WaitFor was available")
default:
}
}
func TestWaitDestroyHandlesBoundsLegacyWait(t *testing.T) {
release := make(chan struct{})
returned := make(chan struct{})
defer func() {
close(release)
select {
case <-returned:
case <-time.After(time.Second):
t.Error("legacy wait goroutine did not finish after release")
}
}()
started := time.Now()
timedOut := waitDestroyHandles([]control.SessionDestroyHandle{{
Wait: func() jobs.TeardownResult {
<-release
close(returned)
return jobs.TeardownResult{}
},
}})
elapsed := time.Since(started)
if !timedOut {
t.Fatal("non-cooperative legacy wait did not report a timeout")
}
if elapsed < desktopSessionRemovalGrace || elapsed > 2*time.Second {
t.Fatalf("legacy wait returned after %s, want a bounded wait near %s", elapsed, desktopSessionRemovalGrace)
}
}
func TestWaitDestroyHandleBatchesWaitsAcrossSessionsConcurrently(t *testing.T) {
started := make(chan int, 2)
release := make(chan struct{})
batches := make([][]control.SessionDestroyHandle, 2)
for i := range batches {
index := i
batches[i] = []control.SessionDestroyHandle{{
WaitFor: func(time.Duration) jobs.TeardownResult {
started <- index
<-release
return jobs.TeardownResult{}
},
}}
}
done := make(chan []bool, 1)
go func() { done <- waitDestroyHandleBatches(batches) }()
seen := map[int]bool{}
for range batches {
select {
case index := <-started:
seen[index] = true
case <-time.After(time.Second):
t.Fatal("session destroy batches did not start concurrently")
}
}
close(release)
if !seen[0] || !seen[1] {
t.Fatalf("started batches = %v, want both sessions", seen)
}
select {
case timedOut := <-done:
if timedOut[0] || timedOut[1] {
t.Fatalf("completed destroy batches timed out: %v", timedOut)
}
case <-time.After(time.Second):
t.Fatal("concurrent destroy batches did not finish after release")
}
}
+3
View File
@@ -43,6 +43,9 @@ func friendlySessionFileError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, agent.ErrSessionFileLockHeld) {
return errSessionBusyElsewhere
}
var errno syscall.Errno
if !errors.As(err, &errno) {
return err
+3
View File
@@ -72,6 +72,9 @@ func TestFriendlySessionFileErrorPassesThroughOtherErrors(t *testing.T) {
if got := friendlySessionFileError(errSessionBusyElsewhere); !errors.Is(got, errSessionBusyElsewhere) {
t.Fatalf("sanitized busy error rewritten to %v", got)
}
if got := friendlySessionFileError(agent.ErrSessionFileLockHeld); !errors.Is(got, errSessionBusyElsewhere) {
t.Fatalf("bounded session file lock error = %v, want %v", got, errSessionBusyElsewhere)
}
notExist := &os.PathError{Op: "lstat", Path: "gone.jsonl", Err: syscall.ENOENT}
if got := friendlySessionFileError(notExist); !errors.Is(got, error(notExist)) {
t.Fatalf("not-exist error rewritten to %v", got)
+194
View File
@@ -0,0 +1,194 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"reasonix/internal/filelock"
"reasonix/internal/fileutil"
)
func TestSessionSidecarSavesUseDurableAtomicWrite(t *testing.T) {
tests := []struct {
name string
path func(string) string
save func(string) error
}{
{
name: "titles",
path: sessionTitlesPath,
save: func(dir string) error {
return saveSessionTitles(dir, map[string]string{"session.jsonl": "Durable title"})
},
},
{
name: "displays",
path: sessionDisplayPath,
save: func(dir string) error {
return saveSessionDisplays(dir, sessionDisplayMap{
"session.jsonl": {messageDisplayKey("expanded prompt"): "visible prompt"},
})
},
},
{
name: "planner displays",
path: sessionPlannerDisplayPath,
save: func(dir string) error {
return saveSessionPlannerDisplays(dir, sessionPlannerDisplayMap{
"session.jsonl": {{UserHash: "prompt-digest", Messages: []HistoryMessage{}}},
})
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
path := tt.path(dir)
previousCrashPoint := fileutil.CrashPoint
t.Cleanup(func() { fileutil.CrashPoint = previousCrashPoint })
usedDurableWrite := false
fileutil.CrashPoint = func(op, gotPath string) {
if op == "atomic-write" && gotPath == path {
usedDurableWrite = true
}
}
if err := tt.save(dir); err != nil {
t.Fatalf("save %s sidecar: %v", tt.name, err)
}
if !usedDurableWrite {
t.Fatalf("%s sidecar bypassed fileutil.AtomicWriteFile", tt.name)
}
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat %s sidecar: %v", tt.name, err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("%s sidecar mode = %o, want 600", tt.name, got)
}
}
})
}
}
func TestSetSessionTitleHonorsSidecarLock(t *testing.T) {
dir := t.TempDir()
release, err := filelock.Acquire(context.Background(), sessionTitlesPath(dir)+".lock")
if err != nil {
t.Fatalf("acquire title sidecar lock: %v", err)
}
previousTimeout := sessionTitlesQueueTimeout
sessionTitlesQueueTimeout = 40 * time.Millisecond
t.Cleanup(func() { sessionTitlesQueueTimeout = previousTimeout })
err = setSessionTitle(dir, filepath.Join(dir, "locked.jsonl"), "Locked")
if err == nil {
release()
t.Fatal("setSessionTitle succeeded while the title sidecar lock was held")
}
release()
if err := setSessionTitle(dir, filepath.Join(dir, "locked.jsonl"), "Saved"); err != nil {
t.Fatalf("setSessionTitle after release: %v", err)
}
}
func TestRecordSessionDisplayExternalLockUsesShortBudget(t *testing.T) {
if os.Getenv("REASONIX_DISPLAY_LOCK_HELPER") != "" {
dir := os.Getenv("REASONIX_DISPLAY_LOCK_DIR")
release, err := filelock.Acquire(context.Background(), sessionDisplayPath(dir)+".lock")
if err != nil {
t.Fatalf("acquire external display lock: %v", err)
}
defer release()
if err := os.WriteFile(filepath.Join(dir, "display-lock.ready"), []byte("ready"), 0o600); err != nil {
t.Fatal(err)
}
if !waitForPlannerDisplayTestFile(filepath.Join(dir, "display-lock.release"), 10*time.Second) {
t.Fatal("timed out waiting to release external display lock")
}
return
}
dir := t.TempDir()
var output strings.Builder
cmd := exec.Command(os.Args[0], "-test.run=^TestRecordSessionDisplayExternalLockUsesShortBudget$")
cmd.Env = append(os.Environ(),
"REASONIX_DISPLAY_LOCK_HELPER=1",
"REASONIX_DISPLAY_LOCK_DIR="+dir,
)
cmd.Stdout = &output
cmd.Stderr = &output
if err := cmd.Start(); err != nil {
t.Fatalf("start external display-lock helper: %v", err)
}
releasePath := filepath.Join(dir, "display-lock.release")
releaseHelper := func() {
_ = os.WriteFile(releasePath, []byte("release"), 0o600)
}
if !waitForPlannerDisplayTestFile(filepath.Join(dir, "display-lock.ready"), 5*time.Second) {
releaseHelper()
_ = cmd.Wait()
t.Fatalf("external display-lock helper did not start: %s", output.String())
}
previousTimeout := sessionDisplayExternalLockTimeout
sessionDisplayExternalLockTimeout = 100 * time.Millisecond
t.Cleanup(func() { sessionDisplayExternalLockTimeout = previousTimeout })
started := time.Now()
err := recordSessionDisplay(dir, filepath.Join(dir, "session.jsonl"), "expanded prompt", "visible prompt")
elapsed := time.Since(started)
releaseHelper()
if waitErr := cmd.Wait(); waitErr != nil {
t.Fatalf("external display-lock helper failed: %v\n%s", waitErr, output.String())
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("record display error = %v, want external lock deadline exceeded", err)
}
if elapsed >= 2*time.Second {
t.Fatalf("record display waited %v, want the short external lock budget", elapsed)
}
}
func TestSetSessionTitleSerializesConcurrentUpdates(t *testing.T) {
dir := t.TempDir()
const sessions = 32
errs := make(chan error, sessions)
var wg sync.WaitGroup
for i := range sessions {
wg.Add(1)
go func(i int) {
defer wg.Done()
path := filepath.Join(dir, fmt.Sprintf("session-%02d.jsonl", i))
errs <- setSessionTitle(dir, path, fmt.Sprintf("Title %02d", i))
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("setSessionTitle: %v", err)
}
}
titles := loadSessionTitles(dir)
if len(titles) != sessions {
t.Fatalf("title count = %d, want %d: %#v", len(titles), sessions, titles)
}
for i := range sessions {
key := fmt.Sprintf("session-%02d.jsonl", i)
if got := titles[key]; got != fmt.Sprintf("Title %02d", i) {
t.Fatalf("%s title = %q, want retained concurrent value", key, got)
}
}
}
+94
View File
@@ -0,0 +1,94 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"reasonix/internal/agent"
)
func reconcileDesktopTrashSessionArtifacts(dir, sessionPath, key string) error {
// Hold the removal guard across the whole move so no runtime can acquire
// the session (or save into it) while its artifacts are relocated; the
// lock sidecars are deleted atomically with the guard release.
guard, err := acquireSessionRemovalGuard(sessionPath)
if err != nil {
return err
}
return trashSessionArtifactsWithGuard(dir, sessionPath, key, guard)
}
func trashSessionArtifactsWithGuard(dir, sessionPath, key string, guard *agent.SessionRemovalGuard) error {
if guard == nil {
return errSessionBusyElsewhere
}
defer guard.Release()
itemDir := filepath.Join(sessionTrashPath(dir), key)
if info, err := os.Stat(itemDir); err == nil {
if !info.IsDir() {
return fmt.Errorf("session trash target is not a directory: %s", key)
}
trashPath := filepath.Join(itemDir, key)
if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() {
_, liveErr := os.Stat(sessionPath)
if liveErr != nil && !os.IsNotExist(liveErr) {
return liveErr
}
if liveErr == nil {
discardable, err := liveSessionContentDiscardable(sessionPath)
if err != nil {
return err
}
if discardable {
return removeDesktopSessionArtifactsWithGuard(sessionPath, guard)
}
matches, err := trashSessionMatchesLive(sessionPath, trashPath)
if err != nil {
return err
}
if matches {
return removeDesktopSessionArtifactsWithGuard(sessionPath, guard)
}
itemDir, err = reserveUniqueSessionTrashItemDir(dir, key)
if err != nil {
return err
}
}
} else if err != nil && !os.IsNotExist(err) {
return err
}
} else if os.IsNotExist(err) {
if err := os.MkdirAll(itemDir, 0o755); err != nil {
return err
}
} else {
return err
}
defer invalidateTopicSessionIndexForPath(sessionPath)
if err := invalidateTopicDirMarkers(dir); err != nil {
return err
}
for _, artifact := range sessionTrashArtifacts(sessionPath, key) {
if err := movePathIfExists(artifact.src, filepath.Join(itemDir, artifact.name)); err != nil {
return err
}
}
if err := trashSubagentArtifacts(dir, sessionPath, itemDir); err != nil {
return err
}
if err := guard.RemoveSidecarsAndRelease(); err != nil {
return err
}
meta := trashedSessionMeta{Key: key, DeletedAt: time.Now().UnixMilli()}
b, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(itemDir, sessionTrashMetaFile), b, 0o644); err != nil {
return err
}
return agent.ClearCleanupPending(sessionPath)
}
+77 -131
View File
@@ -33,6 +33,21 @@ const sessionPlannerDisplayFile = ".planner-display.json"
const sessionTrashDir = ".trash"
const sessionTrashMetaFile = ".trash-meta.json"
const (
// Durable sidecar publication includes an fsync while holding the update
// lock. Keep enough queue budget for a burst of in-process writers on
// slower Windows disks, but fail external contention quickly so turn
// completion and retry-queue handoff do not stall behind another process.
sessionSidecarQueueTimeout = 5 * time.Second
sessionSidecarExternalLockTimeout = 750 * time.Millisecond
)
var (
sessionTitlesQueueTimeout = sessionSidecarQueueTimeout
sessionPlannerDisplayExternalLockTimeout = sessionSidecarExternalLockTimeout
sessionDisplayExternalLockTimeout = sessionSidecarExternalLockTimeout
)
func sessionTitlesPath(dir string) string { return filepath.Join(dir, sessionTitlesFile) }
func sessionDisplayPath(dir string) string { return filepath.Join(dir, sessionDisplayFile) }
func sessionTrashPath(dir string) string { return filepath.Join(dir, sessionTrashDir) }
@@ -73,7 +88,33 @@ func loadSessionTitlesForUpdate(dir string) (map[string]string, error) {
return loadStringMapForUpdate(sessionTitlesPath(dir))
}
// saveSessionTitles writes the map atomically (temp file + rename).
func updateSessionTitles(dir string, mutate func(map[string]string) bool) error {
if strings.TrimSpace(dir) == "" {
return errors.New("title directory is empty")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), sessionTitlesQueueTimeout)
defer cancel()
release, err := filelock.AcquireWithExternalTimeout(ctx, sessionTitlesPath(dir)+".lock", sessionSidecarExternalLockTimeout)
if err != nil {
return fmt.Errorf("lock title sidecar: %w", err)
}
defer release()
m, err := loadSessionTitlesForUpdate(dir)
if err != nil {
return err
}
if !mutate(m) {
return nil
}
return saveSessionTitles(dir, m)
}
// saveSessionTitles writes the map durably and atomically. Keep this on the
// shared helper so the temporary file is fsynced before it is published.
func saveSessionTitles(dir string, m map[string]string) error {
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
@@ -82,21 +123,7 @@ func saveSessionTitles(dir string, m map[string]string) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".titles.*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
if _, err := tmp.Write(b); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return err
}
return fileutil.ReplaceFile(tmpPath, sessionTitlesPath(dir))
return fileutil.AtomicWriteFile(sessionTitlesPath(dir), b, 0o600)
}
// setSessionTitle sets (or, with an empty title, clears) a session's custom name.
@@ -105,17 +132,22 @@ func setSessionTitle(dir, sessionPath, title string) error {
if err != nil {
return err
}
m, err := loadSessionTitlesForUpdate(dir)
if err != nil {
return err
}
key := filepath.Base(sessionPath)
if strings.TrimSpace(title) == "" {
delete(m, key)
} else {
m[key] = strings.TrimSpace(title)
}
return saveSessionTitles(dir, m)
return updateSessionTitles(dir, func(m map[string]string) bool {
title = strings.TrimSpace(title)
if title == "" {
if _, ok := m[key]; !ok {
return false
}
delete(m, key)
return true
}
if m[key] == title {
return false
}
m[key] = title
return true
})
}
// deleteSessionFile moves a session's .jsonl and file sidecars into the local
@@ -215,64 +247,6 @@ func reconcileDesktopCleanupPending(dir string) error {
})
}
func reconcileDesktopTrashSessionArtifacts(dir, sessionPath, key string) error {
// Hold the removal guard across the whole move so no runtime can acquire
// the session (or save into it) while its artifacts are relocated; the
// lock sidecars are deleted atomically with the guard release.
guard, err := acquireSessionRemovalGuard(sessionPath)
if err != nil {
return err
}
defer guard.Release()
itemDir := filepath.Join(sessionTrashPath(dir), key)
if info, err := os.Stat(itemDir); err == nil {
if !info.IsDir() {
return fmt.Errorf("session trash target is not a directory: %s", key)
}
trashPath := filepath.Join(itemDir, key)
if trashInfo, err := os.Stat(trashPath); err == nil && !trashInfo.IsDir() {
matches, err := trashSessionMatchesLive(sessionPath, trashPath)
if err != nil {
return err
}
if !matches {
itemDir, err = reserveUniqueSessionTrashItemDir(dir, key)
if err != nil {
return err
}
}
} else if err != nil && !os.IsNotExist(err) {
return err
}
} else if os.IsNotExist(err) {
if err := os.MkdirAll(itemDir, 0o755); err != nil {
return err
}
} else {
return err
}
for _, artifact := range sessionTrashArtifacts(sessionPath, key) {
if err := movePathIfExists(artifact.src, filepath.Join(itemDir, artifact.name)); err != nil {
return err
}
}
if err := trashSubagentArtifacts(dir, sessionPath, itemDir); err != nil {
return err
}
if err := guard.RemoveSidecarsAndRelease(); err != nil {
return err
}
meta := trashedSessionMeta{Key: key, DeletedAt: time.Now().UnixMilli()}
b, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(itemDir, sessionTrashMetaFile), b, 0o644); err != nil {
return err
}
return agent.ClearCleanupPending(sessionPath)
}
func validateSessionTrashTarget(dir, sessionPath, key string) error {
if _, err := os.Stat(sessionPath); os.IsNotExist(err) {
return nil
@@ -394,6 +368,10 @@ func liveSessionDiscardable(sessionPath string) (bool, error) {
if agent.IsCleanupPending(sessionPath) {
return true, nil
}
return liveSessionContentDiscardable(sessionPath)
}
func liveSessionContentDiscardable(sessionPath string) (bool, error) {
info, err := os.Stat(sessionPath)
if os.IsNotExist(err) {
return true, nil
@@ -452,7 +430,7 @@ func trashSessionArtifactsBeforeMove(dir, sessionPath, key string, beforeMove fu
return err
}
if !target.shouldMove {
return nil
return agent.ClearCleanupPending(sessionPath)
}
// Acquired after prepareSessionTrashTarget: the duplicate-trash path in
// there takes its own removal guard, and the guard is not reentrant.
@@ -598,15 +576,14 @@ func purgeTrashedSessionFile(dir, path string) error {
if err := os.RemoveAll(itemDir); err != nil {
return err
}
m, err := loadSessionTitlesForUpdate(dir)
if err != nil {
return err
}
if _, ok := m[key]; ok {
delete(m, key)
if err := saveSessionTitles(dir, m); err != nil {
return err
if err := updateSessionTitles(dir, func(m map[string]string) bool {
if _, ok := m[key]; !ok {
return false
}
delete(m, key)
return true
}); err != nil {
return err
}
if err := removeSessionDisplayKey(dir, key); err != nil {
return err
@@ -950,10 +927,7 @@ type plannerDisplayTurn struct {
Messages []HistoryMessage `json:"messages"`
}
var (
sessionPlannerDisplayLockTimeout = 750 * time.Millisecond
errCorruptSessionPlannerDisplay = errors.New("corrupt planner display sidecar")
)
var errCorruptSessionPlannerDisplay = errors.New("corrupt planner display sidecar")
// sessionPlannerDisplayUpdateAfterLoad is a subprocess-test seam. Production
// leaves it nil; tests use it to force two independent processes into the old
@@ -1018,21 +992,7 @@ func saveSessionPlannerDisplays(dir string, m sessionPlannerDisplayMap) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".planner-display.*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
if _, err := tmp.Write(b); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return err
}
return fileutil.ReplaceFile(tmpPath, sessionPlannerDisplayPath(dir))
return fileutil.AtomicWriteFile(sessionPlannerDisplayPath(dir), b, 0o600)
}
func saveOrRemoveSessionPlannerDisplays(dir string, m sessionPlannerDisplayMap) error {
@@ -1053,9 +1013,9 @@ func updateSessionPlannerDisplays(dir string, recoverCorrupt bool, mutate func(s
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), sessionPlannerDisplayLockTimeout)
ctx, cancel := context.WithTimeout(context.Background(), sessionSidecarQueueTimeout)
defer cancel()
release, err := filelock.Acquire(ctx, sessionPlannerDisplayPath(dir)+".lock")
release, err := filelock.AcquireWithExternalTimeout(ctx, sessionPlannerDisplayPath(dir)+".lock", sessionPlannerDisplayExternalLockTimeout)
if err != nil {
return fmt.Errorf("lock planner display sidecar: %w", err)
}
@@ -1155,21 +1115,7 @@ func saveSessionDisplays(dir string, m sessionDisplayMap) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".display.*.tmp")
if err != nil {
return err
}
tmpPath := tmp.Name()
if _, err := tmp.Write(b); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return err
}
return fileutil.ReplaceFile(tmpPath, sessionDisplayPath(dir))
return fileutil.AtomicWriteFile(sessionDisplayPath(dir), b, 0o600)
}
func saveOrRemoveSessionDisplays(dir string, m sessionDisplayMap) error {
@@ -1194,9 +1140,9 @@ func updateSessionDisplays(dir string, mutate func(sessionDisplayMap) bool) erro
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 750*time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), sessionSidecarQueueTimeout)
defer cancel()
release, err := filelock.Acquire(ctx, sessionDisplayPath(dir)+".lock")
release, err := filelock.AcquireWithExternalTimeout(ctx, sessionDisplayPath(dir)+".lock", sessionDisplayExternalLockTimeout)
if err != nil {
return fmt.Errorf("lock display sidecar: %w", err)
}
+1 -1
View File
@@ -1243,7 +1243,7 @@ func TestRecordSessionPlannerDisplayConcurrentPreservesEverySession(t *testing.T
func TestRecordSessionPlannerDisplayCrossProcessPreservesEverySession(t *testing.T) {
if role := os.Getenv("REASONIX_PLANNER_DISPLAY_HELPER"); role != "" {
dir := os.Getenv("REASONIX_PLANNER_DISPLAY_DIR")
sessionPlannerDisplayLockTimeout = 5 * time.Second
sessionPlannerDisplayExternalLockTimeout = 5 * time.Second
attempted := filepath.Join(dir, role+".attempted")
loaded := filepath.Join(dir, role+".loaded")
release := filepath.Join(dir, role+".release")
-167
View File
@@ -7660,173 +7660,6 @@ func (a *App) SetTopicPinned(topicID string, pinned bool) error {
return nil
}
var errTopicHasActiveWork = errors.New("wait for the session to finish, answer pending prompts, and stop background jobs before archiving this topic")
// TrashTopic removes an idle topic from the project tree and moves its saved
// session records into the session trash. Idle in-process runtimes are detached
// first, so their autosave cannot recreate state after the topic is gone.
func (a *App) TrashTopic(topicID string) error {
return friendlySessionFileError(a.trashTopic(topicID))
}
func (a *App) topicHasActiveRuntimeWork(topicID string) bool {
a.mu.RLock()
defer a.mu.RUnlock()
for _, tabs := range []map[string]*WorkspaceTab{a.tabs, a.detachedSessions} {
for _, tab := range tabs {
if tab != nil && tab.TopicID == topicID && tab.hasActiveRuntimeWork() {
return true
}
}
}
return false
}
func (a *App) trashTopic(topicID string) error {
if strings.TrimSpace(topicID) == "" {
return fmt.Errorf("topicID is required")
}
var fallback fallbackRuntimeTarget
var changedDirs []string
if err := func() error {
defer a.lockRuntimeMutation("trash-topic")()
a.sessionRemovalMu.Lock()
defer a.sessionRemovalMu.Unlock()
if a.topicHasActiveRuntimeWork(topicID) {
return errTopicHasActiveWork
}
targets, err := a.topicTrashTargets(topicID)
if err != nil {
return err
}
for _, target := range targets {
changedDirs = append(changedDirs, target.dir)
}
removed, nextFallback := a.removeTopicRuntimeBindings(topicID)
fallback = nextFallback
if err := a.prepareRemovedSessionRuntimes(removed); err != nil {
a.closeRemainingRemovedSessionRuntimesAdmissionHeld(removed, map[control.SessionAPI]bool{})
return err
}
destroyBegun := false
closedRemoved := map[control.SessionAPI]bool{}
defer func() {
if destroyBegun {
a.closeRemainingRemovedSessionRuntimesAfterDestroyAdmissionHeld(removed, closedRemoved)
return
}
a.closeRemainingRemovedSessionRuntimesAdmissionHeld(removed, closedRemoved)
}()
for _, target := range targets {
destroys := a.destroyHandlesForSession(target.dir, target.sessionPath, removed)
if len(destroys) > 0 {
destroyBegun = true
}
teardownTimedOut := waitDestroyHandles(destroys)
a.closeRemovedSessionRuntimesForSessionAfterDestroyAdmissionHeld(removed, target.dir, target.sessionPath, closedRemoved)
if teardownTimedOut {
if err := agent.MarkCleanupPending(target.sessionPath, "delete"); err != nil {
return err
}
go delayedDesktopSessionTrash(target.dir, target.sessionPath, target.key, destroys)
} else {
err := trashSessionArtifacts(target.dir, target.sessionPath, target.key)
finishDestroyHandles(destroys)
if err != nil {
return err
}
}
}
return a.deleteTopic(topicID)
}(); err != nil {
return err
}
if fallback.needs {
fallback.topicID = ""
if err := a.openFallbackRuntime(fallback); err != nil {
return err
}
}
if len(changedDirs) > 0 {
a.emitProjectTreeChangedForSessionDirs(changedDirs...)
} else {
a.emitProjectTreeMetadataChanged()
}
return nil
}
type topicTrashTarget struct {
dir string
sessionPath string
key string
}
func (a *App) topicTrashTargets(topicID string) ([]topicTrashTarget, error) {
topicID = strings.TrimSpace(topicID)
var targets []topicTrashTarget
seen := map[string]bool{}
addTarget := func(dir, path string) error {
sessionPath, key, err := validateSessionPath(dir, path)
if err != nil {
return err
}
id := dir + "\x00" + sessionPath
if seen[id] {
return nil
}
seen[id] = true
if err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {
return err
}
targets = append(targets, topicTrashTarget{dir: dir, sessionPath: sessionPath, key: key})
return nil
}
for _, dir := range a.knownSessionDirs() {
index, err := topicSessionIndexForDir(dir)
if err != nil {
return nil, err
}
for _, match := range index.byTopic[topicID] {
if agent.IsCleanupPending(match.path) {
continue
}
if err := addTarget(dir, match.path); err != nil {
return nil, err
}
}
}
a.mu.RLock()
var runtimeTargets []struct {
dir string
path string
}
for _, tab := range a.runtimeTabsLocked() {
if tab == nil || tab.TopicID != topicID {
continue
}
if path := canonicalTabSessionPath(tab.currentSessionPath()); path != "" {
dir := tabSessionDir(tab)
if filepath.IsAbs(path) {
dir = filepath.Dir(path)
}
runtimeTargets = append(runtimeTargets, struct {
dir string
path string
}{dir: dir, path: path})
}
}
a.mu.RUnlock()
for _, target := range runtimeTargets {
if err := addTarget(target.dir, target.path); err != nil {
return nil, err
}
}
return targets, nil
}
// ListProjectTree builds the sidebar tree: project folders each containing
// their topics, plus a Global section.
// topicSummary is used by ListProjectTree and mergeSessionInfos to track
+16 -28
View File
@@ -3140,6 +3140,9 @@ func TestTrashTopicMovesOpenSessionToTrash(t *testing.T) {
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("open topic session should be moved to trash: %v", err)
}
if agent.IsCleanupPending(sessionPath) {
t.Fatal("completed topic archive left a cleanup-pending marker")
}
trashed := app.ListTrashedSessions()
if len(trashed) != 1 || trashed[0].Path != trashPath {
t.Fatalf("trashed sessions = %#v, want %q", trashed, trashPath)
@@ -3288,7 +3291,7 @@ func TestTrashTopicRejectsRunningDetachedRuntime(t *testing.T) {
}
}
func TestTrashTopicWaitsForConcurrentTurnAdmission(t *testing.T) {
func TestTrashTopicRejectsConcurrentTurnAdmissionWithoutWaiting(t *testing.T) {
isolateDesktopUserDirs(t)
topicID := "topic_concurrent_turn_trash"
@@ -3344,31 +3347,12 @@ func TestTrashTopicWaitsForConcurrentTurnAdmission(t *testing.T) {
time.Sleep(time.Millisecond)
}
trashEntered := make(chan struct{})
var trashEnteredOnce sync.Once
app.runtimeMutationBeforeLockHook = func(operation string) {
if operation == "trash-topic" {
trashEnteredOnce.Do(func() { close(trashEntered) })
}
started := time.Now()
if err := app.TrashTopic(topicID); !errors.Is(err, errTopicArchiveBusy) {
t.Fatalf("concurrent TrashTopic error = %v, want %v", err, errTopicArchiveBusy)
}
trashDone := make(chan error, 1)
go func() { trashDone <- app.TrashTopic(topicID) }()
select {
case <-trashEntered:
case <-time.After(5 * time.Second):
t.Fatal("TrashTopic did not reach the runtime mutation barrier")
}
// Wait until TrashTopic owns runtimeRebuildMu and is queued on the admission
// writer. Releasing the turn gate now must let SubmitToTab publish Running
// before TrashTopic can re-check active work.
deadline = time.Now().Add(5 * time.Second)
for app.runtimeRebuildMu.TryLock() {
app.runtimeRebuildMu.Unlock()
if time.Now().After(deadline) {
t.Fatal("TrashTopic never acquired the runtime rebuild lock")
}
time.Sleep(time.Millisecond)
if elapsed := time.Since(started); elapsed > time.Second {
t.Fatalf("concurrent TrashTopic waited %s instead of returning busy", elapsed)
}
tab.turnStartMu.Unlock()
turnGateHeld = false
@@ -3377,9 +3361,6 @@ func TestTrashTopicWaitsForConcurrentTurnAdmission(t *testing.T) {
t.Fatalf("SubmitToTab: %v", err)
}
<-runner.started
if err := <-trashDone; !errors.Is(err, errTopicHasActiveWork) {
t.Fatalf("concurrent TrashTopic error = %v, want %v", err, errTopicHasActiveWork)
}
if !ctrl.Running() {
t.Fatal("rejected archive should leave the concurrently admitted turn running")
}
@@ -3843,6 +3824,13 @@ func TestTrashTopicValidTrashRemovesEmptyLiveStub(t *testing.T) {
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("existing trash should remain authoritative: %v", err)
}
trashed, err := listTrashedSessionFiles(dir)
if err != nil {
t.Fatalf("listTrashedSessionFiles: %v", err)
}
if len(trashed) != 1 || !sameDesktopPath(trashed[0], trashPath) {
t.Fatalf("trashed sessions = %v, want only authoritative copy %q", trashed, trashPath)
}
}
func hasHistoryContent(messages []HistoryMessage, content string) bool {
+269
View File
@@ -0,0 +1,269 @@
package main
import (
"errors"
"fmt"
"log/slog"
"path/filepath"
"strings"
"time"
"reasonix/internal/agent"
"reasonix/internal/control"
)
var (
errTopicHasActiveWork = errors.New("wait for the session to finish, answer pending prompts, and stop background jobs before archiving this topic")
errTopicArchiveBusy = errors.New("Reasonix is finishing another session change — wait a moment and retry archiving")
)
var topicArchiveCleanupHookForTest func() error
type topicArchiveTrace struct {
phase string
targetCount int
runtimeCount int
}
func (a *App) TrashTopic(topicID string) error {
return friendlySessionFileError(a.trashTopic(topicID))
}
func (a *App) topicHasActiveRuntimeWork(topicID string) bool {
a.mu.RLock()
defer a.mu.RUnlock()
for _, tabs := range []map[string]*WorkspaceTab{a.tabs, a.detachedSessions} {
for _, tab := range tabs {
if tab != nil && tab.TopicID == topicID && tab.hasActiveRuntimeWork() {
return true
}
}
}
return false
}
func (a *App) trashTopic(topicID string) (retErr error) {
topicID = strings.TrimSpace(topicID)
if topicID == "" {
return fmt.Errorf("topicID is required")
}
started := time.Now()
trace := topicArchiveTrace{phase: "start"}
defer func() {
outcome := "ok"
if retErr != nil {
outcome = "failed"
}
slog.Debug("desktop: topic archive timing", "outcome", outcome, "phase", trace.phase,
"total_ms", time.Since(started).Milliseconds(), "target_count", trace.targetCount, "runtime_count", trace.runtimeCount)
}()
fallback, changedDirs, err := a.commitTopicArchive(topicID, &trace)
if err != nil {
return err
}
if fallback.needs {
trace.phase = "open_fallback"
fallback.topicID = ""
if err := a.openFallbackRuntime(fallback); err != nil {
// Runtime construction errors can include provider configuration
// details, so keep this recovery diagnostic value-free.
slog.Warn("desktop: open fallback after topic archive failed")
}
}
trace.phase = "notify"
if len(changedDirs) > 0 {
a.emitProjectTreeChangedForSessionDirs(changedDirs...)
} else {
a.emitProjectTreeMetadataChanged()
}
return nil
}
func (a *App) commitTopicArchive(topicID string, trace *topicArchiveTrace) (fallbackRuntimeTarget, []string, error) {
trace.phase = "runtime_lock"
releaseRuntime, ok := a.tryLockRuntimeMutation("trash-topic")
if !ok {
return fallbackRuntimeTarget{}, nil, errTopicArchiveBusy
}
defer releaseRuntime()
trace.phase = "removal_lock"
if !a.sessionRemovalMu.TryLock() {
return fallbackRuntimeTarget{}, nil, errTopicArchiveBusy
}
defer a.sessionRemovalMu.Unlock()
trace.phase = "active_work_check"
if a.topicHasActiveRuntimeWork(topicID) {
return fallbackRuntimeTarget{}, nil, errTopicHasActiveWork
}
trace.phase = "target_scan"
targets, err := a.topicTrashTargets(topicID)
if err != nil {
return fallbackRuntimeTarget{}, nil, err
}
trace.targetCount = len(targets)
changedDirs := make([]string, 0, len(targets))
for _, target := range targets {
changedDirs = append(changedDirs, target.dir)
}
trace.phase = "snapshot"
removed := a.captureTopicRuntimeBindings(topicID)
trace.runtimeCount = len(removed)
if err := a.snapshotTopicRuntimeBindings(removed); err != nil {
return fallbackRuntimeTarget{}, nil, err
}
trace.phase = "acquire_removal_ownership"
ownership, err := acquireTopicArchiveOwnership(targets, removed)
if err != nil {
return fallbackRuntimeTarget{}, nil, err
}
defer ownership.release()
trace.phase = "mark_cleanup_pending"
rollbackMarkers, err := markTopicArchiveCleanupPending(topicID, targets)
if err != nil {
ownership.rollback()
return fallbackRuntimeTarget{}, nil, err
}
trace.phase = "detach_runtimes"
fallback, unchanged := a.removeTopicRuntimeBindingsIfUnchanged(topicID, removed)
if !unchanged {
rollbackMarkers()
ownership.rollback()
return fallbackRuntimeTarget{}, nil, errTopicArchiveBusy
}
a.finalizeRemovedTopicRuntimes(removed)
destroyBegun := false
closedRemoved := map[control.SessionAPI]bool{}
defer func() {
if destroyBegun {
a.closeRemainingRemovedSessionRuntimesAfterDestroyAdmissionHeld(removed, closedRemoved)
} else {
a.closeRemainingRemovedSessionRuntimesAdmissionHeld(removed, closedRemoved)
}
}()
trace.phase = "teardown"
destroyBatches := make([][]control.SessionDestroyHandle, len(targets))
for i, target := range targets {
destroys := a.destroyHandlesForSession(target.dir, target.sessionPath, removed)
destroyBatches[i] = destroys
destroyBegun = destroyBegun || len(destroys) > 0
}
timedOutTargets := waitDestroyHandleBatches(destroyBatches)
for i, target := range targets {
destroys := destroyBatches[i]
a.closeRemovedSessionRuntimesForSessionAfterDestroyAdmissionHeld(removed, target.dir, target.sessionPath, closedRemoved)
if timedOutTargets[i] {
guard := ownership.take(target.sessionPath)
go delayedDesktopTopicTrash(target.dir, target.sessionPath, target.key, guard, destroys)
continue
}
trace.phase = "move_artifacts"
var err error
if hook := topicArchiveCleanupHookForTest; hook != nil {
err = hook()
}
if err == nil {
err = trashSessionArtifactsWithGuard(target.dir, target.sessionPath, target.key, ownership.take(target.sessionPath))
}
finishDestroyHandles(destroys)
if err != nil {
// Cleanup-pending is the durable commit point. Once bindings have
// detached, report the archive as accepted and let startup
// reconciliation finish any filesystem operation that could not.
slog.Warn("desktop: topic archive cleanup remains pending")
}
}
trace.phase = "delete_topic_metadata"
if err := a.deleteTopic(topicID); err != nil {
slog.Warn("desktop: topic archive metadata cleanup remains pending")
} else if err := clearTopicArchiveMetadataPending(topicID); err != nil {
slog.Warn("desktop: topic archive metadata marker cleanup remains pending")
}
return fallback, changedDirs, nil
}
func markTopicArchiveCleanupPending(topicID string, targets []topicTrashTarget) (func(), error) {
if err := markTopicArchiveMetadataPending(topicID, targets); err != nil {
return nil, err
}
marked := make([]string, 0, len(targets))
rollback := func() {
for _, path := range marked {
if err := agent.ClearCleanupPending(path); err != nil {
slog.Warn("desktop: rollback topic archive marker failed")
}
}
if err := clearTopicArchiveMetadataPending(topicID); err != nil {
slog.Warn("desktop: rollback topic archive metadata marker failed")
}
}
for _, target := range targets {
if err := agent.MarkCleanupPending(target.sessionPath, "delete"); err != nil {
rollback()
return rollback, err
}
marked = append(marked, target.sessionPath)
}
return rollback, nil
}
type topicTrashTarget struct {
dir string
sessionPath string
key string
}
func (a *App) topicTrashTargets(topicID string) ([]topicTrashTarget, error) {
topicID = strings.TrimSpace(topicID)
var targets []topicTrashTarget
seen := map[string]bool{}
addTarget := func(dir, path string) error {
sessionPath, key, err := validateSessionPath(dir, path)
if err != nil {
return err
}
id := dir + "\x00" + sessionPath
if seen[id] {
return nil
}
seen[id] = true
if err := validateSessionTrashTarget(dir, sessionPath, key); err != nil {
return err
}
targets = append(targets, topicTrashTarget{dir: dir, sessionPath: sessionPath, key: key})
return nil
}
for _, dir := range a.knownSessionDirs() {
index, err := topicSessionIndexForDir(dir)
if err != nil {
return nil, err
}
for _, match := range index.byTopic[topicID] {
if !agent.IsCleanupPending(match.path) {
if err := addTarget(dir, match.path); err != nil {
return nil, err
}
}
}
}
a.mu.RLock()
var runtimeTargets []struct{ dir, path string }
for _, tab := range a.runtimeTabsLocked() {
if tab == nil || tab.TopicID != topicID {
continue
}
if path := canonicalTabSessionPath(tab.currentSessionPath()); path != "" {
dir := tabSessionDir(tab)
if filepath.IsAbs(path) {
dir = filepath.Dir(path)
}
runtimeTargets = append(runtimeTargets, struct{ dir, path string }{dir: dir, path: path})
}
}
a.mu.RUnlock()
for _, target := range runtimeTargets {
if err := addTarget(target.dir, target.path); err != nil {
return nil, err
}
}
return targets, nil
}
+132
View File
@@ -0,0 +1,132 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"reasonix/internal/agent"
"reasonix/internal/fileutil"
)
const topicArchiveMetadataPendingDir = "desktop-topic-archive-pending"
type topicArchiveMetadataPending struct {
TopicID string `json:"topicId"`
CreatedAt int64 `json:"createdAt"`
Sessions []topicArchiveMetadataPendingSession `json:"sessions,omitempty"`
}
type topicArchiveMetadataPendingSession struct {
Dir string `json:"dir"`
SessionPath string `json:"sessionPath"`
}
func topicArchiveMetadataPendingPath(topicID string) string {
digest := sha256.Sum256([]byte(strings.TrimSpace(topicID)))
return filepath.Join(desktopConfigDir(), topicArchiveMetadataPendingDir, hex.EncodeToString(digest[:])+".json")
}
func markTopicArchiveMetadataPending(topicID string, targets []topicTrashTarget) error {
topicID = strings.TrimSpace(topicID)
if topicID == "" {
return fmt.Errorf("topicID is required")
}
path := topicArchiveMetadataPendingPath(topicID)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
sessions := make([]topicArchiveMetadataPendingSession, 0, len(targets))
for _, target := range targets {
sessions = append(sessions, topicArchiveMetadataPendingSession{Dir: target.dir, SessionPath: target.sessionPath})
}
body, err := json.MarshalIndent(topicArchiveMetadataPending{
TopicID: topicID, CreatedAt: time.Now().UnixMilli(), Sessions: sessions,
}, "", " ")
if err != nil {
return err
}
return fileutil.AtomicWriteFile(path, body, 0o644)
}
func clearTopicArchiveMetadataPending(topicID string) error {
if err := os.Remove(topicArchiveMetadataPendingPath(topicID)); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func listTopicArchiveMetadataPending() ([]topicArchiveMetadataPending, error) {
dir := filepath.Join(desktopConfigDir(), topicArchiveMetadataPendingDir)
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
pending := make([]topicArchiveMetadataPending, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
continue
}
body, err := os.ReadFile(filepath.Join(dir, entry.Name()))
if err != nil {
return nil, err
}
var item topicArchiveMetadataPending
if err := json.Unmarshal(body, &item); err != nil {
return nil, err
}
item.TopicID = strings.TrimSpace(item.TopicID)
if item.TopicID == "" || filepath.Base(topicArchiveMetadataPendingPath(item.TopicID)) != entry.Name() {
return nil, fmt.Errorf("invalid topic archive metadata marker")
}
pending = append(pending, item)
}
return pending, nil
}
func reconcileTopicArchiveMetadataPending(deleteTopic func(string) error) error {
if deleteTopic == nil {
return fmt.Errorf("topic archive metadata reconciler is unavailable")
}
pending, err := listTopicArchiveMetadataPending()
if err != nil {
return err
}
var errs []error
for _, item := range pending {
itemFailed := false
for _, target := range item.Sessions {
sessionPath, key, err := validateSessionPath(target.Dir, target.SessionPath)
if err == nil {
err = agent.MarkCleanupPending(sessionPath, "delete")
}
if err == nil {
err = reconcileDesktopTrashSessionArtifacts(target.Dir, sessionPath, key)
}
if err != nil {
errs = append(errs, err)
itemFailed = true
}
}
if err := deleteTopic(item.TopicID); err != nil {
errs = append(errs, err)
itemFailed = true
}
if itemFailed {
continue
}
if err := clearTopicArchiveMetadataPending(item.TopicID); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
+157
View File
@@ -0,0 +1,157 @@
package main
import (
"errors"
"log/slog"
"slices"
"reasonix/internal/agent"
"reasonix/internal/control"
)
type topicArchiveRemovalOwnership struct {
guard *agent.SessionRemovalGuard
restoreTab *WorkspaceTab
}
type topicArchiveOwnershipBatch struct {
entries []*topicArchiveRemovalOwnership
byPath map[string]*topicArchiveRemovalOwnership
}
func takeTopicArchiveSessionLease(tab *WorkspaceTab, sessionPath string) *agent.SessionLease {
if tab == nil {
return nil
}
key := sessionRuntimeKey(sessionPath)
tab.sessionLeaseMu.Lock()
defer tab.sessionLeaseMu.Unlock()
lease := tab.sessionLease
if lease == nil || sessionRuntimeKey(lease.Path()) != key {
return nil
}
tab.sessionLease = nil
tab.storeSessionLeaseRuntimeKey("")
return lease
}
func topicArchiveLeaseOwners(removed []removedSessionRuntime) map[string]*WorkspaceTab {
owners := make(map[string]*WorkspaceTab, len(removed))
for _, item := range removed {
if item.tab == nil || item.readOnly || item.sessionPath == "" {
continue
}
key := sessionRuntimeKey(item.sessionPath)
if key != "" && item.tab.sessionLeaseRuntimeKey() == key {
owners[key] = item.tab
}
}
return owners
}
func acquireTopicArchiveOwnership(targets []topicTrashTarget, removed []removedSessionRuntime) (*topicArchiveOwnershipBatch, error) {
batch := &topicArchiveOwnershipBatch{byPath: make(map[string]*topicArchiveRemovalOwnership, len(targets))}
localOwners := topicArchiveLeaseOwners(removed)
// Acquire historical/non-runtime sessions first. If another process owns
// any target, the archive aborts before a local runtime lease is touched.
for _, target := range targets {
key := sessionRuntimeKey(target.sessionPath)
if localOwners[key] != nil {
continue
}
guard, err := acquireSessionRemovalGuard(target.sessionPath)
if err != nil {
batch.release()
return nil, err
}
batch.add(target, guard, nil)
}
// Convert local runtime leases without releasing the lease lock. Converted
// guards can be restored if marker publication or generation validation
// fails before detach commits.
for _, target := range targets {
key := sessionRuntimeKey(target.sessionPath)
tab := localOwners[key]
if tab == nil {
continue
}
lease := takeTopicArchiveSessionLease(tab, target.sessionPath)
if lease == nil {
batch.rollback()
return nil, errTopicArchiveBusy
}
guard, err := lease.TryConvertToRemovalGuard()
if err != nil {
tab.adoptSessionLease(lease)
batch.rollback()
if errors.Is(err, agent.ErrSessionLeaseHeld) {
return nil, errSessionBusyElsewhere
}
return nil, err
}
batch.add(target, guard, tab)
}
return batch, nil
}
func (b *topicArchiveOwnershipBatch) add(target topicTrashTarget, guard *agent.SessionRemovalGuard, tab *WorkspaceTab) {
entry := &topicArchiveRemovalOwnership{guard: guard, restoreTab: tab}
b.entries = append(b.entries, entry)
b.byPath[sessionRuntimeKey(target.sessionPath)] = entry
}
func (b *topicArchiveOwnershipBatch) take(sessionPath string) *agent.SessionRemovalGuard {
if b == nil {
return nil
}
entry := b.byPath[sessionRuntimeKey(sessionPath)]
if entry == nil {
return nil
}
guard := entry.guard
entry.guard = nil
return guard
}
func (b *topicArchiveOwnershipBatch) rollback() {
if b == nil {
return
}
for _, entry := range slices.Backward(b.entries) {
if entry.guard == nil {
continue
}
if entry.restoreTab != nil {
lease, err := entry.guard.RestoreSessionLease()
if err != nil {
slog.Warn("desktop: restoring topic archive session ownership failed")
entry.guard.Release()
} else {
entry.restoreTab.adoptSessionLease(lease)
}
} else {
entry.guard.Release()
}
entry.guard = nil
}
}
func (b *topicArchiveOwnershipBatch) release() {
if b == nil {
return
}
for _, entry := range b.entries {
if entry.guard != nil {
entry.guard.Release()
entry.guard = nil
}
}
}
func delayedDesktopTopicTrash(dir, sessionPath, key string, guard *agent.SessionRemovalGuard, destroys []control.SessionDestroyHandle) {
waitAllDestroyHandles(destroys)
if err := trashSessionArtifactsWithGuard(dir, sessionPath, key, guard); err != nil {
slog.Warn("desktop: delayed topic archive cleanup remains pending")
}
finishDestroyHandles(destroys)
}
+141
View File
@@ -0,0 +1,141 @@
package main
import (
"errors"
"log/slog"
"reasonix/internal/agent"
)
func (a *App) captureTopicRuntimeBindings(topicID string) []removedSessionRuntime {
a.mu.RLock()
defer a.mu.RUnlock()
var captured []removedSessionRuntime
for _, tabs := range []map[string]*WorkspaceTab{a.tabs, a.detachedSessions} {
for _, tab := range tabs {
if tab == nil || tab.TopicID != topicID {
continue
}
captured = append(captured, removedRuntimeFromTab(tab, tabRuntimeSessionDir(tab), canonicalTabSessionPath(tab.currentSessionPath())))
}
}
return captured
}
// snapshotTopicRuntimeBindings keeps bindings usable until every writable
// controller snapshots. The caller owns runtime admission, blocking new turns.
func (a *App) snapshotTopicRuntimeBindings(captured []removedSessionRuntime) error {
for _, item := range captured {
if item.ctrl == nil || item.readOnly {
continue
}
if item.ctrl.Running() {
return errTopicHasActiveWork
}
if err := item.ctrl.Snapshot(); err != nil {
if !errors.Is(err, agent.ErrSessionSnapshotConflict) {
return err
}
kind, _ := agent.SnapshotConflictKind(err)
slog.Warn("desktop: skipping stale runtime snapshot before removing topic", "conflict_kind", kind)
}
}
return nil
}
func topicRuntimeBindingsMatchLocked(tabs map[string]*WorkspaceTab, topicID string, expected map[*WorkspaceTab]removedSessionRuntime) (int, bool) {
matched := 0
for _, tab := range tabs {
if tab == nil || tab.TopicID != topicID {
continue
}
item, ok := expected[tab]
if !ok || tab.Ctrl != item.ctrl || tabRuntimeSessionDir(tab) != item.sessionDir || canonicalTabSessionPath(tab.currentSessionPath()) != item.sessionPath {
return 0, false
}
matched++
}
return matched, true
}
// removeTopicRuntimeBindingsIfUnchanged commits only when the captured runtime
// generation still matches, so stale completion cannot remove a rebound tab.
func (a *App) removeTopicRuntimeBindingsIfUnchanged(topicID string, captured []removedSessionRuntime) (fallbackRuntimeTarget, bool) {
expected := make(map[*WorkspaceTab]removedSessionRuntime, len(captured))
for _, item := range captured {
expected[item.tab] = item
}
a.mu.Lock()
visible, visibleOK := topicRuntimeBindingsMatchLocked(a.tabs, topicID, expected)
detached, detachedOK := topicRuntimeBindingsMatchLocked(a.detachedSessions, topicID, expected)
if !visibleOK || !detachedOK || visible+detached != len(captured) {
a.mu.Unlock()
return fallbackRuntimeTarget{}, false
}
var fallback fallbackRuntimeTarget
fallbackSet := false
for id, tab := range a.tabs {
if _, ok := expected[tab]; !ok {
continue
}
if !fallbackSet {
fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}
fallbackSet = true
}
a.markTabRemovedLocked(tab)
delete(a.tabs, id)
a.removeTabOrderLocked(id)
if a.activeTabID == id {
a.activeTabID = ""
}
}
for key, tab := range a.detachedSessions {
if _, ok := expected[tab]; !ok {
continue
}
if !fallbackSet {
fallback = fallbackRuntimeTarget{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot}
fallbackSet = true
}
a.markTabRemovedLocked(tab)
delete(a.detachedSessions, key)
}
if a.activeTabID == "" && len(a.tabOrder) > 0 {
a.activeTabID = a.tabOrder[0]
}
fallback.needs = len(captured) > 0 && len(a.tabs) == 0
dir, entries, activeID, version := a.saveTabsCollectLocked()
a.mu.Unlock()
a.saveTabsWrite(dir, entries, activeID, version)
return fallback, true
}
func (a *App) finalizeRemovedTopicRuntimes(removed []removedSessionRuntime) {
for _, item := range removed {
if item.sink != nil {
item.sink.clearContext()
}
if item.ctrl == nil || item.readOnly {
continue
}
item.ctrl.SetSessionPath("")
a.quiesceTabAutosave(item.tab)
}
}
func (a *App) tryLockRuntimeMutation(operation string) (func(), bool) {
if hook := a.runtimeMutationBeforeLockHook; hook != nil {
hook(operation)
}
if !a.runtimeRebuildMu.TryLock() {
return nil, false
}
if !a.runtimeAdmissionMu.TryLock() {
a.runtimeRebuildMu.Unlock()
return nil, false
}
return func() {
a.runtimeAdmissionMu.Unlock()
a.runtimeRebuildMu.Unlock()
}, true
}
+478
View File
@@ -0,0 +1,478 @@
package main
import (
"bufio"
"bytes"
"errors"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/provider"
"reasonix/internal/store"
)
type snapshotErrorSessionController struct {
control.SessionAPI
err error
}
func (c *snapshotErrorSessionController) Snapshot() error { return c.err }
func TestTrashTopicSnapshotFailureKeepsRuntimeAndFiles(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_snapshot_failure"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "Snapshot failure"); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
sessionPath := writeTopicSessionWithPrompt(t, dir, "snapshot-failure.jsonl", topicID, "Snapshot failure", projectRoot, "preserve me", time.Now())
base := controllerWithContent(t, sessionPath)
defer base.Close()
snapshotErr := errors.New("snapshot blocked")
ctrl := &snapshotErrorSessionController{SessionAPI: base, err: snapshotErr}
tab := &WorkspaceTab{ID: "snapshot-failure", Scope: "project", WorkspaceRoot: projectRoot, TopicID: topicID,
TopicTitle: "Snapshot failure", SessionPath: sessionPath, Ctrl: ctrl, Ready: true, disabledMCP: map[string]ServerView{}}
app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, tabOrder: []string{tab.ID}, activeTabID: tab.ID}
if err := app.TrashTopic(topicID); !errors.Is(err, snapshotErr) {
t.Fatalf("TrashTopic snapshot error = %v, want %v", err, snapshotErr)
}
if got := app.tabs[tab.ID]; got != tab || tab.removed {
t.Fatalf("snapshot failure changed runtime binding: got=%p removed=%v", got, tab.removed)
}
if got := ctrl.SessionPath(); !sameDesktopPath(got, sessionPath) {
t.Fatalf("snapshot failure session path = %q, want %q", got, sessionPath)
}
if agent.IsCleanupPending(sessionPath) {
t.Fatal("snapshot failure must not publish a cleanup-pending marker")
}
if _, err := os.Stat(sessionPath); err != nil {
t.Fatalf("snapshot failure removed the session file: %v", err)
}
if got := loadTopicTitle(projectRoot, topicID); got != "Snapshot failure" {
t.Fatalf("snapshot failure topic title = %q", got)
}
}
func TestTrashTopicRejectsConcurrentRuntimeMutationWithoutWaiting(t *testing.T) {
isolateDesktopUserDirs(t)
topicID := "topic_runtime_mutation_busy"
if err := setTopicTitle("", topicID, "Runtime mutation busy"); err != nil {
t.Fatalf("set topic title: %v", err)
}
app := &App{}
app.runtimeRebuildMu.Lock()
started := time.Now()
err := app.TrashTopic(topicID)
elapsed := time.Since(started)
app.runtimeRebuildMu.Unlock()
if !errors.Is(err, errTopicArchiveBusy) {
t.Fatalf("TrashTopic error = %v, want %v", err, errTopicArchiveBusy)
}
if elapsed > time.Second {
t.Fatalf("TrashTopic waited %s behind another runtime mutation", elapsed)
}
if got := loadTopicTitle("", topicID); got != "Runtime mutation busy" {
t.Fatalf("busy archive changed topic title to %q", got)
}
}
func TestTrashTopicForeignLeaseFailsBeforeCleanupCommit(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_foreign_lease"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "Foreign lease"); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
sessionPath := writeTopicSessionWithPrompt(t, dir, "foreign-lease.jsonl", topicID, "Foreign lease", projectRoot, "preserve me", time.Now())
cmd := exec.Command(os.Args[0], "-test.run=^TestTrashTopicForeignLeaseHelper$")
cmd.Env = append(os.Environ(),
"REASONIX_TOPIC_ARCHIVE_LEASE_HELPER=1",
"REASONIX_TOPIC_ARCHIVE_LEASE_PATH="+sessionPath,
)
stdin, err := cmd.StdinPipe()
if err != nil {
t.Fatalf("lease helper stdin: %v", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatalf("lease helper stdout: %v", err)
}
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
t.Fatalf("start lease helper: %v", err)
}
released := false
release := func() {
if released {
return
}
released = true
_ = stdin.Close()
if err := cmd.Wait(); err != nil {
t.Errorf("lease helper exit: %v (%s)", err, stderr.String())
}
}
defer release()
ready, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil || strings.TrimSpace(ready) != "ready" {
t.Fatalf("lease helper readiness = %q, err=%v (%s)", ready, err, stderr.String())
}
app := NewApp()
if err := app.TrashTopic(topicID); !errors.Is(err, errSessionBusyElsewhere) {
t.Fatalf("TrashTopic foreign lease error = %v, want %v", err, errSessionBusyElsewhere)
}
if agent.IsCleanupPending(sessionPath) {
t.Fatal("rejected archive published a cleanup-pending marker")
}
if got := loadTopicTitle(projectRoot, topicID); got != "Foreign lease" {
t.Fatalf("rejected archive topic title = %q", got)
}
if err := reconcileDesktopCleanupPending(dir); err != nil {
t.Fatalf("reconcile without marker: %v", err)
}
if _, err := os.Stat(sessionPath); err != nil {
t.Fatalf("rejected archive removed the live session: %v", err)
}
release()
if err := reconcileDesktopCleanupPending(dir); err != nil {
t.Fatalf("reconcile after foreign release: %v", err)
}
if _, err := os.Stat(sessionPath); err != nil {
t.Fatalf("failed archive scheduled a later deletion: %v", err)
}
}
func TestTrashTopicForeignLeaseHelper(t *testing.T) {
if os.Getenv("REASONIX_TOPIC_ARCHIVE_LEASE_HELPER") != "1" {
return
}
lease, err := agent.TryAcquireSessionLease(os.Getenv("REASONIX_TOPIC_ARCHIVE_LEASE_PATH"))
if err != nil {
t.Fatalf("TryAcquireSessionLease: %v", err)
}
if _, err := os.Stdout.WriteString("ready\n"); err != nil {
lease.Release()
t.Fatalf("write readiness: %v", err)
}
var release [1]byte
_, _ = os.Stdin.Read(release[:])
lease.Release()
}
func TestSnapshotTopicRuntimeConflictLogOmitsSessionPath(t *testing.T) {
privatePath := filepath.Join(t.TempDir(), "private-session.jsonl")
base := control.New(control.Options{})
defer base.Close()
ctrl := &snapshotErrorSessionController{SessionAPI: base, err: &agent.SessionSnapshotConflictError{
Path: privatePath,
Kind: agent.SessionSnapshotConflictDiverged,
}}
var output bytes.Buffer
previous := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&output, nil)))
defer slog.SetDefault(previous)
app := NewApp()
if err := app.snapshotTopicRuntimeBindings([]removedSessionRuntime{{ctrl: ctrl}}); err != nil {
t.Fatalf("snapshotTopicRuntimeBindings: %v", err)
}
logged := output.String()
if strings.Contains(logged, privatePath) {
t.Fatalf("snapshot conflict log exposed the session path: %s", logged)
}
if !strings.Contains(logged, string(agent.SessionSnapshotConflictDiverged)) {
t.Fatalf("snapshot conflict log omitted the safe conflict kind: %s", logged)
}
}
func TestTrashTopicConvertsLocalLeaseWithoutUnlockWindow(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_local_lease"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "Local lease"); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
sessionPath := writeTopicSessionWithPrompt(t, dir, "local-lease.jsonl", topicID, "Local lease", projectRoot, "preserve me", time.Now())
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: sessionPath, Label: "test", WorkspaceRoot: projectRoot})
defer ctrl.Close()
tab := &WorkspaceTab{ID: "local", Scope: "project", WorkspaceRoot: projectRoot, TopicID: topicID,
TopicTitle: "Local lease", SessionPath: sessionPath, Ctrl: ctrl, Ready: true, disabledMCP: map[string]ServerView{}}
if err := tab.ensureSessionLease(sessionPath); err != nil {
t.Fatalf("ensureSessionLease: %v", err)
}
keep := &WorkspaceTab{ID: "keep", Scope: "project", WorkspaceRoot: projectRoot, TopicID: "keep", Ready: true}
app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab, keep.ID: keep}, tabOrder: []string{tab.ID, keep.ID}, activeTabID: tab.ID}
if err := app.TrashTopic(" " + topicID + " "); err != nil {
t.Fatalf("TrashTopic: %v", err)
}
if agent.IsCleanupPending(sessionPath) {
t.Fatal("completed archive left cleanup pending")
}
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
t.Fatalf("archived session still exists, err=%v", err)
}
for _, path := range []string{store.SessionLeaseInfo(sessionPath), store.SessionLeaseLock(sessionPath), store.SessionLockFile(sessionPath)} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("ownership sidecar survived archive: %s (err=%v)", path, err)
}
}
}
func TestTrashTopicCommittedCleanupFailureReconcilesWithoutFailureResponse(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_committed_cleanup"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "Committed cleanup"); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
sessionPath := writeTopicSessionWithPrompt(t, dir, "committed-cleanup.jsonl", topicID, "Committed cleanup", projectRoot, "preserve me", time.Now())
topicArchiveCleanupHookForTest = func() error { return errors.New("injected cleanup failure") }
defer func() { topicArchiveCleanupHookForTest = nil }()
app := NewApp()
if err := app.TrashTopic(topicID); err != nil {
t.Fatalf("committed TrashTopic returned a failure: %v", err)
}
if !agent.IsCleanupPending(sessionPath) {
t.Fatal("committed cleanup failure did not retain its durable marker")
}
if got := loadTopicTitle(projectRoot, topicID); got != "" {
t.Fatalf("committed archive retained topic title %q", got)
}
if _, err := os.Stat(sessionPath); err != nil {
t.Fatalf("deferred session disappeared before reconciliation: %v", err)
}
topicArchiveCleanupHookForTest = nil
if err := reconcileDesktopCleanupPending(dir); err != nil {
t.Fatalf("reconcile committed cleanup: %v", err)
}
if agent.IsCleanupPending(sessionPath) {
t.Fatal("reconciliation retained the cleanup marker")
}
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
t.Fatalf("reconciled session still exists, err=%v", err)
}
}
func TestTrashTopicMetadataFailureReconcilesFromDurableIntent(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_metadata_retry"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "Metadata retry"); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
writeTopicSessionWithPrompt(t, dir, "metadata-retry.jsonl", topicID, "Metadata retry", projectRoot, "preserve me", time.Now())
blockedTempPath := topicTitleSourcesPath(projectRoot) + ".tmp"
if err := os.MkdirAll(blockedTempPath, 0o755); err != nil {
t.Fatalf("block metadata temp write: %v", err)
}
app := NewApp()
if err := app.TrashTopic(topicID); err != nil {
t.Fatalf("committed TrashTopic returned a failure: %v", err)
}
if got := loadTopicTitle(projectRoot, topicID); got != "Metadata retry" {
t.Fatalf("failed metadata cleanup title = %q, want retained retry locator", got)
}
if _, err := os.Stat(topicArchiveMetadataPendingPath(topicID)); err != nil {
t.Fatalf("metadata cleanup intent was not retained: %v", err)
}
if err := os.RemoveAll(blockedTempPath); err != nil {
t.Fatalf("unblock metadata temp write: %v", err)
}
if err := reconcileTopicArchiveMetadataPending(app.deleteTopic); err != nil {
t.Fatalf("reconcile topic metadata: %v", err)
}
if got := loadTopicTitle(projectRoot, topicID); got != "" {
t.Fatalf("reconciled archive retained topic title %q", got)
}
if _, err := os.Stat(topicArchiveMetadataPendingPath(topicID)); !os.IsNotExist(err) {
t.Fatalf("reconciled metadata marker still exists, err=%v", err)
}
projects := loadProjectsFile()
for _, project := range projects.Projects {
if containsDesktopString(project.Topics, topicID) || containsDesktopString(project.PinnedTopics, topicID) {
t.Fatalf("reconciled archive retained topic in project index: %+v", project)
}
}
}
func TestTopicArchiveIntentCompletesSessionsAndMetadataAfterRestart(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_restart_commit"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "Restart commit"); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
sessionPath := writeTopicSessionWithPrompt(t, dir, "restart-commit.jsonl", topicID, "Restart commit", projectRoot, "preserve me", time.Now())
targets := []topicTrashTarget{{dir: dir, sessionPath: sessionPath, key: filepath.Base(sessionPath)}}
if err := markTopicArchiveMetadataPending(topicID, targets); err != nil {
t.Fatalf("mark archive intent: %v", err)
}
app := NewApp()
if err := reconcileTopicArchiveMetadataPending(app.deleteTopic); err != nil {
t.Fatalf("reconcile archive intent: %v", err)
}
if _, err := os.Stat(sessionPath); !os.IsNotExist(err) {
t.Fatalf("reconciled live session still exists, err=%v", err)
}
trashPath := filepath.Join(sessionTrashPath(dir), filepath.Base(sessionPath), filepath.Base(sessionPath))
if _, err := os.Stat(trashPath); err != nil {
t.Fatalf("reconciled session was not preserved in trash: %v", err)
}
if got := loadTopicTitle(projectRoot, topicID); got != "" {
t.Fatalf("reconciled archive retained topic title %q", got)
}
if agent.IsCleanupPending(sessionPath) {
t.Fatal("reconciled archive retained session cleanup marker")
}
if _, err := os.Stat(topicArchiveMetadataPendingPath(topicID)); !os.IsNotExist(err) {
t.Fatalf("reconciled archive retained topic marker, err=%v", err)
}
}
func TestTrashTopicPreservesDivergedExistingTrash(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
topicID := "topic_diverged_trash"
if err := addProject(projectRoot, ""); err != nil {
t.Fatalf("add project: %v", err)
}
if err := setTopicTitle(projectRoot, topicID, "Diverged trash"); err != nil {
t.Fatalf("set topic title: %v", err)
}
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
sessionPath := writeTopicSessionWithPrompt(t, dir, "diverged-trash.jsonl", topicID, "Diverged trash", projectRoot, "new live history", time.Now())
existingTrashPath := filepath.Join(sessionTrashPath(dir), filepath.Base(sessionPath), filepath.Base(sessionPath))
existing := agent.NewSession("system")
existing.Add(provider.Message{Role: provider.RoleUser, Content: "older trash history"})
if err := existing.SaveSnapshot(existingTrashPath); err != nil {
t.Fatalf("write existing trash: %v", err)
}
app := NewApp()
if err := app.TrashTopic(topicID); err != nil {
t.Fatalf("TrashTopic: %v", err)
}
trashed, err := listTrashedSessionFiles(dir)
if err != nil {
t.Fatalf("listTrashedSessionFiles: %v", err)
}
if len(trashed) != 2 {
t.Fatalf("trashed session count = %d, want both histories: %v", len(trashed), trashed)
}
seen := map[string]bool{}
for _, path := range trashed {
session, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession(%q): %v", path, err)
}
for _, message := range session.Snapshot() {
seen[message.Content] = true
}
}
for _, content := range []string{"older trash history", "new live history"} {
if !seen[content] {
t.Fatalf("archived histories = %#v, missing %q", seen, content)
}
}
}
func TestTopicArchiveOwnershipRollbackRestoresLocalLease(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir sessions: %v", err)
}
sessionPath := filepath.Join(dir, "rollback-lease.jsonl")
if err := os.WriteFile(sessionPath, []byte("{}\n"), 0o644); err != nil {
t.Fatalf("write session: %v", err)
}
tab := &WorkspaceTab{ID: "rollback", SessionPath: sessionPath, Ready: true}
if err := tab.ensureSessionLease(sessionPath); err != nil {
t.Fatalf("ensureSessionLease: %v", err)
}
defer tab.releaseSessionLease()
removed := []removedSessionRuntime{{tab: tab, sessionDir: dir, sessionPath: sessionPath}}
targets := []topicTrashTarget{{dir: dir, sessionPath: sessionPath, key: filepath.Base(sessionPath)}}
batch, err := acquireTopicArchiveOwnership(targets, removed)
if err != nil {
t.Fatalf("acquireTopicArchiveOwnership: %v", err)
}
if tab.sessionLeaseRuntimeKey() != "" {
batch.rollback()
t.Fatal("converted ownership remained attached to the runtime tab")
}
batch.rollback()
if got := tab.sessionLeaseRuntimeKey(); got != sessionRuntimeKey(sessionPath) {
t.Fatalf("rolled back lease key = %q, want %q", got, sessionRuntimeKey(sessionPath))
}
if next, err := agent.TryAcquireSessionLease(sessionPath); !errors.Is(err, agent.ErrSessionLeaseHeld) {
if next != nil {
next.Release()
}
t.Fatalf("competing lease after rollback err = %v, want ErrSessionLeaseHeld", err)
}
}
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"os"
"path/filepath"
)
// ResolveWorkspacePathForTab returns the absolute local path represented by a
// workspace-relative path or a session-authorized external folder reference.
// Keeping this resolution in the backend ensures the renderer cannot mistake an
// external reference token for a child of the workspace root.
func (a *App) ResolveWorkspacePathForTab(tabID, rel string) (string, error) {
path, ok, err := a.workspaceOrExternalPathForTab(tabID, rel)
if err != nil {
return "", err
}
if !ok {
return "", os.ErrInvalid
}
abs, err := filepath.Abs(path)
if err != nil {
return "", err
}
return filepath.Clean(abs), nil
}
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/control"
)
func TestResolveWorkspacePathForTab(t *testing.T) {
parentRoot := robustTempDir(t)
childRoot := filepath.Join(parentRoot, "child")
if err := os.MkdirAll(childRoot, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(childRoot, "shared.txt"), []byte("child"), 0o644); err != nil {
t.Fatal(err)
}
app := &App{tabs: map[string]*WorkspaceTab{
"child": {ID: "child", Scope: "project", WorkspaceRoot: childRoot},
}}
absolutePath, err := app.ResolveWorkspacePathForTab("child", "shared.txt")
if err != nil || absolutePath != filepath.Join(childRoot, "shared.txt") {
t.Fatalf("ResolveWorkspacePathForTab(child) = (%q, %v)", absolutePath, err)
}
if _, err := app.ResolveWorkspacePathForTab("missing", "shared.txt"); err == nil {
t.Fatal("ResolveWorkspacePathForTab accepted a missing tab")
}
if _, err := app.ResolveWorkspacePathForTab("child", "../parent-only.txt"); err == nil {
t.Fatal("ResolveWorkspacePathForTab accepted a path outside the requested tab workspace")
}
}
func TestResolveWorkspacePathForTabExternalFolder(t *testing.T) {
external := filepath.Join(robustTempDir(t), "Folder With Spaces")
if err := os.MkdirAll(filepath.Join(external, "src"), 0o755); err != nil {
t.Fatal(err)
}
expectedExternal := external
if resolved, err := filepath.EvalSymlinks(external); err == nil {
expectedExternal = resolved
}
ctrl := &control.Controller{}
token, _, err := ctrl.RegisterExternalFolderRef(external)
if err != nil {
t.Fatalf("RegisterExternalFolderRef: %v", err)
}
app := &App{tabs: map[string]*WorkspaceTab{
"project": {ID: "project", WorkspaceRoot: robustTempDir(t), Ctrl: ctrl},
}}
absolutePath, err := app.ResolveWorkspacePathForTab("project", token+"/src/outside.txt")
want := filepath.Join(expectedExternal, "src", "outside.txt")
if err != nil || absolutePath != want {
t.Fatalf("ResolveWorkspacePathForTab external = (%q, %v), want %q", absolutePath, err, want)
}
}
+32 -13
View File
@@ -14,7 +14,7 @@
- [Configuration](#configuration)
- [CLI reference](./CLI.md)
- [Environment variables](#environment-variables)
- [Serve web frontend](#serve-web-frontend)
- [Web frontend](#web-frontend)
- [Configuration paths](./CONFIG_PATHS.md)
- [Reasoning language](./REASONING_LANGUAGE.md)
- [Task contracts and pause policy](./TASK_CONTRACT.md)
@@ -69,6 +69,7 @@ reasoning_language = "auto" # visible reasoning text: auto|zh|en
# max_subagent_concurrency = 6 # session-wide sub-agent concurrency (task/fleet/skills)
# max_parallel_writers = 3 # concurrent writers with non-overlapping write_paths
tool_result_snip_ratio = 0.6 # shorten stale tool output before summary compaction
# context_editing = "native" # opt in only for the official Anthropic endpoint; default local
[[providers]]
name = "deepseek-flash"
@@ -205,23 +206,39 @@ these separately reviewed reports. Runtime fatal throws, operating-system kills,
and panics in unwrapped background goroutines cannot be recovered by Go and do
not produce this local report.
## Serve web frontend
## Web frontend
`reasonix serve` starts the same local engine behind a browser UI. Use it when
you want a desktop-style surface without installing the desktop app, when running
Reasonix on a remote development box through a tunnel, or when you want a
shareable view of a live session.
For local use, `reasonix web` starts the browser UI and opens it in your default
browser. Inside an interactive CLI session, `/web` snapshots the current session,
restores the terminal, and opens an explicit `/sessions/<id>#token=...` deep link.
Even a never-used session keeps its reserved ID without forcing an empty
transcript onto disk, so the first Web turn continues the same session identity.
```bash
cd your-project
reasonix serve
# open http://127.0.0.1:8787
reasonix web
```
By default it listens on `127.0.0.1:8787` with `auth_mode = "none"`. Keep that
default for local-only use. If you bind outside loopback, expose it through a
tunnel, or put it behind a reverse proxy, enable authentication before sharing
the URL:
Use `reasonix web --no-open` when you want to start the foreground Web server
and print its URL without opening a browser tab. The lower-level
`reasonix serve` command starts the same engine without opening a browser by
default. It remains the right entry point for remote development boxes,
supervisors, tunnels, reverse proxies, and shareable authenticated sessions.
`reasonix web` starts at `127.0.0.1:8787`, automatically tries 8788, 8789, and
so on when a port is busy (up to 100 retries), and defaults to a newly generated
token even when `[serve].auth_mode` is `none`. Each live process registers a
single-writer heartbeat file under `<Reasonix home>/server/instances/`; clean
shutdown removes its own file, while later instances lazily remove records whose
owner process is confirmed dead. Multiple Web instances can therefore share one
Reasonix home without overwriting registry state. The process stays attached to
the terminal; stop it with Ctrl-C.
An explicit `reasonix web --auth none` disables the default token and should be
used only when the listener is intentionally trusted. `reasonix serve` keeps its
backward-compatible, config-driven `auth_mode = "none"` default on
`127.0.0.1:8787`. If you bind Serve outside loopback, expose it through a tunnel,
or put it behind a reverse proxy, enable authentication before sharing the URL:
```bash
reasonix serve --auth token
@@ -229,7 +246,9 @@ reasonix serve --addr 0.0.0.0:8787 --auth token
reasonix serve --auth password --password 'temporary-password'
```
Token mode prints a share URL with `?token=...`; pass `--token` or set
Token mode prints a share URL with `#token=...`; the Web page exchanges the
fragment for an HttpOnly cookie before starting API or SSE requests, keeping the
token out of request URLs, browser history, referrers, and access logs. Pass `--token` or set
`[serve].token` to reuse a stable token. Password mode requires either
`--password` at startup or a stored bcrypt hash:
+26 -9
View File
@@ -14,7 +14,7 @@
- [配置](#配置)
- [CLI 命令参考](./CLI.zh-CN.md)
- [环境变量](#环境变量)
- [Serve Web 前端](#serve-web-前端)
- [Web 前端](#web-前端)
- [配置路径](./CONFIG_PATHS.zh-CN.md)
- [思考语言](./REASONING_LANGUAGE.zh-CN.md)
- [任务合约与暂停策略](./TASK_CONTRACT.zh-CN.md)
@@ -63,6 +63,7 @@ reasoning_language = "auto" # 可见思考过程语言:auto|zh|en
# max_subagent_concurrency = 6 # 会话级子代理总并发(task/fleet/skills)
# max_parallel_writers = 3 # 互不重叠 write_paths 时的并行写入上限
tool_result_snip_ratio = 0.6 # 在摘要 compaction 前先缩短旧工具输出
# context_editing = "native" # 仅官方 Anthropic 端点显式启用;默认 local
[[providers]]
name = "deepseek-flash"
@@ -184,19 +185,33 @@ reasonix report delete [ID] # 不发送,直接删除
需要单独审阅的报告。Go 无法恢复 runtime fatal throw、操作系统强制终止,以及未包装
后台 goroutine 中的 panic,因此这些情况不会生成本地报告。
## Serve Web 前端
## Web 前端
`reasonix serve` 会用同一个本地 Reasonix 引擎启动浏览器 UI。适合不安装桌面端但想用可视化界面、
在远程开发机上通过 tunnel 使用,或把当前会话临时共享给浏览器查看的场景。
本机使用时,`reasonix web` 会启动浏览器 UI,并自动用默认浏览器打开。也可以在 CLI 交互会话中
执行 `/web`:Reasonix 会保存当前会话、恢复终端,然后打开明确的
`/sessions/<id>#token=...` 深链。即使会话尚未产生第一轮消息,也会延续已预留的 Session ID,
同时继续保持“空会话不提前写 transcript”的惰性落盘行为。
```bash
cd your-project
reasonix serve
# 打开 http://127.0.0.1:8787
reasonix web
```
默认监听 `127.0.0.1:8787`,认证模式是 `auth_mode = "none"`。这个默认值只适合本机使用。
如果要绑定到非 loopback 地址、通过 tunnel 暴露,或放到反向代理后面,请先开启认证再分享 URL:
如果想启动前台 Web 服务并打印地址、但不自动新开浏览器标签页,可使用
`reasonix web --no-open`。底层的 `reasonix serve`
默认不会打开浏览器,继续用于远程开发机、进程托管、tunnel、反向代理和需要认证分享的场景。
`reasonix web` 从 `127.0.0.1:8787` 开始监听;端口占用时会依次尝试 8788、8789……,
最多递增重试 100 次。它默认启用自动生成的 Token,即使配置中的 `[serve].auth_mode`
是 `none` 也一样。每个运行实例都会在 `<Reasonix home>/server/instances/` 下写入自己的
单写者 heartbeat 文件;正常退出时只删除自己的文件,新实例则会惰性清理已确认进程死亡的记录。
因此多个 Web 实例可以共用同一个 Reasonix home,而不会相互覆盖登记状态。服务保持在前台运行,
按 Ctrl-C 停止。
显式传入 `reasonix web --auth none` 可以关闭默认 Token,只应在监听地址确定可信时使用。
`reasonix serve` 则保持向后兼容:默认监听 `127.0.0.1:8787`,认证模式仍由配置决定,空配置为
`auth_mode = "none"`。如果要绑定到非 loopback 地址、通过 tunnel 暴露,或放到反向代理后面,
请先开启认证再分享 URL:
```bash
reasonix serve --auth token
@@ -204,7 +219,9 @@ reasonix serve --addr 0.0.0.0:8787 --auth token
reasonix serve --auth password --password 'temporary-password'
```
Token 模式会在终端打印带 `?token=...` 的分享链接;可通过 `--token` 或 `[serve].token`
Token 模式会在终端打印带 `#token=...` 的分享链接;Web 页面会先将 fragment 换成
HttpOnly Cookie,再启动 API 与 SSE 请求,从而避免 Token 进入请求 URL、浏览器历史、
Referrer 和访问日志。可通过 `--token` 或 `[serve].token`
复用固定 token。Password 模式必须在启动时传 `--password`,或在配置里保存 bcrypt hash:
```bash
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff. Show more