Compare commits

...
235 Commits
Author SHA1 Message Date
SivanCola 668cdee703 Merge pull request #9220 from esengine/release-notes/v1.31.1
docs(release): 准备 v1.31.1 中英更新日志
2026-08-21 12:12:00 +08:00
github-actions[bot] 2daa151e1c docs(release): prepare v1.31.1 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-21 04:08:44 +00:00
SivanCola ca11dd05a0 Merge pull request #9184 from SivanCola/fix/9172-standard-task-continuation
Fix bounded continuation for Standard write tasks / 修复 Standard 写任务的有界自动续跑
2026-08-21 09:45:37 +08:00
SivanCola ee94ecb67e Merge pull request #9204 from SivanCola/fix/pr9195-recovery
Session: fold and clean up recovery copies / 会话:折叠并清理恢复副本
2026-08-21 08:30:56 +08:00
SivanCola b792fa4af5 merge(main-v2): reconcile recovery catalog after scroll controllers
Bring the recovery catalog change onto the merged scroll-controller lifecycle. Preserve the bounded reconcile implementation, refresh the file-size baseline for the combined types changes, and keep the dependent recovery-copy sweep intact.
2026-08-21 03:59:53 +08:00
SivanCola 5ec563372c Merge pull request #9202 from SivanCola/fix/pr9195-scroll-controllers
Desktop: extract transcript scroll controllers / 桌面端:拆分对话滚动控制器
2026-08-21 03:58:11 +08:00
SivanCola 877e1efc0f test: align bottom-hold idle behavior
Expect the idle close to re-sample a held native bottom before ending reader intent, as required by the merged large-wheel fix. Keep a separate assertion that the next intent window rebuilds its hold count from zero.
2026-08-21 03:40:28 +08:00
SivanCola 4c90c3aac0 Merge pull request #9206 from SivanCola/feature/opencode-go-integration
feat(provider): route-safe OpenCode Go presets / 路由安全的 OpenCode Go 预设
2026-08-21 03:26:55 +08:00
SivanCola c2ad5d4fc1 fix: keep recovery sweep in follow-up PR
Do not reference the recovery-copy sweep before its implementation lands in the dependent catalog UX change. The scroll-controller lifecycle retains only its own reconcile responsibilities.
2026-08-21 03:25:56 +08:00
SivanCola f51ed04b28 fix: rely on Go loop variable scoping
Remove the redundant loop-variable copy now that the desktop module targets Go 1.22 or newer, keeping golangci-lint clean without changing reconcile behavior.
2026-08-21 03:17:34 +08:00
SivanCola dbac04872f fix: rely on Go loop variable scoping
Remove the redundant loop-variable copy now that the desktop module targets Go 1.22 or newer, keeping golangci-lint clean without changing reconcile behavior.
2026-08-21 03:17:18 +08:00
SivanCola dcf2581882 fix: bound scroll catalog lifecycle
Keep the extracted session catalog lifecycle bounded after main-v2 reconciliation and preserve recovery-copy cleanup on refresh. Add the same narrow simplified Chinese gzip headroom used by the recovery catalog change.
2026-08-21 03:13:34 +08:00
SivanCola 78c1bf2b62 fix: reconcile recovery catalog lifecycle
Keep session catalog startup work on the shared lifecycle runner after main-v2 advanced. Bound independent directory reconciles to four workers, preserve periodic redundant-copy sweeps, and add narrow gzip headroom for the expanded recovery/catalog locale labels.
2026-08-21 03:10:57 +08:00
SivanCola b414e352ab merge(main-v2): reconcile scroll controller changes
Problem: main-v2 advanced with the completed scroll behavior PR while the controller PR still carried the earlier stability benchmark variant.

Root cause: both branches edited the same browser benchmark after the base branch gained the deterministic repeated-wheel helper.

Fix: retain main-v2's deterministic outer-gutter wheel benchmark and preserve the controller, single-writer, and recovery changes from this PR.

Verification: go run ./tools/repolint; git diff --check; go test ./internal/sessioncatalog ./internal/config; (cd desktop && go test . -run 'Test(Transcript|ProjectTree|SessionCatalog|RenameTopic|ActivityStatus)' -count=1).
2026-08-21 03:01:02 +08:00
SivanCola f1b613874e Merge pull request #9201 from SivanCola/fix/pr9195-scroll-behavior
Desktop: stabilize transcript tail ownership / 桌面端:稳定对话滚动尾部归属
2026-08-21 02:58:59 +08:00
SivanCola 1cf750d67c merge(main-v2): reconcile recovery catalog changes
Problem: main-v2 advanced through the earlier split PRs while recovery cleanup still carried startup concurrency and copy-count changes.

Root cause: the branch retained pre-extraction catalog helpers and the shared project-tree runtime had new recovery-only projection fields.

Fix: adopt the extracted catalog target helpers, preserve bounded recovery reconciliation and cleanup, retain the recovery-copy badge, and baseline the intentional runtime file-size/complexity carry-forward.

Verification: go run ./tools/repolint; git diff --check; go test ./internal/sessioncatalog ./internal/config; (cd desktop && go test . -run 'Test(RecoveryCopy|ProjectTree|SessionCatalog|RenameTopic)' -count=1).
2026-08-21 02:48:23 +08:00
SivanCola 341221c82e Merge pull request #9203 from SivanCola/fix/pr9195-catalog-ux
Desktop: harden catalog-only topic UX / 桌面端:强化仅目录主题交互
2026-08-21 02:37:22 +08:00
SivanCola 4b22777353 Merge pull request #9200 from SivanCola/fix/pr9195-history
History: bound search index growth / 历史记录:限制搜索索引增长
2026-08-21 02:37:16 +08:00
SivanCola 57c1ae8924 Merge pull request #9199 from SivanCola/fix/pr9195-composer
Desktop: preserve IME input and autosize overflow / 桌面端:修复输入法与自适应高度
2026-08-21 02:37:11 +08:00
SivanCola 07373b0b77 Merge pull request #9198 from SivanCola/fix/pr9195-render
Desktop: harden streaming and Mermaid rendering / 桌面端:强化流式与 Mermaid 渲染
2026-08-21 02:35:56 +08:00
SivanCola 3ec2f0161d Merge pull request #9205 from SivanCola/fix/session-catalog-sidebar-recovery
fix(desktop): rebuild session sidebar from authoritative sessions / 修复会话侧栏索引丢失
2026-08-21 02:35:49 +08:00
SivanCola 4e87c51498 Merge pull request #9197 from SivanCola/fix/pr9195-websearch
Fix: surface web search result deltas / 修复:展示 Web Search 增量结果
2026-08-21 02:35:23 +08:00
SivanCola 4a86a8923e fix(provider): satisfy OpenCode Go CI contracts
Problem: PR CI rejected two lint findings and a Chinese locale bundle at the exact gzip budget boundary.\n\nRoot cause: migration code contained a redundant assignment and a legacy prefix pattern, while the added route-specific copy exceeded the maintained locale gate by a few bytes.\n\nFix: simplify migration logic and reuse the existing OpenCode Go description key for the recommended and Responses cards, removing duplicate locale strings.\n\nVerification: go test ./internal/config ./internal/provider ./internal/control; golangci-lint run --timeout=5m; pnpm build; git diff --check.
2026-08-21 02:29:23 +08:00
SivanCola 347013f009 test(desktop): make wheel-to-tail benchmark deterministic
Problem: Chromium caps a single synthetic 100000px wheel and can leave the transcript far above the bottom, making the tail-follow assertion time out.

Root cause: the benchmark used the center of the transcript as a target and assumed one oversized wheel event reaches the native bottom across browser engines.

Fix: target the outer reader gutter and send bounded repeated wheel events, checking native geometry after each event.

Verification: pnpm build; PLAYWRIGHT_EXECUTABLE_PATH=Google Chrome pnpm exec node bench/transcript-scroll-stability.mjs; git diff --check.
2026-08-21 02:29:23 +08:00
SivanCola 44c275fc6d fix(desktop): satisfy activity snapshot lint
Problem: the recovery copy branch failed the modern Go maps-loop lint in the project-tree activity snapshot.

Root cause: the snapshot still used a manual map copy while the shared lint requires maps.Copy.

Fix: use maps.Copy for the locked activity-state snapshot.

Verification: gofmt; go test ./desktop -run 'Test(ProjectTree|RenameTopic|ActivityStatus|RecoveryCopy)' -count=1; go run ./tools/repolint; golangci-lint run --timeout=5m ./desktop/...; git diff --check.
2026-08-21 02:27:14 +08:00
SivanCola e26f5599e7 fix(desktop): keep recovered badge without branch counters
Problem: canonical recovery topics marked the ordinary row with recovery counters that legacy sidebar tests and the plain-row contract reject.\n\nRoot cause: canonical visibility and recovery-history metadata were coupled in the ProjectNode projection.\n\nFix: retain the recovered state for the canonical open target while keeping branch counters and cleanup details in recovery history unless the topic is recovery-only.\n\nVerification: go test ./desktop -run TestUpgradeLegacyRecoveryChainNeedsNoUserActionForOneRowSidebar and repolint.
2026-08-21 02:17:25 +08:00
SivanCola c83f19950c fix(repo): baseline recovery copy type field
Problem: adding the recovery copy count field pushed the existing types file one line beyond the repolint baseline.

Root cause: the intentional contract type file is already over the generic ceiling and its baseline was not advanced with the field.

Fix: advance only the existing types file file-size baseline by one line.

Verification: go run ./tools/repolint; git diff --check; go test ./...; (cd desktop && go test ./...); targeted race tests; frontend tests, TypeScript, and ESLint.
2026-08-21 02:12:29 +08:00
SivanCola f663bc2afb chore(repo): ratchet OpenCode Go change budgets
Problem: the repository standards guard treated the intentionally expanded provider preset, bridge mock, and focused preset test as new size violations.\n\nRoot cause: the existing ratchet baseline did not include the additive route-safe preset and migration coverage.\n\nFix: update only the three affected baseline entries; no unrelated baseline findings are changed.\n\nVerification: go run ./tools/repolint and git diff --check pass.
2026-08-21 02:10:47 +08:00
SivanCola 314e899f76 fix(desktop): keep catalog repair UI within bundle budgets
Problem: the new repair notifications pushed the Chinese locale chunk over the enforced production bundle budget.\n\nRoot cause: adding multiple localized status strings increased a near-limit language chunk.\n\nFix: reuse existing index/rebuild labels for the repair action and status while retaining the catalog status fields and progress affordance.\n\nVerification: pnpm build, typecheck, test:typecheck, lint:hooks, and check:css.
2026-08-21 02:03:41 +08:00
SivanCola 77447b565d fix(desktop): satisfy maps copy lint
Problem: the new project-tree activity watchdog used a manual map copy that modern golangci-lint rejects on the desktop cross-platform build.\n\nRoot cause: the snapshot helper predates the repository's maps.Copy lint contract.\n\nFix: use maps.Copy while retaining the mutex-protected snapshot semantics.\n\nVerification: go test ./...; pnpm exec tsx src/__tests__/project-tree-rename-optimistic.test.ts
2026-08-21 02:03:30 +08:00
SivanCola 71cf079f65 fix(desktop): route markdown window compensation through arbiter
Problem: MarkdownHistory adjusted the transcript scroller directly while the extracted scroll controller declared raw transcript writes off-limits.\n\nRoot cause: the new block-window-prepend owner and context provider were not connected to the progressive markdown window's layout compensation.\n\nFix: send the measured anchor correction through the transcript scroll offset provider and cover the provider path in the markdown history harness.\n\nVerification: pnpm exec tsx src/__tests__/markdown-history.test.tsx; pnpm exec tsc --noEmit -p tsconfig.json; pnpm exec eslint src/components/MarkdownHistory.tsx src/components/TranscriptLayoutIntentContext.tsx src/__tests__/markdown-history.test.tsx src/lib/transcriptAnchorCompensation.ts src/lib/transcriptTailSettle.ts src/lib/useTranscriptScrollArbiter.ts src/lib/transcriptScrollArbiter.ts; node scripts/check-single-scroll-writer.mjs
2026-08-21 01:57:06 +08:00
SivanCola 47443edf38 merge: integrate OpenCode Go route-safe presets 2026-08-21 01:54:05 +08:00
SivanCola b9897edc59 feat(provider): redesign OpenCode Go route-safe presets
Problem: OpenCode Go models from Chat, Anthropic, and Responses routes could be persisted under one provider, causing protocol mismatches and costly setup errors.\n\nRoot cause: the preset and fetched catalog surfaces did not encode route ownership, and legacy aggregate providers had no safe migration path.\n\nFix: add a one-key recommended three-route preset, route-safe catalogs and diagnostics, subscription-equivalent cost defaults, Wails/mock support, and idempotent migration that preserves user fields and rewrites historical model references.\n\nVerification: go test ./..., go vet ./..., desktop Go tests, frontend typechecks, and provider access UI tests pass.
2026-08-21 01:53:51 +08:00
SivanCola c9fd02da4c Merge remote PR branch before follow-up recovery visibility fix
# Conflicts:
#	desktop/session_catalog_recovery_test.go
#	desktop/session_catalog_runtime.go
2026-08-21 01:49:26 +08:00
SivanCola c76d201cd0 fix(desktop): rebuild session catalog from authoritative files
Problem: the desktop sidebar could hide sessions when a ready projection omitted paths or recovery branches after an upgrade.\n\nRoot cause: catalog generation and readiness checks trusted stale projections, while target discovery and recovery-only visibility were incomplete.\n\nFix: move the disposable index to v5, rebuild and validate exact source projections, cover registered and restored workspace directories, preserve rebuild backups, and expose recovery history and self-service diagnostics.\n\nVerification: go vet ./..., go test ./..., desktop go vet/test, race tests, frontend typecheck/lint/CSS checks, and repolint.
2026-08-21 01:48:48 +08:00
SivanCola 7c4f5f2f61 fix(desktop): settle wheel tail intent at native bottom
Problem: a large wheel or touch gesture could clamp the transcript to the physical bottom without emitting a second scroll delivery, leaving the new two-delivery tail-hold policy in manual mode.

Root cause: the reader-intent idle timer closed the ownership window without re-sampling native geometry.

Fix: perform one final delivery through the arbiter before ending reader intent, and baseline the deliberate file-size carry-forward from this scroll-layer extraction.

Verification: pnpm exec tsx src/__tests__/transcript-recovery-race.test.tsx; pnpm exec tsx src/__tests__/transcript-scroll-release.test.ts; pnpm exec tsc --noEmit -p tsconfig.test.json; pnpm exec eslint src/lib/useTranscriptScrollArbiter.ts; go run ./tools/repolint; git diff --check.
2026-08-21 01:48:05 +08:00
SivanCola 536c17f986 fix(desktop): rebuild session catalog from authoritative files
Problem: the desktop sidebar could hide sessions when a ready projection omitted paths or recovery branches after an upgrade.\n\nRoot cause: catalog generation and readiness checks trusted stale projections, while target discovery and recovery-only visibility were incomplete.\n\nFix: move the disposable index to v5, rebuild and validate exact source projections, cover registered and restored workspace directories, preserve rebuild backups, and expose recovery history and self-service diagnostics.\n\nVerification: go vet ./..., go test ./..., desktop go vet/test, race tests, frontend typecheck/lint/CSS checks, and repolint.
2026-08-21 01:42:06 +08:00
SivanCola a7855653d7 fix(history): satisfy modern Go lint
Problem: the history governance split left modern Go lint findings in the new catalog worker and tests.

Root cause: the implementation used a manual goroutine lifecycle and C-style loops after the repository lint baseline moved to Go 1.26.

Fix: use WaitGroup.Go for the background rebuild and integer ranges for bounded test loops.

Verification: go test ./internal/historycatalog ./internal/config; go test -race ./internal/historycatalog; go test ./internal/...; go vet ./internal/historycatalog ./internal/config; golangci-lint run --timeout=5m ./internal/historycatalog/...; git diff --check.
2026-08-21 01:40:49 +08:00
SivanCola ba3546a6bf fix(desktop): keep Mermaid definitions outside pan zoom
Problem: the local Mermaid pan/zoom viewport moved <defs> under the transformed group.

Root cause: marker, clipPath, and gradient definitions use the SVG coordinate system and must not inherit the viewport transform.

Fix: leave <defs> at the SVG root while moving only rendered children into the pan/zoom viewport, with a regression test covering definition ownership.

Verification: pnpm exec tsx src/__tests__/mermaid-rendering.test.tsx; pnpm exec tsx src/__tests__/markdown-streaming-worker.test.tsx; pnpm exec tsc --noEmit -p tsconfig.test.json; pnpm exec eslint src/components/mermaidPanZoom.ts src/components/Markdown.tsx src/components/MermaidDiagram.tsx src/__tests__/mermaid-rendering.test.tsx src/__tests__/markdown-streaming-worker.test.tsx.
2026-08-21 01:17:01 +08:00
SivanCola 43a4347fec perf(agent): fuse session digest into decode (#9196)
Problem: session loading decoded the same persisted content through multiple paths, increasing startup work and making the display projection depend on repeated parsing.

Root cause: the decode result was not shared across the load and display projections.

Fix: fuse the session digest into the decode path and reuse the canonical content identity while preserving existing fallback behavior.

Verification: `go test ./internal/agent`.

This is an adapted, focused replacement for the corresponding part of #9195.
2026-08-21 00:51:30 +08:00
SivanCola 85aa462601 Merge pull request #9139 from ttmouse/fix/qq-gateway-handshake
fix(bot/qq): 自动协商 gateway intents
2026-08-21 00:32:17 +08:00
SivanCola 164ab70cbe feat(session): fold and clean up recovery copies
Dependency: builds on `fix/pr9195-catalog-ux` from the same source series; the PR diff intentionally includes the catalog UX layer when reviewed against official `main-v2`.

Problem: recovered copies could reappear as duplicate sidebar sessions, and provably redundant copies had no bounded, recoverable cleanup path.

Root cause: lineage re-anchoring did not leave tombstones for folded shells, metadata sync could recreate them, and cleanup had no explicit trash/sweep contract.

Fix: persist folded-topic tombstones, expose a muted recovery-copy count, add bounded recovery-copy sweeping through recoverable trash, and add config/schema/regression coverage.

Verification: `go test ./internal/sessioncatalog ./internal/config`; focused project-tree recovery badge contract test.

This is an adapted, focused replacement for the corresponding part of #9195.
2026-08-21 00:27:04 +08:00
SivanCola e80c610e43 refactor(desktop): extract transcript scroll controllers
Dependency: builds on `fix/pr9195-scroll-behavior` from the same source series; the PR diff intentionally includes the behavior layer when reviewed against official `main-v2`.

Problem: the transcript arbiter exceeded maintainability/file-size limits and raw scroll writers were difficult to police.

Fix: extract anchor-compensation and tail-settle controllers, strengthen the single-writer gate, whitelist only non-transcript native scrollers, and align the stability bench with bottom-hold semantics.

Verification: full `pnpm --dir desktop/frontend test:transcript`; `pnpm --dir desktop/frontend build`; scroll-writer gate passes.

This is the second scroll layer and an adapted, focused replacement for the corresponding part of #9195.
2026-08-21 00:26:39 +08:00
SivanCola 43009dcded feat(desktop): harden catalog-only topic UX
Problem: catalog-only topics could not be renamed reliably, stale activity indicators could remain indefinitely, and asynchronous tree refreshes could leave labels stale.

Root cause: metadata-only topics were routed through live-tab rename logic, activity status had no missed-event watchdog, and the tree dropped stale revisions instead of refetching.

Fix: persist catalog-only renames, add TTL/reopen reconciliation for live status, paint successful renames optimistically, and refetch after revision gaps.

Verification: `cd desktop && go test ./...`; focused project-tree rename contract test.

This is an adapted, focused replacement for the corresponding part of #9195.
2026-08-21 00:25:34 +08:00
SivanCola 671b6b740c fix(desktop): stabilize transcript tail ownership
Problem: stale Virtuoso delivery signals and viewport changes could steal tail ownership, strand recovery, or repeatedly write scroll corrections while the user was reading manually.

Root cause: tail-follow, recovery, selection, and native geometry signals were not separated into explicit ownership transitions.

Fix: make physical bottom and explicit user intent authoritative, add bounded recovery/tail convergence, and preserve manual reading through resize, selection, and content churn.

Verification: full `pnpm --dir desktop/frontend test:transcript` and `pnpm --dir desktop/frontend build`.

This is the first scroll layer and an adapted, focused replacement for the corresponding part of #9195.
2026-08-21 00:25:07 +08:00
SivanCola 2cde3b8d4e feat(history): bound search index growth
Problem: the history search index could grow without a bounded governance path as sessions and content revisions accumulated.

Root cause: catalog documents had no explicit cap/eviction policy for retained search material.

Fix: add bounded history-search configuration, document governance, and focused catalog tests while preserving resident projections and revision checks.

Verification: `go test ./internal/historycatalog ./internal/config`.

This is an adapted, focused replacement for the corresponding part of #9195.
2026-08-21 00:25:05 +08:00
SivanCola fb55ccd8b1 fix(desktop): preserve IME input and autosize overflow
Problem: controlled Composer updates could clobber WebView2 IME composition text, and long autosized drafts did not expose their internal scrollbar consistently.

Root cause: composition updates and programmatic draft updates shared the normal controlled-input synchronization path, while autosize overflow had no dedicated visual contract.

Fix: add an IME guard with late-final-input recovery, wire autosize overflow state to scrollbar styling, and raise the creation composer height cap.

Verification: `pnpm --dir desktop/frontend test:composer`; `pnpm --dir desktop/frontend build`.

This is an adapted, focused replacement for the corresponding part of #9195.
2026-08-21 00:25:04 +08:00
SivanCola 107bbbf0ac fix(desktop): harden streaming and Mermaid rendering
Problem: streaming code fences could remain visually malformed, and Mermaid pan/zoom depended on a fragile SVG pan/zoom package.

Root cause: the streaming renderer did not establish a stable fence presentation early enough, while Mermaid interaction was coupled to the external SVG transform helper.

Fix: make streaming fence styling deterministic and replace the Mermaid interaction path with the local pointer-aware implementation.

Verification: focused Markdown/Mermaid tests; `pnpm --dir desktop/frontend build`.

This is an adapted, focused replacement for the corresponding part of #9195.
2026-08-21 00:25:03 +08:00
SivanCola 9bccbd1a41 fix(provider): surface web search result deltas
Problem: Anthropic web-search result delta events were received but not surfaced to the provider/frontend stream.

Root cause: the result-delta event variant was missing from the stream decoder and source projection.

Fix: decode, normalize, and expose web-search result deltas without changing ordinary text/reasoning streaming.

Verification: `go test ./internal/provider/anthropic`; `pnpm exec tsx src/__tests__/search-sources.test.ts`.

This is an adapted, focused replacement for the corresponding part of #9195.
2026-08-21 00:25:02 +08:00
SivanCola 58d7bf264d fix(bot/qq): negotiate gateway intents
Problem:
Using only PUBLIC_GUILD_MESSAGES lets ordinary bots connect but drops MESSAGE_CREATE events for bots authorized for private guild messages.

Root cause:
QQ rejects the entire Identify payload when it contains an unauthorized intent, while the adapter had no safe way to use one fixed guild-message profile for both permission sets.

Fix:
Try the private-guild profile first, fall back once on a pre-READY op=9 response, and remember the public profile for the adapter lifetime. Keep cancellation from starting the fallback connection and add focused profile, routing, and fallback tests.

Verification:
- go test ./internal/bot/qq/
- go test -race ./internal/bot/qq/
- go vet ./internal/bot/qq/
- go run ./tools/repolint
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-20 23:55:04 +08:00
SivanCola aee627a2d5 Merge pull request #9193 from ttmouse/fix/workspace-tree-icon-gap
fix(desktop): refine workspace preview panel layout / 优化工作区预览面板布局
2026-08-20 23:43:23 +08:00
ttmouse bebd89b800 fix(desktop): refine workspace preview panel layout
Three related workspace-preview cleanups:
- Remove the leftover 44px tree-toggle rail column after PR #8748 moved the
  FolderTree button into the preview header (grid column, width reservation,
  and dead rail CSS all dropped; split math no longer reserves the rail).
- Remove the redundant breadcrumb in the preview meta row: the path is already
  shown as crumbs under the file name, so the workspace-crumb group was
  duplicating it.
- Remove the now-empty preview meta row entirely (including the file-size
  label) and its CSS.

Tests updated to assert the two-column preview|tree layout, the rail-free
split widths, and the removed meta row.
2026-08-20 22:14:25 +08:00
SivanCola 540b4d5ad5 fix(test): satisfy integer range lint
Problem:
The PR lint job rejected a classic counter loop in the hard-cap continuation regression under Go 1.22 intrange rules.

Root cause:
The test used an index-only three-clause loop even though the loop bound is an integer constant.

Fix:
Use the equivalent integer range form while preserving the generated turn sequence and assertions.

Verification:
- go test ./internal/control
- go vet ./...
- go run ./tools/repolint
- golangci-lint run --timeout=5m ./...
2026-08-20 20:52:39 +08:00
SivanCola 1b9bcf6df3 fix(control): keep Standard tasks moving through stalls
Problem:
Standard write tasks could still stop after one text-only continuation or after a partial mutation followed by an explicit promise to continue, forcing repeated user prompts in issue #9172.

Root cause:
Task-progress continuation had a two-turn budget and stopped after the first unchanged receipt fingerprint. Standard readiness also treated any successful mutation without a current todo as complete, even when the final response deferred another implementation action.

Fix:
Use a 12-turn task-progress ceiling with a two-consecutive-stall limit that resets on novel host receipts. Add a narrow bilingual deferred-action control gap after mutation, preserve todo and safety authority, and expose the new recovery reason in desktop locales and documentation.

Verification:
go test ./internal/agent ./internal/control ./internal/evidence
go test -race ./internal/agent ./internal/control
go test ./...
go vet ./...
cd desktop && go test ./...
pnpm final-readiness locale, todo visibility, and typecheck checks
go run ./tools/repolint
2026-08-20 20:38:45 +08:00
SivanCola a8c41e2394 Merge pull request #9190 from SivanCola/docs/release-v1.31.0-final
Refresh v1.31.0 notes for the latest candidate / 为最新候选刷新 v1.31.0 发布说明
2026-08-20 20:27:40 +08:00
SivanCola c19905f581 Refresh v1.31.0 notes for the final candidate
Problem:
A user-visible transcript fix merged after the reviewed v1.31.0 release record, moving main-v2 and leaving the release notes incomplete and their immutable guide links stale.

Root cause:
The earlier record was reviewed before PR #9179 landed, while the same-version preparation workflow cannot refresh the record because it queries an unpushed local merge commit through the GitHub API.

Fix:
Document the composer-wrap jump-bottom fix in both languages and rebind both migration guide links to the current main-v2 revision without changing unrelated reviewed notes.

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- rendered and reviewed English and Chinese notes
- git diff --check
2026-08-20 20:25:07 +08:00
SivanCola 02708f7d85 Merge pull request #9179 from SivanCola/fix/composer-wrap-transcript-flash
fix(desktop): stop jump-bottom flash when the composer wraps a line / 输入框折行时不再闪「回到底部」
2026-08-20 20:14:30 +08:00
SivanCola 154445dd8e Merge pull request #9188 from SivanCola/docs/release-v1.31.0-refresh
Refresh v1.31.0 release candidate links / 刷新 v1.31.0 发布候选链接
2026-08-20 19:48:02 +08:00
SivanCola 4d44c734b9 Refresh v1.31.0 release candidate links
Problem:
The reviewed v1.31.0 release record still linked its migration guides to the pre-fix candidate after the desktop benchmark repair moved main-v2.

Root cause:
The guide URLs are immutable source links, so the earlier reviewed record remained bound to the old candidate revision.

Fix:
Rebind both migration guide URLs to the current main-v2 candidate without changing the reviewed user-visible release content.

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- rendered and reviewed English and Chinese notes
- git diff --check
2026-08-20 19:45:44 +08:00
SivanCola ec4b3f61e8 Merge pull request #9186 from SivanCola/fix/release-v1.31.0-transcript-copy-bench
test(desktop): sample copy viewport in place / 同位置采样复制视口
2026-08-20 19:42:01 +08:00
SivanCola 1b498e9ef0 test(desktop): sample copy viewport in place
Problem:
The exact v1.31.0 candidate failed the Linux desktop browser gate even though logical selection, copy resolution, overlay cleanup, and virtual-window bounds all passed.

Root cause:
The benchmark saved scrollTop before deliberately scrolling the selected rows out of view and back in. Virtuoso can remeasure that restored window, so the saved value was stale before copy cleanup began.

Fix:
Capture the control scrollTop immediately before the copy action, after the overlay has been restored, and compare cleanup against that same-position sample. Include both values in the assertion diagnostic.

Verification:
- transcript-selection.mjs: 10 consecutive real Chromium passes
- transcript-scroll-stability.mjs
- pnpm build
- go run ./tools/repolint
- git diff --check
2026-08-20 19:23:21 +08:00
SivanCola 1d28cab879 fix(control): yield readiness continuation to queued work
Problem:
User work can arrive while the host is announcing an automatic readiness continuation. The earlier preemption check has already passed, so the synthetic turn can start ahead of the newly queued work.

Root cause:
The continuation loop did not re-check cancellation, Steer, parked turns, or the durable inbox after notice delivery and recovery preparation.

Fix:
Re-check every host-owned preemption source immediately before the synthetic run and restore the one-shot recovery preparation when yielding. Add a deterministic regression that enqueues durable user work from the continuation notice boundary and proves no additional provider request starts. Also format the readiness comment that blocked CI.

Verification:
- go test ./internal/agent ./internal/control ./internal/evidence
- go test -race ./internal/agent ./internal/control
- go test ./...
- go vet ./...
- cd desktop && go test ./...
- go run ./tools/repolint
- frontend locale, Todo visibility, and typecheck checks
- cache and documentation impact guards
2026-08-20 18:59:16 +08:00
SivanCola 413dbe9452 Merge pull request #9183 from esengine/release-notes/v1.31.0
docs(release): 准备 v1.31.0 中英更新日志
2026-08-20 18:58:18 +08:00
SivanCola cd93913cbe docs(release): include latest v1.31.0 change
Problem:
The v1.31.0 draft was generated before PR #9142 merged into main-v2, leaving the latest composer improvement out of the reviewed release range.

Root cause:
The release-notes branch predated the newest main-v2 merge, and the existing-branch generator cannot refresh it because it queries an unpushed local merge commit through the GitHub API.

Fix:
Add the verified bilingual composer improvement to highlights and changes, and credit its contributor.

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- node scripts/release-notes.mjs render --version 1.31.0
- git diff --check
2026-08-20 18:37:34 +08:00
SivanCola 6b95ef2978 fix(desktop): integrate composer resize tail pin
Problem
The transcript tail fix was based before the composer auto-grow change and its new coverage pushed two files beyond repository size limits.

Root cause
Tail-follow waited for the coalesced frame after an in-flow composer resize, while viewport and content shrink state remained embedded in the already-full scroll arbiter and recovery test.

Fix
Merge the latest main-v2, keep tail-follow pinned synchronously across viewport shrink, extract the geometry decision, and move the focused hook regression into the smaller extent-race suite without widening lint baselines.

Verification
- pnpm test:transcript
- composer sizing, resized CSS, and run-strip suites
- pnpm build and pnpm test:typecheck
- pnpm test:transcript-browser
- go run ./tools/repolint
- git diff --check
2026-08-20 18:36:06 +08:00
SivanCola bf5b53b5b2 docs(release): correct v1.31.0 notes
Problem:
The generated v1.31.0 notes credited the GitHub Actions bot and summarized the issue-reporting update without listing its release entry.

Root cause:
The regenerated release catalog omitted merged PR #9151 from the improved changes and retained the automation author in contributors.

Fix:
Restore the bilingual issue-reporting entry and keep only human contributors.

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- git diff --check
2026-08-20 18:26:32 +08:00
SivanCola 546f4ee2cf fix(control): continue Standard write tasks to host progress
Problem:
Standard write tasks can stop after a prose-only response or with current-turn Todo items still incomplete, forcing users to repeatedly send continue.

Root cause:
Final readiness mixed observed quality gaps with automatic continuation policy, and Standard turns had no host-owned mutation expectation or bounded task-progress continuation class.

Fix:
Freeze the raw-input mutation expectation into the agent turn, project Standard readiness onto missing mutation and current-turn Todo gaps, and allow at most two synthetic continuations with receipt-deduplicated progress gating. Preserve full Delivery, Goal, and approved Plan closure rules, preemption behavior, recovery cards, cache-stable prefixes, and historical Todo display semantics.

Verification:
- go test ./internal/agent ./internal/control ./internal/evidence
- go test -race ./internal/agent ./internal/control
- go test ./...
- go vet ./...
- cd desktop && go test ./...
- pnpm test:todo-visibility
- pnpm typecheck
- scripts/check-cache-impact.sh
- scripts/check-docs-impact.sh
2026-08-20 18:26:09 +08:00
SivanCola e90b63bbd2 Merge pull request #9142 from wqshan-cn/agent/composer-content-auto-grow
fix(desktop): auto-grow resized composer for long drafts
2026-08-20 18:23:13 +08:00
github-actions[bot] 85cdf99128 docs(release): prepare v1.31.0 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-20 10:22:59 +00:00
SivanCola d619d3fc75 Merge pull request #9182 from SivanCola/fix/release-v1.31.0-transcript-bench
test(desktop): compare selection windows in place / 同位置校验选区虚拟窗口
2026-08-20 18:22:02 +08:00
SivanCola deca64bcb1 test(desktop): compare selection windows in place
Problem:
The Stable candidate failed the Linux Desktop browser gate twice because the selection benchmark compared mounted row counts from two scroll regions with different variable-height content.

Root cause:
React Virtuoso expands the viewport in pixels, so a normal virtual window can contain different row counts across the transcript. The tail sample therefore was not a valid baseline for the later selection viewport.

Fix:
Compare the logical-selection window with the normal window at the same scroll position after copy cleanup, for both reverse and forward gestures. Also assert cleanup preserves the viewport before using that control measurement.

Verification:
- transcript-selection.mjs: 10 consecutive real Chromium passes
- pnpm test:transcript-browser
- pnpm build
- go run ./tools/repolint
- git diff --check
2026-08-20 18:04:39 +08:00
wqshan-cnandSivanCola b7c0508dc0 fix(desktop): auto-grow resized composer drafts
Problem
A manually resized Composer treated the saved height as a hard cap, so long pasted or typed drafts could be clipped even when the viewport had room.

Root cause
Content measurement stopped when a manual height existed, and the resized CSS path kept the saved height fixed.

Fix
Treat the saved manual height as the minimum baseline. Measure plain and rich draft content in all modes, grow up to the viewport-aware logical maximum, and enable internal scrolling only after the cap. Keep run-strip reservation and live resize in logical-height space.

Verification
- Composer content sizing: 12 passed
- Resized input CSS contract: 7 passed
- Composer interaction: 64 passed
- Combined with PR #9179 transcript scroll/recovery tests: 35 + 91 passed
- TypeScript, ESLint, repolint, CSS checks, and single-scroll-writer check passed

Co-authored-by: SivanCola <[email protected]>
2026-08-20 18:03:00 +08:00
SivanCola 942ce85f02 Merge pull request #9176 from esengine/release-notes/v1.31.0
docs(release): 准备 v1.31.0 中英更新日志
2026-08-20 17:19:21 +08:00
github-actions[bot] 0c78c31c83 docs(release): prepare v1.31.0 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-20 09:15:59 +00:00
SivanCola cf01a93409 Merge pull request #8261 from ttmouse/pr/auto-panel-overall
feat(desktop): Automation workspace and reliable scheduling / 自动化工作区与可靠调度
2026-08-20 17:12:35 +08:00
SivanCola eeba3fe83e Merge latest main-v2 into heartbeat panel PR
Problem:
- main-v2 advanced to 3bc93c07a while this PR was under review, leaving semantic conflicts in the topic bar and workspace dock tests.
- The merged workspace migration test also needed a type-safe jsdom window assignment under the PR test configuration.

Root cause:
- Both branches changed Topicbar controls and WorkspacePanel lifecycle behavior independently.
- The jsdom Window type is not directly assignable to the browser global type used by the test compiler.

Fix:
- Keep the latest TopicbarMoreMenu, dock restoration, and keyed WorkspacePanel reset from main-v2 while preserving the Automation view boundary that suppresses the workspace dock.
- Update the topicbar assertions for the merged More menu behavior and use an explicit double assertion for the jsdom window fixture.

Verification:
- desktop Go tests and race tests passed.
- golangci-lint v2.12.2 passed with zero issues.
- production and test TypeScript checks passed.
- all 209 frontend test suites passed.
- the production frontend build and bundle budgets passed.
- repolint and git diff checks passed.

Co-authored-by: SivanCola <[email protected]>
2026-08-20 16:46:24 +08:00
SivanCola 9c94db3e67 fix(desktop): satisfy heartbeat lint gates
Problem:
The approved CI run failed desktop-macos because golangci-lint v2.12.2 reported nine Heartbeat issues, so the restricted workflow could not complete successfully.

Root cause:
The new parser used allocation-heavy string splitting and index-based cuts, one test retained a classic integer loop, and the sidecar rollback error was formatted without preserving its error chain.

Fix:
Use SplitSeq and Cut without changing cron semantics, adopt integer range syntax, and wrap both the config write and sidecar rollback errors.

Verification:
- cd desktop && golangci-lint run --timeout=5m
- cd desktop && go test . -run Test(Cron|MergeHeartbeatRunUpdatesCapsHistory|Heartbeat) -count=1
- cd desktop && go test ./... -count=1
- cd desktop && go test -race ./... -count=1
- go run ./tools/repolint
- git diff --check
2026-08-20 16:35:05 +08:00
SivanCola 3bc93c07a3 Merge pull request #8748 from ttmouse/pr/chat-panel-resize-min-400
feat(desktop): refine workspace dock sizing and state / 优化工作区面板宽度与状态管理
2026-08-20 16:23:06 +08:00
SivanCola 9b90e4bdaa fix(desktop): harden heartbeat execution and scheduling
Problem:
Older runtimes could ignore a future run-history sidecar, manual triggers could execute a stale in-memory task, and cron-heavy task lists repeatedly resolved next-run times during sorting and rendering.

Root cause:
The sidecar reader collapsed forward-schema errors into an empty history, TriggerNow trusted the cached task snapshot, and the frontend comparator recalculated the schedule for every comparison.

Fix:
Propagate unsupported sidecar schemas through config snapshots, revalidate manual executions from disk while holding the task lease, and precompute each task's next run once for sorting and display.

Verification:
- cd desktop && go test ./...
- cd desktop && go test -race ./...
- cd desktop && go vet ./...
- cd desktop/frontend && pnpm test
- cd desktop/frontend && pnpm build
- cd desktop/frontend && pnpm typecheck
- cd desktop/frontend && pnpm test:typecheck
- go run ./tools/repolint
- git diff --check
2026-08-20 16:21:48 +08:00
SivanCola 5b259ec487 Merge remote-tracking branch 'origin/main-v2' into fix/pr-8261-review 2026-08-20 16:08:58 +08:00
SivanCola 323a25a5b0 fix(desktop): split workspace panel lint owners
Problem:
PR #8748 exceeded the repository file-size and test-file-size budgets, so the lint workflow could not pass.

Root cause:
WorkspacePanel still owned tree-row and breadcrumb rendering, while one workspace DOM suite combined shared setup with an unrelated selection-isolation regression.

Fix:
Extract workspace tree rows and path breadcrumbs into focused components. Move the shared DOM setup into a test harness and register the selection-isolation regression as its own workspace suite.

Verification:
- go run ./tools/repolint
- pnpm test:workspace
- pnpm typecheck
- pnpm lint:hooks
- pnpm build
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-20 16:08:00 +08:00
SivanCola 9dc1d95b26 Merge pull request #9164 from SivanCola/fix/session-catalog-upgrade-repair
fix(sessioncatalog): repair incomplete sidebar projections / 自愈侧栏会话索引缺失
2026-08-20 15:47:30 +08:00
SivanCola 6aecbe4b89 fix(desktop): serialize heartbeat task execution
Problem:
- Concurrent desktop processes could execute the same due task, and every-minute cron tasks could be skipped after the first run.
- Concurrent frontend full-table mutations could lose updates, while the PR exceeded locale and repository capacity gates.

Root cause:
- The task lease ended before run state was durably persisted, due checks were not revalidated after lease handoff, and cron deduplication used only the previous run timestamp.
- Frontend mutations shared stale snapshots, and feature-only translations were bundled into every global locale chunk.

Fix:
- Hold the task lease through state persistence, re-read and revalidate after acquisition, and deduplicate cron runs by occurrence minute.
- Serialize frontend mutations against the latest authoritative task list and keep failed drafts visible.
- Split heartbeat owners and tests into focused files, keep feature translations in the lazy Heartbeat chunk, and restore the existing locale budgets.

Verification:
- cd desktop && go test ./... -count=1
- cd desktop && go test -race ./... -count=1
- pnpm test -- --keep-going (206 suites)
- pnpm build
- go run ./tools/repolint
- git diff --cached --check

Co-authored-by: SivanCola <[email protected]>
2026-08-20 15:17:04 +08:00
SivanCola 0a2840b292 fix(sessioncatalog): harden persisted projection repair
Problem:
The initial projection repair exceeded repository file-size and comment budgets, and its in-memory regression test did not cover a persisted catalog reopening after incomplete rows were stored.

Root cause:
The integrity query and regression test were added to files already at their capacity limits, while the test kept the same Catalog instance alive across each simulated projection loss.

Fix:
Move the ready-directory integrity check and regression coverage into focused files. Gate all indexed integrity scans on the matching ready signature, read the invariants from one SQLite snapshot, and reopen the disk-backed catalog before repairing missing session and topic rows.

Verification:
- go run ./tools/repolint
- go test ./internal/sessioncatalog/ -count=1
- go test -race ./internal/sessioncatalog/ -count=1
- go vet ./...
- go test ./... -count=1
- cd desktop && go test ./... -run 'Test(ListProjectTopics|ProjectTreeSnapshot|UpgradeLegacy|SessionCatalog|ReconcileRepairsPersistedReadyDirectory).*' -count=1
2026-08-20 14:56:42 +08:00
SivanCola 970647c5d4 fix(desktop): align right-side workspace resizing
Problem:
The right-side workspace tree separator moved opposite to its arrow-key direction, and the rebased pointer path used left-side coordinates. The PR also retained unrelated .zkge artifacts.

Root cause:
The tree moved to the right without updating the keyboard delta, while a later rebase dropped the treeOnRight pointer option and did not reserve the rail in the right-side clamp.

Fix:
Reverse the horizontal keyboard delta, restore right-side pointer geometry, preserve rail space at the maximum width, add focused DOM and pure-function coverage, and remove the unrelated .zkge tree.

Verification:
- pnpm test:workspace
- workspace split, scroll restore, More menu, and topicbar focused suites
- pnpm build
- REASONIX_CHANNEL=test pnpm build
- system Chrome keyboard and pointer interaction

Co-authored-by: SivanCola <[email protected]>
2026-08-20 14:52:21 +08:00
SivanCola d17d7aca23 fix(sessioncatalog): repair incomplete ready projections
Problem:
After an upgrade, the sidebar can omit existing conversations even though their session files remain on disk.

Root cause:
The directory reconcile fast path trusted an unchanged signature and ready marker without checking that the disposable session and topic projections were still complete.

Fix:
Compare the completed scan total with live catalog rows and verify that every indexed session has a topic projection before skipping reconciliation. Rebuild from disk when either invariant is broken.

Verification:
- go test ./internal/sessioncatalog/ -count=1
- go vet ./internal/sessioncatalog/
- go test -race ./internal/sessioncatalog/ -run TestReconcileRepairsReadyDirectoryMissingCatalogRows -count=1
- cd desktop && go test ./... -run Test\(ListProjectTopics\|ProjectTreeSnapshot\|UpgradeLegacy\|SessionCatalog\|ReconcileRepairsReadyDirectory\).* -count=1
2026-08-20 14:33:52 +08:00
SivanCola 537f2ed6f1 fix(desktop): harden workspace overflow interactions
Problem:
- The latest-base merge exceeded the initial JavaScript gzip and raw bundle gates.
- The topic-bar More menu did not move or restore keyboard focus.
- The workspace scroll test bypassed the DOM restoration path.

Root cause:
- Overflow actions and their CSS shipped in the initial app shell.
- The menu only handled pointer activation and Escape dismissal.
- The jsdom fixture had no scroll extent or scrollTo implementation.

Fix:
- Lazy-load the overflow actions and their component-scoped CSS.
- Implement roving focus, submenu navigation, and focus restoration.
- Exercise persisted deep-scroll restoration against a scrollable DOM fixture.
- Ratchet only raw startup capacity by 9.0 KiB (0.38%); retain gzip and chunk gates.

Verification:
- pnpm test:workspace
- Topicbar menu, tree scroll restore, topicbar controls, and workspace layout: 76 assertions passed.
- pnpm typecheck and focused ESLint passed.
- Latest main-v2 synthetic merge production build and test-channel bundle gate passed.
- Chromium keyboard navigation, lazy loading, focus restoration, and menu styling passed.

Co-authored-by: SivanCola <[email protected]>
2026-08-20 14:19:36 +08:00
c7b4267529 fix(desktop): make heartbeat history crash-consistent
Problem:
- A process or power loss after replacing heartbeat-tasks.runs.json but before heartbeat-tasks.json could pair old task definitions with new history.
- Calendar schedule work adopted from #8662 lacked commit-level attribution.

Root cause:
- The sidecar did not identify its config generation, so rollback only covered write errors returned to the live process.
- The integration commit referenced the source PR without a co-author trailer.

Fix:
- Store current and previous run-history generations keyed by config revision while retaining top-level runs for previous readers.
- Select the generation matching the committed config, reject future sidecar schemas, and cover restart, deletion, and task ID reuse.
- Credit the verified public author of #8662.

Verification:
- cd desktop && go test ./... -count=1
- cd desktop && go test -race ./...
- focused crash test passed 20 consecutive runs
- git diff --check

Co-authored-by: ttmouse <[email protected]>
Co-authored-by: glyahh <[email protected]>
2026-08-20 13:38:54 +08:00
ttmouse 876c080f7a fix(desktop): keep workbench dock-toggle clear of Windows caption buttons
Workbench keeps its workspace toggle in the fixed top-right dock-toggle
instead of an AppChrome rail. On Windows frameless the toggle sat at
right:12px under the min/max/close strip (z-index 100 covers it), making
the collapse button unreachable. Shift it left of the caption strip with
the same windows-window-controls-safe inset the classic-mode panel toggle
uses.
2026-08-20 13:01:58 +08:00
SivanCola 1355d81122 fix(desktop): stop jump-bottom flash when the composer wraps a line
Problem
Wrapping the composer to a second line (or deleting back to one)
flashed the transcript bottom. The in-flow footer grew, the
transcript viewport shrank, and jump-bottom appeared for a frame.

Root cause
Tail-follow waited a rAF to reconverge after footer-driven viewport
shrink, and published the transient off-bottom gap as isAtBottom=false.

Fix
Pin the native tail immediately when the transcript viewport shrinks
under tail-follow, and keep isAtBottom true while tail-follow owns
the viewport so jump-bottom stays a manual-reading control.

Verification
tsx src/__tests__/transcript-scroll-release.test.ts
tsx src/__tests__/transcript-recovery-race.test.tsx
node scripts/check-single-scroll-writer.mjs
2026-08-20 12:49:46 +08:00
ttmouse 0ed88af232 fix(workspace): resolve rebase conflicts and adapt tests to upstream changes
- workspace-layout.test.ts: drop duplicated fragment left over from the
  earlier conflict resolution (was a syntax error)
- styles.css: move .code max-height comment outside the rule block so the
  preview CSS contract test still matches margin: 0
- workspace-tree-scroll-restore.test.tsx: call rememberWorkspaceTreeScroll
  directly (jsdom cannot dispatch trusted scroll events; the hook only
  records user-initiated scrolling)
- app-chrome-tabs.test.ts: follow upstream App.tsx which now passes
  hydrating={runtimeTransitioning || transcriptHydrating}
2026-08-20 12:29:57 +08:00
ttmouse b5e008fddd fix(workspace): 滚动持久化改用上游 hook,忽略脚本重置防回顶
- 文件树滚动持久化从内联 scroll handler 改为上游 #8841 的
  useWorkspaceTreeScrollPersistence:scroll 只更新内存快照,
  scrollend/pagehide/visibility-hidden 才落盘 localStorage,避免
  连续滚动时同步写存储卡顿
- hook 的 onScroll 只持久化用户真实滚动(isTrusted),忽略树重建时
  脚本把容器重置为 scrollTop 0 的事件,防止 0 覆盖已保存的滚动位置
  导致切换 Tab 后恢复回顶
2026-08-20 12:27:28 +08:00
ttmouse 80334d5838 refactor(workspace): 预览状态改为 keyedResource,与上游一致
- preview/loadingPreview 简单 state → previewResource(emptyKeyedResource +
  begin/resolve/rejectKeyedResourceRequest)
- 派生 previewKey/preview/loadingPreview/previewErr(key 含文件路径)
- 切换文件时 previewKey 变化 → preview 立即置空 → CodeViewer 卸载重挂,
  从根源避免虚拟滚动残留(height/transform/堆叠)
- 错误处理/渲染分支/codePreviewActive 改用 previewErr
2026-08-20 12:27:11 +08:00
ttmouse e75eab9317 fix(desktop): 修复文件预览切换时的行间距/堆叠/限高问题
- WorkspacePanel: 预览改 SWR 模式(loading 时保留已有 preview 并叠加状态条),
  避免切换文件时 CodeViewer 卸载重挂导致的布局抖动
- codePreviewLayoutActive: loading 期间预览 body 保持 flex 布局
- LineNumberCode: 清除虚拟滚动直接写入 DOM 的残留(容器 height、行 transform、
  scrollTop),非虚拟行 transform:none 兜底;虚拟行改用 React 渲染 transform
  兜底,避免双向切换时行堆叠/行间空隙
- styles.css: .code 加 max-height:none,大文件预览填满整个区域
2026-08-20 12:26:44 +08:00
ttmouse a986e31378 fix(desktop): restore terminal prefetch in More menu + WebKit layout fixes
After rebasing onto latest main-v2:

- TopicbarMoreMenu: add prefetchTerminal prop; the terminal menu item
  prefetches on pointer/focus so opening stays fast (matches the upstream
  prefetch contract moved into the folded menu)
- App.tsx: drop unused useMountTransition import; keep prefetchTerminalPanel
  wired to the More menu
- workspace-layout test: assert prefetch on the More-menu item instead of
  the removed flat toolbar button (C2 architecture)
- styles.css: pin -webkit-text-size-adjust + overflow-wrap to stop WebKit
  text inflation / code-line wrapping during the preview width transition

27/27 verify checks green; workspace-layout 53/53.
2026-08-20 12:25:44 +08:00
ttmouse 7de4a9c575 chore(desktop): bump x/image/x/text to upstream versions
Follow upstream's dependency upgrades (x/image v0.45, x/text v0.41) from
the display-stability and sandbox-grant commits. Verified with go build.

Also commit the pre-rebase verification baseline snapshot.
2026-08-20 12:25:44 +08:00
ttmouse fba72a4169 test(desktop): add regression tests for scroll reachability + legacy key migration
From upstream review findings, turn the two behavior fixes into tests that
run in verify-restore.sh:

1. workspace-tree-scroll-restore: source-level guards that the reachability
   check uses totalSize - viewportHeight (not plain totalSize), reads the
   virtualizer scrollRect, and retries once the viewport settles — catches a
   future rebase reverting to the buggy check.

2. workspace-panel-open-migration (new): jsdom behavior test that a missing
   per-project dock-open key seeds from the legacy global key (closed stays
   closed, open stays open), project key wins, default open when absent.

27/27 verify checks green.
2026-08-20 12:25:44 +08:00
ttmouse 3e428233e0 fix(desktop): use virtualizer scrollRect for scroll-restore reachability
The reachability check read treeRef.clientHeight at restore time, which can
be 0 right after a tab switch while the tree is settling, making the check
think the saved offset is unreachable and leaving the pending restore stuck
— the list stays at the top. Use the virtualizer's own scrollRect.height
(updated by the virtualizer as the viewport settles) and add it to the
effect deps so the restore retries once the viewport height is known.

26/26 verify checks green; scroll-restore 5/5.
2026-08-20 12:25:44 +08:00
ttmouse 09c1e1f4c3 fix(desktop): scroll-restore reachability + legacy dock-open migration
Address two upstream review findings:

1. Scroll restore reachability: a saved offset is only reachable once
   totalSize - viewportHeight >= target (max scroll position = totalSize -
   viewport), not totalSize >= target. The old check could clear the
   pending restore right after content barely exceeded the offset but
   before providing enough scrollable space, losing deep-scroll restores.

2. Legacy dock-open key migration: when a project has no per-project
   workspacePanelOpen preference yet, seed it from the legacy single
   global key (reasonix.workspacePanel.open) so an upgrade does not flip
   a user's existing choice (e.g. dock was closed, stays closed on first
   open of any project).

Kept the branch's no-560-ceiling decision (dock width = available width);
reviewer's min(560, available) not adopted per user preference.

26/26 verify checks green; scroll-restore 5/5; app-chrome-tabs 128/128.
2026-08-20 12:25:30 +08:00
ttmouse 6640e96a8a refactor(desktop): unify panel persistence onto upstream workspaceTreeMemory
Per user decision: upstream's workspaceTreeMemory envelope covers all of
C14-C18 semantics, so replace the branch's 4 localStorage keys +
workspacePanelSession with it, keeping the branch's C17 recentPaths wiring
(upstream stores the field but does not consume it).

- WorkspacePanel: init treeWidth/selectedFilePath/recentPaths from
  readWorkspaceTreeMemory; persist via rememberWorkspaceTreeState;
  scroll via rememberWorkspaceTreeScroll; drop the old 4-key helpers
- scroll-restore test: assert reasonix.workspaceState.v2 scrollTop
- verify-restore.sh: C15-C18 probes check workspaceTreeMemory

26/26 verify checks green; tsc 0 errors; tree-memory 15/15.
2026-08-20 12:25:30 +08:00
ttmouse a9cfb6bbe5 fix(desktop): dock width is user-adjustable + remembered, no fixed 560 ceiling
Remove the upstream restoreWorkspaceDockWidths dual-width logic: the tree
width is clamped to the dynamic available width (chat keeps its 400px
floor) instead of the fixed 560 RIGHT_DOCK_TREE_MAX_WIDTH, so a user's
remembered dock width is preserved when reopened and can exceed 560 when
the viewport allows. Preview-width restore is dropped (single-width dock).

C1/C3 probes green; layout 44/44, changes-errors 58/58.
2026-08-20 12:24:09 +08:00
ttmouse dbe2a3b241 refactor(desktop): restore branch-local WorkspacePanel, keep upstream App features
Per user decision: keep upstream's token economy + delivery worktree
(App.tsx stays upstream), restore the branch's own WorkspacePanel/styles/
persistence (workspacePanelSession + WORKSPACE_*_KEY) to eliminate rebase
hybrid deviations.

- WorkspacePanel: branch version + 3 optional props (dockTreeWidth/
  dockPreviewWidth/onRestoreDockWidths) to stay compatible with upstream
  App.tsx; restores dock widths on mount
- scroll-restore test: back to branch key (workspacePanel:treeScroll)
- verify-restore.sh: C15-C18 probes check branch persistence; drop
  upstream-only swr-contract test

26 checks green, 18/18 adjustments restored; tsc 0 errors; go build ok.
2026-08-20 12:24:09 +08:00
SivanCola 06924f84b7 Merge latest main-v2 metadata updates into PR #8261
Problem:
- main-v2 advanced once more before the PR update was published.

Root cause:
- The base merged issue-form, label-workflow, and migration-documentation updates.

Fix:
- Integrate the current main-v2 head without rewriting the contributor branch history.

Verification:
- Merge completed without conflicts.
- git diff --check passes.
- The base delta touches only GitHub metadata and migration docs, so the already-passed desktop runtime and frontend gates remain applicable.
2026-08-20 12:23:59 +08:00
SivanCola d9cb23997f Merge latest main-v2 into PR #8261
Problem:
- main-v2 advanced again while PR #8261 was under review.

Root cause:
- The base accepted project-tree pinned-heading changes after the first conflict-resolution merge.

Fix:
- Integrate the new main-v2 commits so the PR is based on the current remote branch without rewriting contributor history.

Verification:
- cd desktop && go test ./... -count=1
- cd desktop && go test -race ./...
- pnpm lint:hooks, pnpm typecheck, pnpm test:typecheck, and pnpm build
- Heartbeat editor 18/18, schedule 59/59, runner contract 3/3, app chrome 129/129, and pinned-heading focused tests
- Browser automation workflow and console verification
2026-08-20 12:23:02 +08:00
ttmouse 41f67cec07 fix(desktop): move tree-toggle button out of its own rail column (C10)
Reverts the rebase-introduced layout where the show/hide file-tree button
sat alone in a workspace-tree-rail column. Restore the branch design: the
FolderTree button lives inside the preview window-actions group (after
maximize, before close), shown when previewVisible && !changedMode — it
no longer occupies its own column.

All 27 verify checks green; workspace tests 58/34/44 pass.
2026-08-20 12:21:21 +08:00
ttmouse c6fe0f2771 test(desktop): add C1/C3 behavior assertions; fix C4 scope-switch leak; port C17 recentPaths
- workspace-layout: explicit C1 assertions (dock never consumes the chat
  400px floor) and C3 assertions (single tree width, no detail ternary)
- workspace-panel: fix C4 scope-switch request leak via scopeSwitchPendingRef
  while preserving cross-scope selection memory (swr contract stays green)
- workspace-panel: port C17 independent recentPaths into workspaceTreeMemory
- workspace-preview-css: adapt to branch layout (tree on right, per user
  preference) instead of upstream's tree-on-left
- verify-restore.sh: run 9 test files (was 4)
2026-08-20 12:21:21 +08:00
ttmouse a14dd3bf1c fix(desktop): port local adjustments onto upstream workspaceTreeMemory
After rebasing onto upstream 4f1cae106, restore the branch's local
behaviors that the upstream persistence refactor had replaced, verified by
.zkge claims:

- C1: dock width ceiling = workspacePanelAvailableWidth (chat >= 400),
  drop fixed 560 RIGHT_DOCK_TREE_MAX_WIDTH ceiling (user request)
- C4: scope switch must not request the previous scope's selected change
  (selectedChangePathInScope guard + scope-switch clear + loadChangeDetail guard)
- C5: pending scroll restore reads workspaceTreeMemory scrollTop
- C12: savedTreeWidth = treeWidthMode manual ? treeWidth : null
  (user-resized tree width survives reopen; upstream had null = even split)
- C17: independent recentPaths persisted in workspaceTreeMemory snapshot
- tests: scroll-restore asserts v2 envelope; changes-errors 58/58 green

verify-restore.sh: 22 checks green (18/18 adjustments restored).
2026-08-20 12:21:21 +08:00
ttmouse e67af2fa94 docs(zkge): add claim-driven rebase guide (4 phases + conflict arbitration)
Answers how to rebase without losing the 18 local adjustments: baseline
verify before, per-conflict arbitration via C# oracle, full verify + build
after, human acceptance last. Includes per-file arbitration table and the
lessons from the previous rebase where local persistence was overwritten.
2026-08-20 12:21:21 +08:00
ttmouse 58e9c1cc57 docs(zkge): add C14-C18 fine-grained persistence claims
Capture the branch's persistence/memory behaviors that rebasing onto
upstream's workspaceTreeMemory silently replaced, so each survives future
rebases as an individually verifiable item:

- C14: dock open/closed state remembered per project root
- C15: dock width + file-tree width persisted separately
- C16: opened file (preview) remembered & cleared on explicit close
- C17: recent-files list persisted independently of live preview tabs
- C18: file-tree scroll position persisted

All are hard gates except C18. verify-restore.sh now checks 18
adjustments (22 checks).
2026-08-20 12:21:21 +08:00
ttmouse 9a748d67c9 docs(zkge): add C12 tree-width persistence + C13 panel internal borders
C12 (CLAIM.TREEWIDTH.012, hard gate): user-resized file-tree width is
preserved when reopening a file after closing its preview (savedTreeWidth
only from manual mode), first split defaults to even, tree auto-hides when
panel is too narrow - the list/detail layout relationship the user flagged
as broken after rebase.

C13 (CLAIM.PANELBORDER.013): split-preview preview border dedup + tree
resizer hairline.

verify-restore.sh now checks 13 adjustments (17 checks).
2026-08-20 12:21:21 +08:00
ttmouse af28696235 docs(zkge): add C11 changes-panel row/hover/revert/badge styles
Split the changes-panel styling out of C6 into its own verifiable item:
workspace-change-row structure, row hover spanning file + revert button,
revert revealed on hover/focus in the badge slot, --active persistent
highlight, icon/padding/badge styles (d8d42210f, 48 style lines).

verify-restore.sh now checks 11 adjustments (15 checks total).
2026-08-20 12:21:21 +08:00
ttmouse f392748041 docs(zkge): add C10 file-tree icon position; harden C3 tab-switch width
- C10 (CLAIM.TREEICON.010): file-tree toggle button position (FolderTree
  in the preview action group) + workspace-tree__icon row styles + active
  highlight, from ee6591b25/d8d42210f
- C3 (CLAIM.DOCK.003): strengthen oracle so 'switching tabs must never
  resize the sidebar' is explicitly asserted (single-width dock, no
  preview-width dual system)
- verify-restore.sh now checks 10 adjustments (14 checks total)
2026-08-20 12:21:21 +08:00
ttmouse fb8a28032b docs(zkge): add restore-verification suite for chat-panel-resize adjustments
Capture the branch's 9 local adjustments as individually verifiable
capability questions + claims + score card, so future rebases/merges can
confirm each one survived instead of silently losing them (as happened
rebasing onto upstream workspaceTreeMemory).

- .zkge/capability-questions.md: business-layer capability list (C1-C9)
- .zkge/claims/: 9 technical claims with oracle/counterexample/grep probe
- .zkge/architecture/score-card.yaml: CQ -> test mapping + hard gate
- .zkge/verify-restore.sh: one-command restore check (13 checks, exit 0)

Baseline verified on 8358e0ebb: 9/9 adjustments present, 13/13 checks pass.
2026-08-20 12:21:21 +08:00
ttmouse 287c958d4a fix(desktop): refine workspace panel — view-switch scroll restore, breadcrumb paths, sidebar rails, split-preview border
- WorkspacePanel: reset pending scroll restore on viewMode change (files<->changed) so a saved offset is not re-applied to a shorter list, which left a blank band at the top
- WorkspacePanel: show workspace root breadcrumb in changed overview and file preview; first crumb shows only the project name, hovering any crumb reveals the full absolute path
- styles.css: unify left/right sidebar resize rails (1px hairline, accent on hover), drop duplicated sidebar/split-preview borders, slate sidebar background to warm white
- test: cover files<->changed view round trip in scroll-restore suite
2026-08-20 12:21:20 +08:00
ttmouse ffb441676a fix(desktop): sidebar slate background to warm white; raise raw bundle budget for workspace panel styling 2026-08-20 12:21:20 +08:00
ttmouse 663961546f fix(desktop): refine workspace change row hover, revert overlay, and status badge styling 2026-08-20 12:21:08 +08:00
ttmouse 829252c184 fix(desktop): restore tree scroll when total height equals offset; add scroll-restore contract test
- Change getTotalSize() <= target to < target so an exact-height tree
  actually restores instead of staying pending at the top.
- Add CLAIM.TREE.008 contract test (zk-ge): scroll persists to
  localStorage, survives unmount/remount (dock tab switch), and tree rows
  re-render. DOM restore itself depends on the virtualizer's real layout
  measurement, so the visual restore stays a manual browser acceptance.
2026-08-20 12:21:08 +08:00
ttmouse 6bbf077316 fix(desktop): isolate workspace file and change selections 2026-08-20 12:21:08 +08:00
ttmouse b7f36a2b53 fix(desktop): address Codex review feedback on workspace panel
- Preserve saved tree scroll offset on remount (don't persist scrollTop 0
  before restoration runs).
- Drop the persisted selectedPath when the user closes all previews so a
  restart does not reopen a closed file; keep the session cache for the
  changed view.
- Clear the Files selection when entering Changes so a preview path is not
  mistaken for a selected change; restore it from the session cache on
  return.
- Copy All now uses writeClipboardText (Clipboard API -> Wails bridge ->
  execCommand fallbacks) instead of navigator.clipboard alone.
- Make collapsible prompt-shelf cards keyboard focusable (tabIndex 0).
- Fix dock width clamps so a narrow viewport maxWidth actually caps the
  stored width instead of being raised back to the fixed maximum.
2026-08-20 12:21:08 +08:00
ttmouse d81eaffbd7 refactor(desktop): unify dock width, persist per-project dock state, and refine workspace UI
- Keep one dock width (tree width) across tab switches; remove the
  preview-width dual system so toggling tabs never resizes the sidebar.
- Persist the right dock open/closed state per workspace root and restore
  it when switching projects; preview documents no longer leak across
  projects.
- Preserve the user-resized file-tree width when reopening a file after
  closing its preview (was reset to the even split).
- Move the workspace-panel toggle to a fixed top-right position in
  workbench mode; classic uses the AppChrome toggle, creation keeps it in
  the topic bar.
- Remove the workspace-toggle press animation (scale/transform) and give
  the dock tabs a rounded-rect selected state with a soft grey
  background that adapts to light/dark themes.
- Extend the dock's vertical resizer to the terminal drawer, fly the
  export submenu left so it never clips at the screen edge, and keep
  per-project workspace memory in a session cache.
2026-08-20 12:21:08 +08:00
ttmouse a741bf0a57 feat(desktop): collapse topic bar actions into a More menu
Keep only three topic-bar entries — file opener, workspace-panel toggle,
and a new More (⋮) menu — and move copy/export/changes/terminal/session
summary into that menu so the bar stays compact when the chat pane is
squeezed to its 400px minimum. The workspace-panel toggle now renders in
every layout (it was previously gated to creation/workbench chrome).
2026-08-20 12:21:08 +08:00
ttmouse 1c3daf3940 fix(desktop): allow chat pane to shrink to 400px minimum
Remove the 560px comfort-min width applied while the workspace dock is
open and not maximized, so the chat pane can be dragged down to the
same 400px floor used in other states. CSS fallbacks for --chat-min-width
are aligned to 400px.
2026-08-20 12:20:56 +08:00
SivanColaandttmouse a4a21fcd72 fix(desktop): harden heartbeat state ownership
Problem:
- Concurrent automation edits could roll back engine-owned run state, failed saves could dismiss drafts, and several cron/calendar edge cases scheduled at the wrong time.
- Recommended automations were created too eagerly, and tab activation could leave the automation overlay visible.

Root cause:
- Full frontend snapshots were treated as authoritative across async execution and persistence boundaries.
- Cron steps used zero-based arithmetic for one-based fields, next-run search was too short, and biweekly parity used elapsed hours across DST.
- Sidecar and primary config writes did not expose a failure-atomic commit order, while editor conversion and save failures lacked explicit state.

Fix:
- Preserve TopicID, LastRunAt, and run history across stale saves; clear empty sidecars and roll them back if the primary config write fails.
- Keep failed and recommended drafts open with safe defaults, actionable errors, editable scope, and lossless frequency conversion.
- Correct cron step anchors, long-horizon leap-day search, civil-date biweekly parity, and direct tab navigation back to chat.
- Add focused backend, frontend, runner-contract, and browser-mock coverage.

Verification:
- cd desktop && go test ./... -count=1
- cd desktop && go test -race ./...
- Heartbeat editor: 18 passed
- Heartbeat schedule: 59 passed
- Heartbeat runner contract: 3 passed
- App chrome tabs: 129 passed
- pnpm lint:hooks, pnpm typecheck, pnpm test:typecheck, and pnpm build

Co-authored-by: ttmouse <[email protected]>
2026-08-20 12:18:04 +08:00
SivanColaandttmouse 4553294004 Merge main-v2 into PR #8261
Problem:
- PR #8261 had diverged from main-v2 and could not be reviewed or merged on the current desktop code.

Root cause:
- Both branches evolved shared desktop and frontend integration points after the feature branch was created.

Fix:
- Merge main-v2 and resolve the overlapping desktop contracts while retaining the heartbeat feature behavior.

Verification:
- cd desktop && go test ./... -count=1
- cd desktop && go test -race ./...
- frontend Heartbeat and app chrome focused suites
- pnpm build

Co-authored-by: ttmouse <[email protected]>
2026-08-20 12:17:43 +08:00
SivanCola 37e364152c Merge pull request #9151 from SivanCola/chore/issue-form-v3-studio
ci: add v3 Studio version line and drop v1 from issue forms / 为 Issue 表单新增 v3 Studio 并去掉 v1 选项
2026-08-20 12:15:42 +08:00
SivanCola 9badd6969a Merge pull request #5899 from ttmouse/pr/pinned-section-optimization
feat(desktop): refine the pinned section header / 优化左侧菜单置顶区域展示
2026-08-20 12:05:24 +08:00
SivanCola 0f6e675d86 ci: drop the v1 option from issue version-line forms
Problem
Bug report and Feature request still offered a v1 (legacy TypeScript)
Version line choice after v3 Studio was added.

Root cause
The templates and issue version-label workflow still treated v1/0.x as
a filing line.

Fix
Remove the v1 dropdown option, label only v2/v3 from new reports, and
stop mapping Exact version 0.x onto v1.

Verification
Parsed the issue-form YAML and confirmed both dropdowns list only v2
and v3. Inspected the v2/v3 match and 1.x/2.x Exact version mapping.
2026-08-20 11:54:27 +08:00
SivanCola aa3f0002d8 ci: add v3 Studio version line for the studio branch
Problem
Bug report and Feature request forms only offered v1 and v2, so Studio
(2.x) issues could not be filed or labeled as their own line.

Root cause
The issue templates and version-label workflows were hardcoded to v1/v2,
and any Exact version that was not 0.x was mapped to v2.

Fix
Add a v3 Studio option that names the studio branch, label matching
issues as v3, map 2.x Exact version to v3, and label PRs based on
studio as v3.

Verification
Parsed the issue-form and workflow YAML, and inspected the v1/v2/v3
mappings in both version-label workflows.
2026-08-20 11:48:56 +08:00
SivanCola d633652aa0 chore(desktop): account for pinned heading bundle size
Problem:
The pinned heading's dedicated markup and CSS move the stable and test raw startup bundles 0.4 KiB past their existing gates.

Root cause:
The current-base budgets retain only 0.1 KiB of headroom, while the scoped non-interactive heading recipe adds 0.5 KiB raw in both channels.

Fix:
Raise only the stable and test raw initial-asset budgets by 0.5 KiB (0.021%), preserving 0.1 KiB of headroom without widening gzip or chunk limits.

Verification:
- Stable build: 2344.1 / 2344.2 KiB
- Test-channel budget: 2349.3 / 2349.4 KiB
- 88 project-tree tests
- repolint and git diff --check
2026-08-20 11:32:28 +08:00
SivanCola b31ce49535 Merge main-v2 into pinned section optimization
Problem:
The stale PR base predates the trusted documentation-impact checker, so pull_request_target checked out a base commit without scripts/check-docs-impact.sh.

Root cause:
The branch had not incorporated 2,242 main-v2 commits since its original merge base.

Fix:
Merge the latest main-v2 without rewriting contributor history, allowing metadata guards and CI to run against the current repository contract.

Verification:
- Clean merge-tree against origin/main-v2
- Focused pinned-heading contract test
- 88 project-tree tests
- ESLint, TypeScript, test typecheck, CSS checks, and git diff --check
2026-08-20 11:17:28 +08:00
SivanCola 88218968a3 fix(desktop): keep pinned heading non-interactive
Problem:
The pinned section heading reused interactive folder classes, so hovering a static label painted a folder hover background and implied it could be clicked.

Root cause:
The visual alignment borrowed project-tree__folder and project-tree__folder-main, which also own folder hover behavior.

Fix:
Use a dedicated section-title modifier for the pinned heading, preserve its Pin icon and folder-aligned dimensions, mark the icon decorative, and add source contract coverage.

Verification:
- pnpm exec tsx src/__tests__/project-tree-pinned-header.test.ts
- pnpm exec tsx src/__tests__/project-tree-runtime.test.ts
- pnpm check:css
- pnpm typecheck
- pnpm test:typecheck
- Browser computed-style check for default and hover states
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-20 11:14:04 +08:00
SivanCola 0d5100ff3a Merge pull request #9146 from esengine/release-notes/v1.30.0
docs(release): 准备 v1.30.0 中英更新日志
2026-08-20 10:15:32 +08:00
SivanCola 7a499d3c04 docs(release): keep v1.30.0 acknowledgments human
Problem:
Regenerating the existing Notes PR made its automation-authored commit appear as a v1.30.0 contributor.

Fix:
Remove only github-actions[bot] from the new release record while preserving every human contributor and cited product change.

Verification:
Validated the full catalog, release-note contracts, rendered v1.30.0 notes, and diff whitespace.
2026-08-20 09:34:20 +08:00
github-actions[bot] e966ce72ed docs(release): prepare v1.30.0 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-20 01:32:30 +00:00
SivanCola 771cbfe7cc chore(release): refresh v1.30.0 baseline
Problem:
The reviewed v1.30.0 draft predates the Windows release-gate test fix merged into main-v2.

Fix:
Merge the current protected main-v2 baseline so Prepare release can regenerate and validate the candidate-bound Stable record.

Verification:
The branch and main-v2 remote SHAs were re-read immediately before this merge.
2026-08-20 09:31:05 +08:00
SivanCola 7484917322 Merge pull request #9147 from SivanCola/fix/release-v1.30.0-windows-tests
test: make write-scope tests portable / 使写入范围测试跨平台
2026-08-20 09:28:19 +08:00
SivanCola 4b34d507fc test: make write-scope tests portable
Problem:
Windows CI fails deterministic write-scope and delivery tests because fixtures use Unix-only absolute paths and compare normalized paths case-sensitively.

Root cause:
filepath treats /tmp and /home as relative paths on Windows, while Receipt normalization lowercases Windows paths.

Fix:
Build scratch and outside fixtures from os.TempDir and the platform volume root, JSON-encode Windows paths, and compare the normalized Windows expectation.

Verification:
Linux package tests pass for internal/evidence, internal/completion, and internal/taskcontract. Windows amd64 test binaries compile successfully for all three packages.
2026-08-20 08:55:44 +08:00
github-actions[bot] b9ef378def docs(release): prepare v1.30.0 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-20 00:41:04 +00:00
SivanCola 0cdbbbb0ec Merge pull request #9137 from SivanCola/feature/narrow-write-conflicts
feat: narrow write conflicts to realized paths and nested git repos / 写入冲突按落盘路径与嵌套仓库判定
2026-08-20 08:37:46 +08:00
SivanCola 1242bd5268 Fix fleet workspace lease scope
Problem:
Foreground fleet calls were classified as direct workspace mutations, so they held an exclusive workspace lease for the full fleet lifetime and blocked unrelated writers.

Root cause:
The non-mutation meta-tool registry omitted fleet even though its child tasks own and report the actual workspace mutations.

Fix:
Classify fleet as a non-mutating meta tool and add effect/runtime regressions proving no outer lease is held. Writable tool hooks still retain the existing whole-workspace lease.

Verification:
- go test ./internal/agent -run TestFleetMetaToolDoesNotTakeOuterWorkspaceLease -count=50
- go test -race ./internal/agent ./internal/evidence ./internal/filelock ./internal/workspacelease -count=1
- go test ./... -p 1 -count=1 -timeout 600s
- cd desktop && go test ./... -count=1 -timeout 900s
- go vet ./...
- make lint
- bash scripts/cache-guard.sh
- pnpm --dir desktop/frontend build
2026-08-20 05:06:28 +08:00
SivanCola 1559419186 Preserve whole-writer priority across scheduler pumps
Problem:
- Later directory writers that had already queued behind a whole-workspace writer could bypass it when another scheduler event pumped the queue.
- Cancelling the whole-workspace waiter did not immediately reconsider the writers behind it.

Root cause:
- Incoming admission honored whole-writer priority, but the waiter pump evaluated each queued writer independently and cancellation only removed the waiter.

Fix:
- Keep a blocked whole-workspace waiter as a FIFO barrier for later writers while allowing read-only work to use free capacity.
- Pump the remaining queue immediately after a cancelled waiter is removed.
- Add deterministic pump-order and cancellation regressions.

Verification:
- Focused scheduler regressions: 100 repetitions
- Focused scheduler race regressions: 20 repetitions
- go test and go test -race for agent/control/filelock/workspacelease
- go vet and golangci-lint
- repolint and git diff --check
2026-08-20 04:24:29 +08:00
SivanCola 17a2db6264 Preserve queued whole-workspace writer priority
Problem:
- A whole-workspace writer queued behind an unrealized directory writer could be repeatedly bypassed by later directory writers, creating unbounded tail wait under steady background work.

Root cause:
- Directory claims intentionally expose an empty scheduling reservation before their first concrete write, and incoming admission did not consider an already queued whole-workspace writer.

Fix:
- Give a queued whole-workspace writer priority over later writer admissions while keeping read-only work concurrent.
- Add a deterministic arrival-order regression covering the queued, active, and released states.

Verification:
- go test ./internal/agent -run '^TestScheduler(QueuedWholeWriterBlocksLaterDirectoryWriter|WholeClaimCannotStartBehindUnrealizedDirectoryWriter|DirectoryClaimsStartInParallel|WholeClaimNarrowsForNewSiblingsAndOpaqueRestoresExclusion)$' -count=100 -timeout 300s
- go test -race ./internal/agent -run '^TestScheduler(QueuedWholeWriterBlocksLaterDirectoryWriter|WholeClaimCannotStartBehindUnrealizedDirectoryWriter|DirectoryClaimsStartInParallel|WholeClaimNarrowsForNewSiblingsAndOpaqueRestoresExclusion)$' -count=20 -timeout 300s
- go test ./internal/agent ./internal/control ./internal/filelock ./internal/workspacelease -count=1 -timeout 300s
- go test -race ./internal/agent ./internal/control ./internal/filelock ./internal/workspacelease -count=1 -timeout 600s
- go run ./tools/repolint
- git diff --check
2026-08-20 03:51:58 +08:00
SivanCola c94e51138d Preserve macOS workspace lock compatibility
Problem:
- Folding macOS workspace and lock-file paths changed the legacy exact-root hash for mixed-case paths and could address the wrong lock file on case-sensitive volumes.

Root cause:
- One folded identity was reused for the new hierarchy protocol, the previous exact-root protocol, the process-local registry, and the filesystem path opened for flock.

Fix:
- Keep separate folded coordination and exact compatibility identities, acquiring both only where their lock domains differ.
- Preserve the filesystem lock path while folding only the process-local registry key.
- Cover new case aliases, old exact-root holders, and parent writes crossing an old nested repository root.

Verification:
- go test ./internal/filelock ./internal/workspacelease -count=20 -timeout 300s
- go test -race ./internal/filelock ./internal/workspacelease -count=5 -timeout 300s
- go test ./internal/workspacelease -run 'Test(Darwin|HierarchyProtocol|ParentWorkspace|NestedWorkspace|SameOwner|WorkspaceWriter|ReversePath|ExclusiveUpgrade)' -count=50 -timeout 300s
- go test -race ./internal/agent ./internal/control ./internal/filelock ./internal/workspacelease -count=1 -timeout 600s
- go run ./tools/repolint
- git diff --check
2026-08-20 03:51:37 +08:00
SivanCola 84fd077bfd Fix Windows workspace lease slot regression
Problem:
The Windows smoke job timed out in the same-owner writer-priority regression even though the production re-entry path was not deadlocked.

Root cause:
The test selected increasing lock stripes by hashing raw filepath.Join output. On Windows that used backslashes, while production canonicalizes paths to lowercase forward-slash identities before hashing, so the test could accidentally request stripes in reverse order and trigger the intended order guard.

Fix:
Resolve test stripes through the production pathSpecs canonicalization path before comparing their order.

Verification:
- focused regression: 50 repetitions
- focused race regression: 20 repetitions
- go test and go test -race for agent/filelock/workspacelease
- Windows and Linux workspacelease test binaries cross-compiled
- go vet, repolint, and git diff --check
2026-08-20 03:22:01 +08:00
SivanCola ea5e3ffc63 Merge pull request #9116 from ttmouse/pr/feishu-chat-session-reuse
feat(bot): 钉钉 Stream 适配器、IM 会话复用与机器人设置面板 UI 优化
2026-08-20 02:41:31 +08:00
SivanCola 3e4a8e279c Deflake optional transcript controls in browser bench
Problem:
The desktop browser CI gate could time out while clicking the optional jump-to-bottom control even after confirming that it existed.

Root cause:
The bench queried an async React control with locator.count() and clicked it in a separate Playwright operation. A scroll-state update could unmount the control between those calls, leaving click() waiting for 30 seconds.

Fix:
Query and activate optional transcript controls in one browser task, and apply the same helper to the sibling older-history setup paths that have the same lifecycle.

Verification:
- pnpm test:transcript-browser
- PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/transcript-selection.mjs (2 additional runs)
- pnpm build
- REASONIX_CHANNEL=test pnpm build
- go run ./tools/repolint
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-20 02:01:59 +08:00
SivanCola 7d4126f20a Deduplicate workspace conflict path copy
Problem:

The latest-base merge tree exceeded the production initial-JavaScript gzip budget by 26.8 bytes.

Root cause:

File and multi-file conflicts used separate translation keys with identical copy and a three-way runtime branch.

Fix:

Use the existing non-empty conflict label to select one shared path-scoped translation while preserving the workspace fallback and all user-visible wording.

Verification:

cd desktop/frontend && pnpm build

go run ./tools/repolint

git diff --check
2026-08-20 01:44:37 +08:00
SivanCola 51e88c2bdc Merge latest main-v2 into DingTalk support
Problem:
main-v2 advanced with complete-history question navigation, leaving this PR conflicting in the shared bundle budgets while both branches also touched locales, styles, and the repolint baseline.

Root cause:
The navigation and DingTalk changes independently extended the same startup bundle and capacity guards, so neither branch budget represented their combined merge tree.

Fix:
Merge main-v2 at 0d80a69dc, preserve both the long-history navigation contracts and DingTalk settings/runtime wiring, and set channel-specific bundle ceilings from measured current-base and combined artifacts. The largest ratchet is about 0.117%, with roughly 0.1 KiB of build-SHA headroom.

Verification:
- pnpm test:transcript
- focused DingTalk settings, settings snapshot, responsive layout, deferred history, and tab hydration tests
- pnpm build
- REASONIX_CHANNEL=test pnpm build
- go test ./...
- cd desktop && go test ./...
- go run ./tools/repolint
- git diff --cached --check

Co-authored-by: SivanCola <[email protected]>
2026-08-20 01:38:25 +08:00
SivanCola 0d80a69dc4 Merge pull request #9117 from wuhanwoaini521/codex/sync-jump-bar-with-scroll
fix(desktop): keep complete history navigable / 保持完整历史对话可导航
2026-08-20 01:18:16 +08:00
SivanCola a84641421c Keep whole-workspace claims initially exclusive
Problem:
- A whole-workspace writer could start behind a directory-declared writer when the directory writer had not realized a file yet, making admission depend on arrival order.

Root cause:
- Live directory claims intentionally expose an empty scheduling reservation before their first write, and canStartLocked compared the incoming whole claim only with that narrowed reservation.

Fix:
- Keep incoming whole-workspace claims exclusive against every active writer, including unrealized directory claims.
- Add a reverse-order regression that also proves admission resumes after the directory writer releases.

Verification:
- go test ./internal/agent -run '^TestScheduler(WholeClaimCannotStartBehindUnrealizedDirectoryWriter|DirectoryClaimsStartInParallel|WholeClaimNarrowsForNewSiblingsAndOpaqueRestoresExclusion|ParentFileWriteAfterChildRealize)$' -count=50 -timeout 180s
- go test -race ./internal/agent -run '^TestScheduler(WholeClaimCannotStartBehindUnrealizedDirectoryWriter|DirectoryClaimsStartInParallel|WholeClaimNarrowsForNewSiblingsAndOpaqueRestoresExclusion|ParentFileWriteAfterChildRealize)$' -count=10 -timeout 180s
- go test ./internal/agent -count=1 -timeout 180s
- go test -race ./internal/agent ./internal/filelock ./internal/workspacelease -count=1 -timeout 300s
- go run ./tools/repolint
- git diff --check
2026-08-20 00:36:53 +08:00
SivanCola 3d8f6fb150 fix(desktop): bound distant history paging
Problem:
Jumping across a 10,000-turn transcript needed many small history requests because the backend also caps entries per page.

Root cause:
The frontend increased only the turn budget for targeted jumps and kept the production default entry limit of 120.

Fix:
Use a bounded 500-turn and 1,000-entry budget only for targeted jumps, while preserving ordinary paging and the backend byte cap. Model the production entry bound in the stress fixture.

Verification:
- go run ./tools/repolint
- pnpm exec tsx src/__tests__/use-controller-meta.test.ts
- pnpm exec tsx src/__tests__/transcript-store.test.ts
- pnpm test:typecheck
- transcript scroll stability browser gate
- git diff --check
2026-08-20 00:31:28 +08:00
SivanCola 6d13ae9408 Merge main-v2 into DingTalk session reuse
Problem:
- main-v2 advanced with the transcript reader extent fix, leaving this PR conflicting because both branches updated the frontend bundle budgets.

Root cause:
- The independent reader-stability and DingTalk startup changes share the same initial bundle, so neither branch budget covered their combined merge tree.

Fix:
- Preserve the reader extent stabilization and the DingTalk session/runtime wiring.
- Set production and test bundle ceilings from measured latest-base and merged artifacts, with only narrowly rounded channel-specific headroom.

Verification:
- pnpm test:transcript
- focused DingTalk settings, settings snapshot, and responsive layout tests
- go test ./...
- cd desktop && go test ./...
- go test -race ./internal/bot/... ./internal/botruntime
- cd desktop && go test -race ./...
- pnpm build
- REASONIX_CHANNEL=test pnpm build
- go run ./tools/repolint
- git diff --cached --check

Co-authored-by: SivanCola <[email protected]>
2026-08-20 00:27:12 +08:00
SivanCola 56f72ef802 Serialize tool batches around writable hooks
Problem:
- Parallel read-only calls in one provider round could contend for the whole-workspace parent claim when tool hooks may write, causing a sibling call to be blocked and dropped.

Root cause:
- Batch partitioning considered only the tools' ReadOnly flags, while executeOne correctly promoted every hook-capable call to a whole-workspace writer.

Fix:
- Run provider-round batches in order only when configured tool hooks may mutate the workspace.
- Preserve ordinary read-only fan-out and add regression coverage for both paths and complete hook execution.

Verification:
- go test ./internal/agent -run 'TestWritableHooks(SerializeReadOnlyToolBatch|UseWorkspaceLease|ReserveWholeParentWorkspace)$' -count=50 -timeout 180s
- go test -race ./internal/agent ./internal/filelock ./internal/workspacelease -count=1 -timeout 300s
- go test ./... -count=1 -timeout 300s
- go vet ./...
- cd desktop && go test ./... -count=1 -timeout 180s
- cd desktop/frontend && pnpm build
- golangci-lint run --timeout=5m ./...
- go run ./tools/repolint
- git diff --check
2026-08-20 00:25:25 +08:00
SivanCola 0d45cbf9f8 Merge main-v2 after transcript extent stabilization
Problem: PR #9117 was based before merged PR #9140, and both branches update the transcript browser gate, transcript suite, and bundle budgets.

Root cause: the complete-history navigation work and the Windows extent-rebound guard were developed from the same #9130 baseline, so the final merge tree needed an explicit semantic integration.

Fix: merge main-v2 at 89c983a9, retain both focused test groups and browser replays, and set channel-specific bundle budgets from the combined measured output.

Verification: transcript suite, Chromium selection and scroll-stability gates, 10,000-turn performance benchmark, stable/test builds and budgets, test typecheck, repolint, and diff checks pass.
2026-08-19 23:58:18 +08:00
SivanCola 89c983a9bc Merge pull request #9140 from SivanCola/fix/windows-transcript-extent-oscillation
fix(desktop): preserve reader intent across extent rebounds / 修复会话高度回弹导致的反向跳动
2026-08-19 23:51:15 +08:00
SivanCola ba15a60e38 Use iterator path splitting in hierarchy locks
Problem:
- The required golangci-lint job rejected the hierarchy path loop under the modernize rules.

Root cause:
- The loop ranged over an allocated strings.Split result instead of the Go iterator API required by the pinned linter.

Fix:
- Range over strings.SplitSeq while preserving the same normalized path components and lock order.

Verification:
- golangci-lint run --timeout=5m ./...
- go test -race ./internal/workspacelease -count=1
- go test ./internal/agent -run SchedulerWholeClaim -count=1
- go run ./tools/repolint
- git diff --check
2026-08-19 23:38:11 +08:00
SivanCola 68d695917a Fix overlapping workspace write coordination
Problem:
- Parent workspaces and directly opened nested roots could take disjoint path and whole-workspace locks while mutating the same file.
- Realized whole-workspace claims still blocked new disjoint siblings, and Desktop could not identify a holder across canonical roots.
- A queued workspace writer could deadlock a re-entrant path hold from the same lease owner.

Root cause:
- Path holds shared only their opener's exact workspace lock, scheduler admission kept consulting the immutable declaration, and Desktop rejected unequal roots before comparing canonical file keys.
- Writer-priority shared-lock acquisition did not distinguish re-entry by the owner whose active shared hold blocked the writer.

Fix:
- Add bounded hierarchy stripes plus ancestor and nested-repository compatibility locks in stable order, preserving previous exact-root locks for mixed-version coexistence.
- Reuse shared domains within one owner while retaining writer priority for other owners, and normalize lock identities consistently on case-insensitive platforms.
- Admit new siblings against the live reservation and match Desktop conflicts by concrete keys and workspace containment.
- Document the hierarchy protocol and add deterministic interleaving, compatibility, platform, matching, and latency coverage.

Verification:
- go test -race ./internal/agent ./internal/filelock ./internal/workspacelease -count=1
- go test ./... -count=1 -timeout 300s
- go vet ./...
- cd desktop && go test ./... -count=1 -timeout 180s
- cd desktop/frontend && pnpm build
- go run ./tools/repolint
- Windows and Linux workspacelease test binaries cross-compiled
- git diff --check
2026-08-19 23:32:45 +08:00
SivanCola f47beddae4 Remove stale DingTalk ping constant
Problem:
The CI lint job rejected the DingTalk adapter because wsPingInterval was declared but no longer used.

Root cause:
The connection startup refactor moved heartbeat handling to server-initiated ping frames but left the old client interval constant behind.

Fix:
Remove the obsolete constant and its comment.

Verification:
- golangci-lint run --timeout=5m ./internal/bot/dingtalk/...
- go test ./internal/bot/dingtalk ./internal/bot/... ./internal/botruntime
- go run ./tools/repolint
- gofmt -l internal/bot/dingtalk/dingtalk.go
- git diff --check
2026-08-19 23:23:31 +08:00
SivanCola 6d1f42a55b test(desktop): stabilize reduced-motion hydration wait
Problem:
The combined transcript browser suite could exhaust a 15-second geometry-stability precondition after the preceding selection stress run, even though the reduced-motion scenario passed in isolation.

Root cause:
The scenario allowed 30 seconds for content hydration but only the default 15 seconds for the subsequent eight-frame stable-tail precondition.

Fix:
Align that precondition with the existing 30-second hydration window without changing frame-gap, long-task, reversal, write-count, or bottom-distance assertions.

Verification:
The full pnpm test:transcript-browser sequence passed from selection through the reduced-motion idle and return-to-bottom checks.
2026-08-19 23:17:05 +08:00
SivanCola 8ca26f1050 fix(desktop): preserve reader intent across extent rebound
Problem:
Windows WebView2 can briefly collapse and restore the transcript extent while rows remain mounted, reversing a downward reader gesture by multiple screens without triggering blank recovery.

Root cause:
The scroll arbiter treated the transient physical-bottom clamp as an authoritative delivery and had no bounded transaction tying the rebound to the active reader input.

Fix:
Track a 180 ms reader extent transaction, require a large collapse and rebound before one anchor-based correction, and cancel stale geometry on ownership, resize, recovery, and surface changes. Normalize wheel and touch input geometry while keeping the arbiter as the sole Virtuoso writer.

Verification:
Added deterministic geometry, ownership-race, cancellation, touch, keyboard, and Playwright replay coverage. Passed transcript tests, production/test builds and bundle budgets, scroll-writer checks, repolint, desktop Go tests/vet, and the isolated browser stability bench.
2026-08-19 23:14:53 +08:00
SivanCola 983d785e1b test(desktop): stabilize reduced-motion wheel gate
Problem:
The Linux desktop CI job timed out while the reduced-motion browser gate tried to return the transcript to the physical bottom after an upward wheel gesture.

Root cause:
The gate sent its downward wheel after a fixed 120ms delay without first proving Chromium had delivered the upward gesture, entered manual mode, and moved away from the tail. Wheel delivery and coalescing on the Linux runner made that setup nondeterministic.

Fix:
Wait for the upward gesture to establish manual reading away from the captured bottom position, then use a bounded sequence of real downward wheel gestures until tail-follow reaches the physical bottom. Keep the existing reversal, moving-frame, and physical-bottom assertions unchanged.

Verification:
- transcript-scroll-stability.mjs passed three consecutive runs
- pnpm --dir desktop/frontend test:transcript-browser
- pnpm --dir desktop/frontend test:transcript
- pnpm --dir desktop/frontend test:performance
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend lint:hooks
- pnpm --dir desktop/frontend build
- REASONIX_CHANNEL=test pnpm --dir desktop/frontend build
- go run ./tools/repolint
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-19 23:08:01 +08:00
SivanCola cc99f059c6 Fix DingTalk runtime switch safety
Problem:
DingTalk runtime/profile commands could tear down the current bot controller before a replacement was viable. Webhook redirects and startup readiness were insufficiently guarded, while settings round trips could drop hidden legacy session mappings.

Root cause:
Runtime overrides were published and the old state was closed before controller boot and lease transfer completed. Webhook validation allowed HTTP through process-global test policy and did not validate every redirect. Settings rebuilt the DingTalk config without preserving non-UI fields.

Fix:
Build, bind, and lease replacement controllers before atomically swapping them, reusing the same-path lease keeper and preserving the old runtime on failure. Require HTTPS for production webhooks, revalidate redirect hops, complete the initial WebSocket handshake synchronously, preserve legacy mappings, handle disabled connections correctly, and keep the frontend connection label and dependency graph accurate.

Capacity:
DingTalk status wiring measures 425.9 KiB gzip against a narrowly raised 426.0 KiB initial-JS budget (+0.5 KiB, 0.118%). Complete DingTalk setup guidance measures 55.8/55.9 KiB for zh and 56.5/56.6 KiB for zh-TW; each locale ratchet rises only 0.2 KiB (about 0.36%).

Verification:
- go test ./...
- go test -race ./internal/bot/... ./internal/botruntime
- cd desktop && go test ./...
- cd desktop && go test -race ./...
- go vet ./internal/bot/... ./internal/botruntime
- cd desktop && go vet ./...
- pnpm build
- settings snapshot and responsive contract suites
- go run ./tools/repolint
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-19 23:06:00 +08:00
SivanCola 8d42eb9ec2 test(desktop): stabilize native thumb drag gate
Problem:
The Linux desktop CI job timed out twice while waiting for a native scrollbar thumb drag to reach the physical transcript bottom, even though measurement freezing and the preceding navigation and performance assertions passed.

Root cause:
The browser gate stopped the held pointer five pixels inside the scrollbar track. Native Chromium scrollbar themes do not guarantee that this coordinate clamps the thumb to the track endpoint.

Fix:
Drag the held native thumb beyond the visible track while keeping the pointer inside the viewport, and retain the exact one-pixel physical-bottom assertion and existing timeout.

Verification:
- pnpm --dir desktop/frontend test:transcript-browser
- transcript-scroll-stability.mjs passed two additional consecutive runs
- pnpm --dir desktop/frontend test:transcript
- pnpm --dir desktop/frontend test:performance
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend test:typecheck
- pnpm --dir desktop/frontend lint:hooks
- env -u REASONIX_CHANNEL pnpm --dir desktop/frontend build
- REASONIX_CHANNEL=test pnpm --dir desktop/frontend build
- go run ./tools/repolint
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-19 22:36:10 +08:00
ttmouse 3819aaa7b8 fix(bot/qq): drop unauthorized intents that make gateway reject with op=9
QQ gateway closes the WebSocket with op=9 INVALID_SESSION when Identify
declares an intent the bot is not authorized for. Unverified/not-published
bots have no guild-message permission, so the hardcoded 1<<9 GUILD_MESSAGES
(and 1<<26 INTERACTION, a paid high-level capability) always fail the
handshake (#7512 has per-bit protocol-level evidence).

Identify now uses only authorized, needed intents:
  GUILDS (1<<0) | GUILD_MEMBERS (1<<1) | DIRECT_MESSAGE (1<<12) |
  GROUP_AND_C2C_EVENT (1<<25) | PUBLIC_GUILD_MESSAGES (1<<30)

Verified against a real QQ bot: gateway now reaches READY and receives
C2C_MESSAGE_CREATE events (previously: op=9 + EOF loop).

Fixes #8108, #7512
2026-08-19 22:32:26 +08:00
SivanCola d4a3cdc584 Merge remote-tracking branch 'origin/main-v2' into fix/pr-9116-review 2026-08-19 22:19:06 +08:00
SivanCola d964364804 Merge latest main-v2 into history navigation fix
Context:
PR #9130 landed on main-v2 after the long-history navigation update was pushed. Its transcript geometry diagnostics overlapped the navigation owner, test script, and bundle budgets.

Conflict resolution:
Preserve both the bounded Windows geometry recovery and complete-history navigation contracts. Combine their transcript suites and callbacks, extract question navigation and pagination race helpers from oversized owner files, keep the stable/test channel budgets independently bounded, and normalize the merged diagnostic tests for the repository test compiler.

Verification:
pnpm --dir desktop/frontend test:transcript
pnpm --dir desktop/frontend typecheck
pnpm --dir desktop/frontend test:typecheck
pnpm --dir desktop/frontend lint:hooks
pnpm --dir desktop/frontend test:performance
env -u REASONIX_CHANNEL pnpm --dir desktop/frontend build
REASONIX_CHANNEL=test pnpm --dir desktop/frontend build
cd desktop && go test ./...
go run ./tools/repolint
git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-19 22:02:26 +08:00
SivanCola 56eba1327a Fix workspace lease lint diagnostics
Problem:
- The pull request lint job rejected two newly added loops under intrange and modernize.

Root cause:
- The bounded-stripe test used a three-clause integer loop, and release cleanup indexed a slice backwards manually.

Fix:
- Use an integer range for the stripe coverage loop.
- Use slices.Backward to preserve LIFO release ordering without manual indexing.

Verification:
- golangci-lint run ./...
- go test ./... -count=1 -timeout 300s
- go test -race ./internal/agent ./internal/filelock ./internal/workspacelease -count=1 -timeout 300s
- go vet ./...
- go run ./tools/repolint
- git diff --check
2026-08-19 22:01:12 +08:00
SivanCola d1d0d08495 Keep merged App within the lint budget
Problem:
- The latest-base merge tree exceeded the baselined App.tsx file-size budget by one line.

Root cause:
- The PR and the updated base each remained within the budget independently, but their non-conflicting App.tsx edits combined to one extra line.

Fix:
- Keep the workspace-conflict translation call on one line without changing behavior.

Verification:
- pnpm lint:hooks
- go run ./tools/repolint
- git diff --check
2026-08-19 21:44:12 +08:00
SivanCola 9ef2e019ed Fix narrow write coordination edge cases
Problem:
- File-scoped lease batches could deadlock on reverse path order, lose active protection during workspace upgrades, and misidentify the local writer in Desktop.
- Writable tool hooks, case aliases, queued writers, and historical path-lock growth left adjacent correctness and resource gaps.

Root cause:
- Paths were acquired incrementally, held and waiting lease state shared one scope, and hook coordination followed only the concrete tool's declared write surface.
- Path locks used unbounded identities and local shared locking did not give waiting writers priority.

Fix:
- Acquire canonical path batches in stable order through a bounded global stripe set, retain active holds during upgrades, and prioritize queued workspace writers.
- Elevate hook-wrapped calls to workspace-wide coordination, fold case aliases, and match Desktop conflicts with exact held and waiting keys.

Verification:
- go test ./... -count=1 -timeout 300s
- go test -race ./internal/agent ./internal/filelock ./internal/workspacelease -count=1
- go vet ./...
- cd desktop && go test ./... -count=1 -timeout 180s
- cd desktop/frontend && pnpm build
- go run ./tools/repolint
- git diff --check
2026-08-19 21:40:09 +08:00
SivanCola 4fb5d19169 Merge pull request #9130 from SivanCola/fix/windows-transcript-scroll-stability
fix(desktop): stabilize Windows transcript geometry / 修复 Windows 长会话滚动跳变与空白
2026-08-19 21:33:33 +08:00
SivanCola 56da9cf573 Merge pull request #9000 from Huang-404-Q/fix-tui-text-paste-blocked-by-image-probe
fix(tui): fall back to text paste when the clipboard holds no image / 修复剪贴板无图片时 Ctrl+V 拦截纯文本粘贴
2026-08-19 21:33:25 +08:00
SivanCola 772e065869 perf(desktop): bound question navigation startup cost
Problem:
The long-history question rail remained in the initial application bundle, and active-question tracking scanned every mounted anchor even after passing the viewport. The exact-navigation wiring exceeded the existing initial gzip budget by 25 bytes.

Root cause:
Transcript imported the rail eagerly, the scroll-sync helper did not use the ordered DOM-anchor invariant, and the bundle guard had no allowance for the small owner-level navigation addition.

Fix:
Lazy-load the question rail behind its existing visibility gate, stop the ordered anchor scan at the first position below the viewport, remove redundant loaded-state checks, and record the measured 25-byte (0.0057%) increase with the smallest 0.1 KiB budget increment.

Verification:
pnpm --dir desktop/frontend test:transcript
pnpm --dir desktop/frontend test:typecheck
pnpm --dir desktop/frontend test:performance
pnpm --dir desktop/frontend build
git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-19 21:31:15 +08:00
SivanCola 69e6ec3654 fix(desktop): satisfy modern Go lint
Problem
The macOS desktop CI job rejected the scroll diagnostics exporter under the current modernize rules.

Root cause
The exporter used a hand-written membership loop and converted fmt.Sprintf output to bytes instead of using the Go 1.26 standard-library helpers.

Fix
Use slices.Contains for enum membership and fmt.Appendf for the checksum line.

Verification
- cd desktop && golangci-lint run --timeout=5m
- cd desktop && go vet ./...
- cd desktop && go test . -run scroll diagnostic tests -count=1
- go run ./tools/repolint
- git diff --check
2026-08-19 21:04:59 +08:00
SivanCola 31293d1f97 fix(desktop): harden long-history navigation
Problem:
Legacy history pages use zero-based cursors, which shifted absolute question navigation by one. An older-page request could also complete after its tab was closed and recreate released transcript state.

Root cause:
The fallback HistoryPage projection copied its cursor directly into the one-based frontend coordinate model, and tab cleanup removed state without invalidating the pagination request generation.

Fix:
Normalize legacy pages to absolute one-based question turns, retain a tombstone generation when transcript state is released, and discard success or error completions for removed tabs. Add deterministic close-race coverage and a 10,000-turn targeted-paging stress test.

Verification:
pnpm --dir desktop/frontend test:transcript
pnpm --dir desktop/frontend typecheck
pnpm --dir desktop/frontend test:typecheck
pnpm --dir desktop/frontend lint:hooks
pnpm --dir desktop/frontend check:css
pnpm --dir desktop/frontend test:performance
cd desktop && go test ./...
git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-19 21:00:37 +08:00
SivanCola 3514beec85 Merge branch 'main-v2' into fix/windows-transcript-scroll-stability 2026-08-19 20:54:47 +08:00
SivanCola 3917c8817c fix: preserve overlapping TUI paste requests
Problem:
A terminal paste arriving after the image probe could still duplicate the scheduled text fallback, while a second Ctrl+V during the probe was dropped.

Root cause:
The image-paste state tracked only one pending probe and checked the terminal paste sequence before the asynchronous text read, without carrying that guard to final application.

Fix:
Coalesce overlapping image probes, count every paste request, subtract terminal-owned pastes both before and after the text read, and replay only the remaining requests through the canonical composer paste path.

Verification:
- focused clipboard interleaving tests, including 50 repeated runs
- go test ./... with the cache guard
- go test -race ./internal/cli
- cd desktop && go test ./...
- go vet ./... and go build ./...
- golangci-lint for native, Darwin, and Windows targets
- repolint plus Windows and Linux cross-compilation
2026-08-19 18:59:36 +08:00
SivanCola bfa6abd159 chore(desktop): keep bundle rationale stable
Describe the transcript recovery bundle delta as a bounded percentage so the comment remains accurate across build-SHA gzip drift.
2026-08-19 18:57:16 +08:00
SivanCola 9858d733ae fix(desktop): finish bounded transcript probe
Problem:
- The bounded recovery probe relied on Virtuoso emitting another range or scroll event before it could leave enlarged overscan.

Root cause:
- Entering safe mode changed probe props but did not schedule the probe verdict from the integrity hook itself.

Fix:
- Schedule one post-render blank check whenever safe mode starts.
- Exit the probe after that single healthy or still-blank verdict without remounting.
- Cover a 10,000-row failed probe with no follow-up range or scroll event.

Verification:
- pnpm exec tsx src/__tests__/transcript-recovery-race.test.tsx (89 passed)
- pnpm test:transcript
- pnpm test:transcript-browser
- pnpm build (425.3 / 425.5 KiB)
2026-08-19 18:56:13 +08:00
SivanCola 5c952f3d33 test(desktop): compile question navigation regression
Problem:
The test TypeScript build rejects an obsolete questions fixture after active-question selection was narrowed to mounted anchor positions.

Root cause:
The helper no longer consumes the full question array, but its old test data remained declared under noUnusedLocals.

Fix:
Remove the unused fixture while keeping the mounted-position regression cases unchanged.

Verification:
pnpm --dir desktop/frontend test:typecheck
pnpm --dir desktop/frontend exec tsx src/__tests__/transcript-question-nav.test.ts
2026-08-19 18:52:18 +08:00
SivanCola 514e39f2e5 feat: hold write leases per tool and name the real conflict surface / 写锁收到单次 tool,并标明真实冲突面
Problem:
After the first write, a session kept the workspace exclusive until the run
ended. Read-only bash could still look like a writer at the edges, fleet
preflight failed omitted write_paths, wait copy said the whole workspace was
busy, and a finished conversation could keep the lock.

Root cause:
Owner released only on EndRun, path-bound locks were repo-wide in the same
git root, fleet preflight treated whole-workspace claims as fatal, and the
conflict card had no file/workspace distinction.

Fix:
- Hold/release write leases per mutating tool; EndRun drops leftovers.
- Path-bound writes take shared workspace + exclusive file locks so different
  files (same repo included) may proceed; bash/MCP exclusive only for that command.
- Read-only bash (git status, Get-ChildItem, ls) does not take a write lease.
- Fleet omitted write_paths queue in the scheduler instead of failing preflight.
- Conflict cards name the file being written; no home paths.

Verification:
- go test ./internal/agent ./internal/workspacelease ./internal/filelock
- go test -race ./internal/workspacelease ./internal/filelock
- cd desktop && go test ./
2026-08-19 18:50:44 +08:00
SivanCola 34b7a49dd9 Merge latest main-v2 into transcript navigation fix
Context:
Bring PR #9117 onto the current main-v2 merge tree before final validation. Preserve the contributor and maintainer commits while incorporating the latest workspace, provider, stream-batching, and repository test changes.
2026-08-19 18:47:52 +08:00
SivanCola e1490d6f88 fix(desktop): keep complete transcript history navigable
Problem:
Long desktop sessions hide earlier turns behind a manual fold control, and the question rail only represents the loaded suffix. Failed expansion can also leave navigation without a clear recovery path.

Root cause:
Transcript pagination is exposed as a user-controlled fold, absolute history turns are discarded during projection, and the rail renders and measures one DOM marker per loaded question. Older-page completions also need stronger invalidation across tab and session hydration changes.

Fix:
Auto-load ordinary older history, preserve absolute turns, and support bounded targeted paging for unloaded questions. Render at most 120 sampled markers with direct pointer mapping and keyboard slider semantics. Stop automatic retries after a failure, expose a safe retry action, and discard stale page completions by request generation and session identity.

Verification:
pnpm --dir desktop/frontend test:transcript
pnpm --dir desktop/frontend typecheck
pnpm --dir desktop/frontend test:typecheck
pnpm --dir desktop/frontend lint:hooks
pnpm --dir desktop/frontend check:css
pnpm --dir desktop/frontend test:performance
cd desktop && go test ./...
git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-19 18:47:38 +08:00
SivanCola 63b52be9ea fix(desktop): bound transcript recovery fallbacks
Problem
Repeated blank recovery could retain an all-row measurement fallback, exact row heights could survive immutable late-content patches, and native smoke cleanup hid persistent Windows deletion failures.

Root cause
Recovery tracked only the last hard reset, measured sizes were keyed by row and width without content identity, and the smoke script used SilentlyContinue for final cleanup.

Fix
Give each layout generation one hard reset and one bounded measurement probe, version measured rows by immutable item identity, invalidate stale exact and median samples, and retry native-smoke cleanup before surfacing persistent errors. Add long-history, late-patch, and locked-file regression coverage. The production bundle budget moves by 0.5 KiB for a measured 361-byte gzip increase.

Verification
- pnpm test:transcript
- pnpm test:transcript-browser
- pnpm build
- REASONIX_CHANNEL=test pnpm build
- cd desktop && go test . -count=1
- cd desktop && go vet ./...
- go run ./tools/repolint
- git diff --check
2026-08-19 18:40:43 +08:00
SivanCola 10a46ff37c Merge pull request #9131 from SivanCola/test/plan-todo-safe-recovery
test(agent): cover safe Plan todo recovery / 覆盖 Plan 待办安全恢复路径
2026-08-19 18:40:26 +08:00
SivanCola 9bd3518ca9 feat: narrow write conflicts to realized paths and nested git repos / 写入冲突按落盘路径与嵌套仓库判定
Problem:
Conflict detection treated the whole workspace (or a declared directory prefix)
as exclusive. Parallel writers blocked even when they touched different files
or different nested git repositories.

Root cause:
write_paths scheduling used capability Overlaps, so directory identity and
omitted claims serialized every writer. workspacelease used one exclusive flock
keyed by the opened workspace root.

Fix:
- Schedule directory claims in parallel; reserve realized files at write time.
- Shrink omitted write_paths to realized files until bash/MCP goes opaque.
- Add shared flock; nested-repo path-bound writes take shared parent + exclusive repo.
- Same-repo sessions still serialize. Desktop conflict cards match lock domains.

Verification:
- go test ./internal/agent ./internal/filelock ./internal/workspacelease
- go test -race ./internal/agent ./internal/filelock ./internal/workspacelease
- cd desktop && go test ./...
2026-08-19 18:24:40 +08:00
SivanCola 49acdab882 fix: harden TUI clipboard paste fallback
Problem:
Rapid Ctrl+V text fallbacks could be dropped, and clipboard image probes could classify operational failures as an empty image clipboard.

Root cause:
Internal text fallback reused terminal paste accounting, while Linux and macOS image readers inferred missing image data from broad command failures.

Fix:
Track only terminal-originated paste events, probe explicit clipboard image types before reading, and preserve probe, read, and save failures.

Verification:
- go test ./...
- go test -race ./internal/cli ./internal/control
- cd desktop && go test ./...
- go vet ./...
- go build ./...
- golangci-lint on Linux, macOS, and Windows targets
- repolint and Windows cross-compilation

Co-authored-by: SivanCola <[email protected]>
2026-08-19 18:22:56 +08:00
ttmouse 961c78f473 fix(bot): address review on dingtalk webhook security, /model atomicity, legacy session persistence
- dingtalk: drop access token from webhook replies; restrict webhook to dingtalk
  hosts; reject non-dingtalk session webhooks at normalize/send; SSRF regression
  tests; ReplyToMsgID never used as URL
- dingtalk: use isInAtList structured mention field with text fallback
- botruntime: count dingtalk allowlist fields and direct [bot.dingtalk].access in
  startup gate; tests
- gateway: /model validates model via ModelResolver before swapping controller
  (failure-atomic, old session stays usable); provider-only usage rejected
- botruntime/config: persist /new-rotated session path for legacy direct
  [bot.dingtalk] via Dingtalk.SessionMappings; render + forget cleanup
- docs: remove work-note docs (dingtalk-support-scope, feishu analysis)
- repolint: baseline only for PR files, global limits kept at upstream
2026-08-19 18:22:01 +08:00
SivanCola 7f2e20353c Merge pull request #9001 from ttmouse/fix/file-preview-spacing
fix(desktop): fix large file preview height and row gaps / 修复文件预览限高与行间距空白
2026-08-19 18:08:06 +08:00
SivanCola c7ebc746d9 Merge pull request #9066 from zcxads666/fix/provider-name-readonly-hint
fix(desktop): show a clearer disabled style and hint for immutable provider names / 供应商名称不可修改时提供更直观的置灰与提示样式
2026-08-19 17:56:36 +08:00
SivanCola 3f70dca6ee Merge pull request #9032 from Linearl/fix/group-title-font-main-v2
fix(desktop): make session-group header font larger than session titles / 分组标题字号略大于普通会话标题
2026-08-19 17:49:36 +08:00
SivanCola 36b53d2638 Merge pull request #9045 from 75924/fix/desktop-stream-stall-flush
fix(desktop): flush stream deltas on a stall timer / 修复思考中卡住、停止后才显示内容
2026-08-19 17:38:30 +08:00
ttmouse aa7ad6153c Merge remote-tracking branch 'upstream/main-v2' into pr/feishu-chat-session-reuse 2026-08-19 17:37:08 +08:00
SivanCola f222cb584a Merge main-v2 into fix/file-preview-spacing
Resolve the workspace test command conflict by preserving both the base branch's turn verification suite and this branch's preview CSS regression coverage.

Verification:
- go run ./tools/repolint
- pnpm --dir desktop/frontend test:workspace
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend check:css
- pnpm --dir desktop/frontend build
- git diff --check --cached
2026-08-19 17:08:34 +08:00
SivanCola 93442715d7 Fix workspace code preview layout regressions
Problem:
Large code previews remained capped by the shared scroll height, and the preview could switch layout while code was loading, leaving visible row gaps.

Root cause:
The workspace code selector did not explicitly override the shared max-height rule, while the code layout class depended on the loaded preview payload.

Fix:
Clear the shared height cap inside workspace previews, keep the code layout active throughout loading, and add deterministic CSS and component regression coverage to the workspace suite.

Verification:
- go run ./tools/repolint
- pnpm --dir desktop/frontend test:workspace
- pnpm --dir desktop/frontend typecheck
- pnpm --dir desktop/frontend check:css
- pnpm --dir desktop/frontend build
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-19 17:04:16 +08:00
SivanCola f0c301b7d3 Fix provider name hint accessibility
Problem:
The immutable provider name hint was visible but not exposed as the field description, and its faint text color did not meet normal-text contrast requirements.

Root cause:
The name label, input, and hint had no shared IDs, while the hint reused the generic low-emphasis memory style.

Fix:
Associate the label and conditional hint with the name input via useId and aria-describedby. Apply a scoped secondary text color and extend the regression suite to guard both contracts.

Verification:
- provider-name-readonly.test.tsx passed (23/23)
- provider-editor-model-picker.test.tsx passed (45/45)
- focused ESLint, CSS checks, test typecheck, and git diff --check passed
- Chromium accessibility and 12-theme contrast matrix passed

Co-authored-by: SivanCola <[email protected]>
2026-08-19 17:01:58 +08:00
SivanCola c1c32cfac4 fix(desktop): preserve group title size while renaming
Problem: Session-group titles grow to the intended hierarchy size, but entering rename mode still shrinks the editable title to the old 11px size.

Root cause: The rename input retained a fixed font-size instead of following the updated group header typography.

Fix: Inherit the group header font size in rename mode and add a source-level regression contract that rejects the old fixed size.

Verification: project-tree row stability test, CSS guards, ESLint, TypeScript, full frontend build, bundle budgets, repolint, and Chromium computed-style checks at 100% and 125% all passed.

Co-authored-by: SivanCola <[email protected]>
2026-08-19 16:56:39 +08:00
SivanCola 47482db32b test(desktop): run stream batching regressions in CI
Problem:
The focused stream batching regressions were only discoverable through the general frontend test runner, which desktop CI does not invoke. The timer comments also described a requested delay as a hard latency guarantee and implied it could bypass a blocked main thread.

Root cause:
No CI-owned suite grouped the stream batching tests, and the documentation conflated a best-effort timer with a watchdog.

Fix:
Add a test:stream suite, mark its three tests as owned by that suite, and invoke it from desktop CI. Clarify that timer delivery can be delayed by browser throttling or long main-thread tasks.

Verification:
- pnpm test:stream
- pnpm exec tsc --noEmit
- pnpm exec eslint src/lib/rafBatch.ts src/__tests__/raf-batch.test.ts
- pnpm test:motion
- go run ./tools/repolint
- actionlint .github/workflows/ci.yml
- git diff --check

Co-authored-by: SivanCola <[email protected]>
2026-08-19 16:55:49 +08:00
SivanColaandpoorpaper 9d00b31ab0 test(agent): cover safe Plan todo recovery
Problem:
PR #9097 identified an untested Agent path where todo_write creates the canonical todo while Plan mode is already active. Existing tests seeded state directly, so the safe end-to-end recovery contract remained implicit.

Root cause:
The current contract was split across separate tests for todo progress and Plan-mode sign-off blocking; neither exercised both decisions after real in-turn todo creation.

Fix:
Add an Agent-level regression that creates the todo through executeOne, proves an evidence-backed complete_step stays phase-blocked without changing canonical state, and completes the same step through todo_write.

Verification:
- go test ./internal/agent -run ^TestPlanModeTodoCreatedInTurnUsesTodoWriteRecovery$ -count=1
- go test -race ./internal/agent -count=1
- go vet ./internal/agent
- go test ./internal/tool -run ^TestBuiltinToolContractDocumentation$ -count=1
- go run ./tools/repolint
- git diff --check

Co-authored-by: poorpaper <[email protected]>
2026-08-19 16:54:08 +08:00
ttmouse 6b6a3f0f4b fix(bot): address Codex review on dingtalk adapter and IM session reuse
- /new: pin the rotated session path as the active override so the next
  message cannot re-resolve the deterministic/mapped old path and silently
  undo the rotation (P1)
- dingtalk: Start validates credentials and the initial connection
  synchronously, surfacing start failures instead of a green "running"
  status that never connects (P1)
- /model: report the fully resolved effective model (override → channel/
  route → global) and reject provider-only switches that saved an
  unresolvable "provider/" model (P2)
- config: Dingtalk RequireMention defaults to true, matching Feishu (P2)
- allowlist + self-user-ids: wire the Dingtalk entries into both the
  desktop and CLI gateway configs (P2)
- CLI bot: apply direct [bot.dingtalk] runtime options via
  MergeLegacyDingtalkChannel, same path as the desktop (P2)
- desktop: TestDingtalkBot resolves credentials from the selected
  connection instead of the legacy block; SettingsPanel derives gateway
  enabled state including dingtalk (P2)
- tests: regressions for rotation pinning, /model, dingtalk Start,
  legacy channel merge and connection credential resolution
2026-08-19 16:45:11 +08:00
SivanCola 2fc5edf33d refactor(desktop): isolate scroll diagnostic contracts
Problem
Repository lint rejected new complexity in the transcript arbiter, bridge contract, and diagnostic payload validators.

Root cause
Diagnostic event mapping and recovery types lived in the single scroll-writer module, while manifest, summary, event-index, and label validation were combined into two large Go functions.

Fix
Extract pure diagnostic mapping, recovery types, and the additive Wails binding contract into focused modules. Split the Go validation boundary by payload responsibility without changing accepted fields or runtime behavior.

Verification
- go run ./tools/repolint
- pnpm test:transcript
- pnpm test:transcript-browser
- env -u REASONIX_CHANNEL pnpm build
- REASONIX_CHANNEL=test pnpm build
- cd desktop && go test ./...
- cd desktop && go vet ./...
2026-08-19 16:42:45 +08:00
SivanCola b1c821eead fix(desktop): stabilize virtual transcript geometry
Problem
Windows WebView2 users can still see multi-screen scroll jumps, blank transcript viewports, and bottom drift in long sessions after the reduced-motion write loop was fixed.

Root cause
Virtuoso's data-known-size can describe a stale size-tree slot rather than the current row. Feeding it back into the remount cache propagates cross-row height assignments, while overlapping tail-settle and blank-recovery work can amplify the resulting layout correction.

Fix
Cache real itemSize DOM measurements by row and content width, bound tail-settle transactions and recovery remounts, and fall back to temporary full-row measurement when a generation remains blank. Add test-only privacy-safe scroll diagnostics that compile out of stable builds.

Verification
- pnpm test:transcript
- pnpm test:transcript-browser
- env -u REASONIX_CHANNEL pnpm build
- REASONIX_CHANNEL=test pnpm build
- cd desktop && go test ./...
- cd desktop && go vet ./...
2026-08-19 16:30:33 +08:00
hanswu 20eb9caadf fix(desktop): sync question outline with transcript scroll 2026-08-19 11:26:58 +08:00
ttmouse cbefa8e52d feat(bot): add DingTalk Stream adapter, IM session reuse, and bot settings UI polish
- DingTalk enterprise app robot (Stream mode) adapter: WebSocket 长连接、
  @mention 门控、消息去重、会话 webhook 回复、思考中表情、重连退避
- IM 会话复用:确定性 per-chat 会话文件(重启后同一聊天复用同一会话)
- /model 命令:查询/切换当前会话模型(可选 --provider)
- 会话 webhook 持久化恢复:入站消息透传 SessionWebhook,gateway 重启后
  持久化恢复的消息仍能回复
- 设置面板 UI:渠道品牌图标(QQ/微信/钉钉/飞书/Lark,simple-icons +
  IconPark/Ant Design 路径)、状态点统一绿色、配对开关左右布局、danger
  区块样式简化、删除按钮红色
- 工具进度消息仅钉钉渠道抑制(其他平台保留「正在执行」提示)
- 测试:钉钉适配器单测、会话复用回归、renderSink 钉钉跳过进度、设置往返
2026-08-19 11:20:14 +08:00
ttmouse 0b8a4e56d3 feat(bot): pin deterministic per-chat session files for IM bot
When a message has no persisted session_mappings or /attach binding, the
gateway fell back to a fresh timestamp-named session file, so the same
Feishu chat spawned a new conversation per message. Derive a stable
chat→file path (dsh-dingtalk-channel's `ding-<chatId>` analogue) so the
same chat reuses one session across restarts.

- sessionProfileForMessage: fall back to botStableSessionPath when no
  mapping/attach resolves
- getOrCreateSession: pin new conversations to the stable path instead of
  degrading to a timestamp file
- botStableSessionPath: DM/direct keyed on chat, groups keyed per sender,
  mirroring BuildSessionKey scope
- update repolint baseline for local-run (file-size debt)
2026-08-19 10:52:30 +08:00
SivanCola 5386a938bd Merge pull request #9102 from SivanCola/fix/turn-verification-open
fix(desktop): make Turn verification open the change-panel summary / 本轮验证打开改动面板摘要
2026-08-19 10:07:57 +08:00
SivanCola d9e40b3619 Merge main-v2 into turn verification fix
Problem:
PR #9102 conflicted with the latest completion-summary presentation changes on main-v2, and the combined test fixture no longer satisfied the required Meta shape.

Root cause:
Both branches changed completion notice routing and the workspace summary at the same ownership points. A side-only resolution would either lose historical summary navigation or regress standard/delivery notice semantics.

Fix:
Preserve per-notice summaries, monotonic stale-safe reveal requests, repeat scrolling, and Files-to-Changes navigation while retaining neutral standard notices, scratch suppression, delivery attention, and quality-floor styling. Update the merged test fixture to construct a valid Meta value.

Verification:
Focused completion, controller, and workspace tests; workspace and transcript suites; production and test typechecks; hooks and CSS checks; frontend build and bundle budgets; repolint; diff check; real Chromium interaction verification.
2026-08-19 09:50:36 +08:00
SivanCola d6ee5cd12b Merge pull request #9096 from SivanCola/fix/scratch-vs-delivery-proof
fix(completion): keep scratch writes off the delivery attention card / 探路写入不再触发交付警告卡
2026-08-19 08:02:12 +08:00
SivanCola c6e26f43f1 Merge branch 'main-v2' into fix/scratch-vs-delivery-proof 2026-08-19 04:31:55 +08:00
ttmouse 99a4e1b7ac test(desktop): follow upstream App.tsx hydration prop for welcome suppression
Upstream now passes hydrating={runtimeTransitioning || transcriptHydrating};
the test asserted the pre-rebase exact prop.
2026-08-19 04:29:51 +08:00
SivanCola 7c2cc5f2f0 fix(desktop): route turn verification to its source summary
Problem
Historical completion notices could open no verification details or a later turn's summary, and repeated reveals in the Changed overview did not scroll.

Root cause
Completion notices shared a no-argument callback backed by the current summary, while the reveal effect returned after consuming requests even when navigation state was already settled.

Fix
Bind normalized summaries to completion notices, carry target and turn context through reveal requests, invalidate stale contexts, scroll same-view requests immediately, and extract the verification panel and tests to preserve lint budgets.

Verification
Ran focused completion and workspace verification tests, workspace and transcript suites, production build, TypeScript checks, CSS and hooks lint, repolint, diff checks, and a clean-console Chromium click/scroll check.
2026-08-19 04:25:25 +08:00
ttmouse af5cb03087 fix(desktop): AppChrome 传递 workspacePanelRenderable(rebase 后 prop 名对齐) 2026-08-19 04:20:50 +08:00
ttmouse 8526803d9b test(desktop): topicbar-controls 断言匹配 workspacePanel 变量重命名 2026-08-19 04:20:50 +08:00
ttmouse 1fca13f3cb fix(desktop): 响应 SivanCola review 二轮——周期转cron有损拒绝/草稿保护/schedule helper统一/cron校验完整化/runner路径/sidecar runHistory
- intervalToCron: 非整点窗口、2h+窗口拒绝转换;cycle 残留窗口忽略用自身时钟
- toggleEnabled 只持久化 saved enabled 保留草稿;handleTrigger 用 mergeEngineRunState 只合并引擎字段
- 整合 #8662 heartbeat.schedule.ts(月末 clamp/闰日/双周锚点/DST),taskNextRun 基于 lastRunAt,到期显示 dueSoon
- isCronExpr 前后端: 步长>0、范围 lo<=hi、dom/month 1 基、dow 0-7
- run-tests.mjs --import 绝对路径(pathToFileURL)
- runHistory 移入 sidecar(heartbeat-tasks.runs.json),读侧更高 schema 阻断
2026-08-19 04:20:50 +08:00
ttmouse 6f15585a72 fix(desktop): 修复 cron 边界/双周显示错位/i18n 硬编码
- cron: dow=7 按标准周日别名匹配(此前静默永不触发);dom/month 下限
  校验为 1(dom=0/month=0 不再被当作有效表达式);前后端 isCronExpr
  保持一致
- biweekly: 前端周锚改为与 Go 引擎一致的周一制(weekStart),修复周四~
  周日创建的双周任务下次运行显示差一周的问题
- cronToInterval: 拒绝有损转换(dom/dow/month 受限或定点时刻返回 null),
  切回 interval 不再把每周 cron 静默改写成 1h
- i18n: 移除「日」/全角括号/(untitled) 硬编码(新增 heartbeat.monthDay、
  heartbeat.cycleTimeAt 键),列表 join 用 joinComma;en unitDay/unitHour
  改为 d/h
- taskNextRun: next<=now 时显示 dueSoon;mainView 回退覆盖 shortcuts/
  export 弹窗;合并 runHistory 逻辑抽取 mergeRunHistory 去重
- 回归测试:Go TestCronDueDowSevenSundayAlias/TestIsCronExprFieldBounds,
  前端 dow=7、biweekly 周锚、cronToInterval、dom/month 边界用例
2026-08-19 04:20:50 +08:00
ttmouse bf4b6d8a43 fix(desktop): heartbeat 面板浅色背景恢复 #fafafa——新增 --heartbeat-split-bg 变量主题感知
- 响应 SivanCola review #3 时把 #fafafa 改为 var(--bg-soft),但浅色主题下
  --bg-soft 是 #eef2f7(浅蓝灰),与原近白 #fafafa 差异明显,面板背景被替换
- 新增 --heartbeat-split-bg:浅色(data-theme=light / OS 浅色)#fafafa,
  暗色(data-theme=dark / OS 深色)var(--bg) 跟随主题
- .heartbeat-split 与 .heartbeat-split__right 两处使用该变量
2026-08-19 04:20:50 +08:00
ttmouse e7905f8eef fix(desktop): runHistory 跨版本兼容——config 加 schemaVersion 前向保护(响应 SivanCola review #7)
- heartbeatConfig 加 SchemaVersion 字段(heartbeatSchemaVersion=2),写入时标记格式版本
- writeTasks 前向保护:拒绝用当前二进制整表保存覆盖更高 schemaVersion 的未来配置,避免静默降级丢 runHistory
- 明确迁移边界:v2 配置旧二进制读不报错但整表保存会丢 runHistory,升级单向
- 新增 TestHeartbeatConfigSchemaVersionWritten / TestHeartbeatConfigForwardProtection
2026-08-19 04:20:50 +08:00
ttmouse f6a92cd294 fix(desktop): 响应 SivanCola review——周期语义/推荐卡片权限/主题色/共享toggle/cron校验/CSS静态导入
- 周期任务 next-run 改用调度语义(nextCycleRunAt 镜像后端),不再转 cron
- intervalToCron 对 biweekly/秒级/跨午夜窗口返回 null(无法无损表达)
- 窗口结束时刻 exclusive(09:00-17:00 → 9-16)
- 推荐卡片改为打开编辑器,默认 enabled=false + approvalMode=ask
- heartbeat-split 硬编码 #fafafa → var(--bg-soft)(主题感知)
- 编辑器状态入口 + 右侧按钮共用 toggleEnabled helper(基于保存基线)
- isCronExpr 前后端加字段范围校验(拒绝 99 * * * *)
- taskNextRun 双调用去重;heartbeat.css 静态导入 + css-stub-register
- app-chrome-tabs 断言更新为 HeartbeatView;budget 调整
- 补周期转换守卫与周期 next-run 回归测试(14 项断言)
2026-08-19 04:20:50 +08:00
ttmouse c63259709d feat(desktop): 自动化面板整体优化——页面式布局 + 运行历史 + 分组视图 + 滚动条
将心跳任务面板从弹窗升级为页面式主视图(sidebar 入口 + mainView 状态机):

- 页面式布局:automation 视图填充主内容区,聊天/面板导航自动切回 chat
- 左右分栏:左栏任务列表(scope 分组、搜索、状态/范围过滤),右栏详情/编辑器,可拖拽分割(宽度持久化)
- 运行历史:每次成功执行记录 runHistory(At+TopicID,最多 20 条),详情页展示并支持跳转对应对话;旧任务无历史时用 topicId 合成一条
- 数据保护:前端整表保存时按 At union 磁盘 runHistory,防止旧快照清空引擎写入的执行历史(ReplaceTasks/ReplaceConfig 双路径)
- 状态同步:手动触发后详情页同步 runHistory/topicId/lastRunAt;列表开关同步详情 enabled;TaskEditor draft 跟随
- 分组视图 + 纯列表双视图切换
- 滚动条:顶部与搜索框齐平、滚动时显示/停止后淡出、颜色 35% 透明、淡出时 visibility 隐藏避免拦截点击
- 支持 cron 表达式调度 + 时间窗口
- Tooltip 增加 delay 参数;locale 预算调整;新增 heartbeat-next-run 测试
2026-08-19 04:20:50 +08:00
SivanCola a78c06df45 fix(completion): preserve failed-check attention
Problem:
A failed mutation check could be omitted from the completion attention decision, and the neutral change notice labeled mutation receipts as files.

Root cause:
Completion attention was evaluated before counting failed checks, while the frontend reused receipt-count mutations as a file count.

Fix:
Count contract check failures before computing attention, pass the count into the shared policy helper, and describe mutation receipts as changes in every locale. Add focused regressions for both contracts.

Verification:
- go test ./internal/agent ./internal/completion ./internal/taskcontract ./internal/checkpoint ./internal/cli -count=1
- go test -race ./internal/agent ./internal/completion ./internal/taskcontract ./internal/checkpoint ./internal/cli -count=1
- go test ./... -count=1
- go vet ./...
- go run ./tools/repolint
- cd desktop && go test ./... -count=1 && go vet ./...
- cd desktop/frontend && pnpm exec tsx src/__tests__/completion-summary-presentation.test.ts
- cd desktop/frontend && pnpm exec tsx src/__tests__/use-controller-stream-progress.test.ts && pnpm exec tsx src/__tests__/completion-summary-ui.test.tsx
- cd desktop/frontend && pnpm exec tsc --noEmit && pnpm build
2026-08-19 03:56:04 +08:00
SivanCola 725c1b0581 fix(desktop): make Turn verification open the change-panel summary
Problem: Users treated 本轮验证 as a second action on the quality notice.
Clicking it did nothing, so the orange card and 0/5 todos looked stuck.

Root cause: The notice only had View changes. The same words in the
workspace panel were a heading, not a control, and did not reveal the
summary when a file preview was selected.

Fix: Add a Turn verification button on completion notices that opens the
changed dock, returns to the overview, and scrolls to the summary. Keep
the in-panel title as a heading. Do not rerun checks or advance todos.

Verification: tsx src/__tests__/completion-summary-ui.test.tsx
tsx src/__tests__/workspace-changes-errors.test.tsx
tsc --noEmit
2026-08-19 03:46:33 +08:00
SivanCola 78f97e8c62 fix(delivery): keep scratch classification fail-closed
Problem:
Scratch-path inference could hide pathless Bash side effects, durable writes outside the workspace, and scratch symlink aliases into the workspace.

Root cause:
Delivery accounting treated script location as side-effect proof and equated non-workspace with non-delivery. Scratch classification used lexical paths only.

Fix:
Keep pathless Bash fail-closed, centralize delivery-mutation classification so only proven scratch is excluded, and resolve scratch aliases before assigning scope.

Verification:
- go test ./internal/evidence ./internal/completion ./internal/taskcontract ./internal/checkpoint ./internal/agent ./internal/cli -count=1
- go test -race ./internal/evidence ./internal/completion ./internal/taskcontract ./internal/checkpoint ./internal/agent ./internal/cli -count=1
- go test ./... -count=1
- go vet ./...
- go run ./tools/repolint
- GOOS=windows GOARCH=amd64 go test -exec=/usr/bin/true ./internal/evidence ./internal/checkpoint
2026-08-19 03:27:16 +08:00
SivanCola 1e3ba38550 fix: harden scratch delivery accounting
Problem:
Scratch execution could suppress rewind coverage incorrectly, while completion attention was reclassified by mutable client state. Platform paths, lint budgets, and locale budgets also kept CI red.

Root cause:
A command-token heuristic conflated script location with all side effects, write scope resolved symlinks before the checkpoint safety layer, and completion events omitted the turn-time policy decision.

Fix:
Keep checkpoint coverage fail-closed, add a narrow turn-local scratch delivery scope, classify workspace symlinks lexically for the safety layer, carry authoritative floor and attention fields, stabilize the macOS watcher test, and ratchet locale budgets by the measured delta.

Verification:
- go test ./... -count=1
- go test -race on evidence, completion, taskcontract, checkpoint, agent, and CLI packages
- go vet ./... in root and desktop modules
- go test ./... -count=1 in the desktop module
- Windows cross-compilation for evidence and checkpoint tests
- frontend completion tests, typecheck, production build, and bundle budgets
- go run ./tools/repolint
2026-08-19 02:41:54 +08:00
SivanCola bbb55c5318 fix(checkpoint): treat scratch paths as non-project coverage
Problem
Capturing /tmp probe scripts recorded outside_workspace, and bash that
only ran those scripts recorded bash_side_effect. Rewind then asked
the user to confirm "partial project coverage" for files that were
never in the workspace.

Root cause
CapturePath treated every path outside the workspace root as a project
hole. observeBeforeMutation recorded a bash gap whenever the tool could
not preview a workspace path.

Fix
Add GapScratch. Scratch preview paths and bash commands that only name
scratch files use that reason. Rewind confirmation ignores scratch-only
gaps so workspace restore is not blocked by probe scripts.

Verification
go test ./internal/checkpoint/ ./internal/agent/ ./internal/control/ ./internal/cli/ -count=1
2026-08-19 01:50:45 +08:00
SivanCola 825caa2a95 fix(desktop,cli): show change chips instead of scratch warnings
Problem
The transcript turned every quality-summary gap into an orange
"this turn still needs attention" card. Research turns that only
probed /tmp, and standard-floor workspace edits without review,
looked like failed delivery.

Root cause
completionSummaryNeedsAttention treated partial verdicts, unavailable
review, and any gap_kinds as interrupts. The card reused one generic
sentence for every case.

Fix
Align desktop and CLI with NeedsAttention. Failed checks and delivery
gaps keep a warning with specific copy. Workspace mutations on the
standard floor become a neutral "changes this turn" chip. Scratch-only
turns insert nothing.

Verification
go test ./internal/cli/ -count=1
pnpm exec tsx src/__tests__/use-controller-stream-progress.test.ts
pnpm exec tsx src/__tests__/completion-summary-ui.test.tsx
pnpm exec tsx src/__tests__/use-controller-meta.test.ts
2026-08-19 01:50:45 +08:00
SivanCola c96a2ecc3f fix(completion): count only workspace mutations for proof gaps
Problem
Any successful write, including /tmp probe scripts, incremented
completion Mutations and produced unverified_change / unreviewed_change
gaps. The host then emitted a quality summary that frontends treated as
attention.

Root cause
completion.Build and emitCompletionSummary counted every Write/Mutation
receipt. They did not ask WriteScope whether the path was a project file.

Fix
BuildAt filters Changes and Mutations to workspace paths. NeedsAttention
interrupts only for blocked turns, failed checks, unbacked claims, or
delivery-floor proof gaps. Standard unreviewed writes and scratch-only
turns stay quiet.

Verification
go test ./internal/completion/ ./internal/agent/ -count=1
2026-08-19 01:50:45 +08:00
SivanCola 7e16d2781b fix(evidence): classify scratch and outside write scope
Problem
Probe scripts written to /tmp or session temp were treated as project
writes. Delivery obligations and later quality cards then treated a
research turn as incomplete delivery.

Root cause
Writer classification only asked whether a path looked sensitive. It
had no notion of where the write landed, so /tmp/btc_klines.py and a
workspace source file used the same duty table.

Fix
Add WriteScope (workspace / scratch / outside). /tmp, /private/tmp, and
reasonix-session-tmp-* are scratch. Scratch writers create no
DiffReview or delivery-floor FullVerify duties. ClassifyEffect still
keeps WorkspaceWrite for mutation-risk scoring.

Verification
go test ./internal/evidence/ ./internal/taskcontract/ -count=1
2026-08-19 01:50:45 +08:00
zcxads666 28266bce75 fix(desktop): scope disabled provider-name styling and refine hint copy / 置灰样式仅限供应商名称输入框并优化提示文案 (#9064) 2026-08-18 10:54:16 +08:00
zcxads666andCursor 9904064811 fix(desktop): gray out locked provider name with a rename hint / 灰显供应商名称并提示暂不支持改名 (#9064)
Co-authored-by: Cursor <[email protected]>
2026-08-18 09:58:31 +08:00
chapter d1d277d350 fix(desktop): flush stream deltas on a stall timer / 修复思考中卡住、停止后才显示内容
Problem:
When requestAnimationFrame stopped firing (WebView2 window minimized or
occluded, main thread saturated by a long transcript), text/reasoning
deltas piled up in the rAF batch buffer indefinitely: nothing was flushed
until a non-stream event (e.g. Stop) called drain(). Users saw the
transcript frozen on "thinking…" with the whole reply appearing only
after pressing Stop, even though the backend had finished streaming.

Root cause:
createRafBatch only flushed on requestAnimationFrame callbacks and had no
fallback when frames stopped being produced.

Fix:
Arm a 200ms stall timer alongside the rAF handle. Whichever fires first
flushes; an earlier flush cancels the timer, so the visible path keeps its
one-flush-per-frame behavior and the fallback only engages while rAF is
stalled. Add a dedicated raf-batch test suite covering the visible path,
the stalled path, drain semantics, re-entrant pushes, and the microtask
fallback.

Verification:
- tsx src/__tests__/raf-batch.test.ts (19/19)
- tsx src/__tests__/stream-delta-batch.test.ts (20/20, regression)
- tsx src/__tests__/use-controller-stream-progress.test.ts (95/95, regression)
- tsc --noEmit
- eslint src/lib/rafBatch.ts src/__tests__/raf-batch.test.ts
2026-08-17 22:35:30 +08:00
Linearleaf 47266ee2e3 fix(desktop): make session-group header font larger than session titles
分组标题字号略大于普通会话标题,并放宽行高 / group header now uses --text-md with normal casing and taller padding (fixes #9030)
2026-08-17 17:51:14 +08:00
ttmouse 26026f6d30 fix(workspace): let code preview fill available height for large files / 修复大文件代码预览占满可用高度的问题
- Add max-height: none to .workspace-preview__body--code .code so the
  .code--scroll-y class (added for virtual-scrolled files) does not cap
  the preview at min(60vh, 28rem), which left the bottom half empty.
- Introduce codePreviewLayoutActive to keep the flex-column layout
  during code file loading, avoiding stale gaps when the viewer mounts.
2026-08-17 10:51:56 +08:00
Huang-404-Q c9b0cc6159 docs(cli): note the Ctrl+V text fallback in the clipboard section 2026-08-17 10:39:01 +08:00
Huang-404-Q 639c3be8dc fix(tui): fall back to text paste when the clipboard holds no image
Ctrl+V ran the image path unconditionally and blocked the whole paste when
no image was found, reporting a missing wl-paste/xclip even where both are
installed. Separate an empty clipboard from a missing tool, degrade to text
paste, and skip the fallback when the terminal already delivered a bracketed
paste for that key press.

Closes #8992
2026-08-17 10:34:26 +08:00
ttmouse 0b3e00c538 fix: add CSS overrides for pinned section Pin icon
- Replace dead section-title CSS with pinned folder icon rules
- Override :root[data-theme-style] display:none for nested SVGs
- Set icon to 14x14 with fg-faint color to match folder icon sizing
2026-07-16 17:25:54 +08:00
ttmouse a1a0684f94 feat: optimize pinned section in project tree sidebar
- Add Pin icon with folder styling to the 'Pinned' section header
- Always show pin/unpin action button in compact topic mode
  (remove unread status dependency so the button is consistently visible)
2026-07-16 17:25:54 +08:00
375 changed files with 33050 additions and 5689 deletions

No files matched your search

+2 -2
View File
@@ -7,7 +7,7 @@ body:
attributes:
value: |
Thanks for filing a bug. Pick which version line you're on below — it gets
labeled (`v1`/`v2`) automatically, no maintainer action needed.
labeled (`v2`/`v3`) automatically, no maintainer action needed.
- type: dropdown
id: version-line
attributes:
@@ -15,7 +15,7 @@ body:
description: Which line are you running?
options:
- "v2 — Go rewrite (1.x), main-v2 (active development)"
- "v1 — Legacy TypeScript (0.x), maintenance only"
- "v3 — Studio (2.x), studio"
validations:
required: true
- type: input
+1 -1
View File
@@ -10,7 +10,7 @@ body:
description: Which line is this for?
options:
- "v2 — Go rewrite (1.x), main-v2 (active development)"
- "v1 — Legacy TypeScript (0.x), maintenance only"
- "v3 — Studio (2.x), studio"
validations:
required: true
- type: textarea
+2
View File
@@ -389,8 +389,10 @@ jobs:
pnpm --dir frontend test:terminal
pnpm --dir frontend test:task-monitor
pnpm --dir frontend test:workspace
pnpm --dir frontend test:stream
pnpm --dir frontend test:motion
pnpm --dir frontend test:motion-browser
pnpm --dir frontend test:composer
pnpm --dir frontend test:transcript
pnpm --dir frontend test:transcript-browser
+23 -18
View File
@@ -2,7 +2,7 @@ name: Label issue version
# Issue forms can't let a reporter set a label directly (that needs triage rights),
# so the bug/feature forms carry a "Version line" dropdown and this workflow reads
# the submitted value and applies the matching v1/v2 label on their behalf.
# the submitted value and applies the matching v2/v3 label on their behalf.
on:
issues:
types: [opened, edited]
@@ -25,7 +25,8 @@ jobs:
const sectionOf = (name) =>
body.split(/^###\s+/m).find((s) => new RegExp(`^${name}`, 'i').test(s)) || '';
const m = sectionOf('Version line').match(/\bv([12])\b/i);
const lines = ['v1', 'v2', 'v3'];
const m = sectionOf('Version line').match(/\bv([23])\b/i);
if (!m) {
core.info('No version line found; nothing to label.');
return;
@@ -33,31 +34,35 @@ jobs:
let choice = 'v' + m[1];
// The dropdown is a choice, and v2 sits first, so it gets picked by
// reporters who are actually on 0.x. `Exact version` is pasted from
// `reasonix --version`, so when it parses it is the better evidence:
// 0.x is the TypeScript line, 1.x the Go rewrite. Anything that does
// not parse (a commit sha, "main-v2") leaves the dropdown to decide.
// reporters who are actually on the other line. `Exact version` is
// pasted from `reasonix --version`, so when it parses it is the
// better evidence: 1.x Go rewrite (v2), 2.x Studio (v3, studio
// branch). 0.x is no longer a filing line. Anything that does not
// parse (a commit sha, "main-v2", "studio") leaves the dropdown
// to decide.
const exact = sectionOf('Exact version').match(/\b(\d+)\.(\d+)\.(\d+)/);
if (exact) {
const fromVersion = Number(exact[1]) === 0 ? 'v1' : 'v2';
if (fromVersion !== choice) {
const major = Number(exact[1]);
const fromVersion = major === 1 ? 'v2' : major === 2 ? 'v3' : null;
if (fromVersion && fromVersion !== choice) {
core.info(`Version line says ${choice} but ${exact[0]} says ${fromVersion}; trusting the version.`);
choice = fromVersion;
}
}
const other = choice === 'v2' ? 'v1' : 'v2';
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.issue.number,
labels: [choice],
});
// Keep v1/v2 mutually exclusive in case an edit flipped the choice.
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: context.issue.number,
name: other,
});
} catch (e) {
core.info(`No ${other} label to remove.`);
// Keep version-line labels mutually exclusive if an edit flipped the choice.
for (const other of lines.filter((l) => l !== choice)) {
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: context.issue.number,
name: other,
});
} catch (e) {
core.info(`No ${other} label to remove.`);
}
}
+15 -14
View File
@@ -2,7 +2,7 @@ name: Label PR version
# Contributors (especially from forks) can't set labels themselves — that needs
# triage rights. A PR's version line is fully determined by its base branch, so
# this reads base.ref and applies v1/v2 on their behalf. pull_request_target runs
# this reads base.ref and applies v1/v2/v3 on their behalf. pull_request_target runs
# in the base repo's context so it has write access even for fork PRs; it only
# reads trusted metadata (the base ref) and never checks out or runs PR code.
on:
@@ -23,27 +23,28 @@ jobs:
- uses: actions/github-script@v9
with:
script: |
const lineByBase = { v1: 'v1', 'main-v2': 'v2', studio: 'v3' };
const lines = Object.values(lineByBase);
const base = context.payload.pull_request.base.ref;
const label = base === 'v1' ? 'v1'
: base === 'main-v2' ? 'v2'
: null;
const label = lineByBase[base] || null;
if (!label) {
core.info(`Base branch '${base}' isn't a release line; skipping.`);
return;
}
const other = label === 'v2' ? 'v1' : 'v2';
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.payload.pull_request.number,
labels: [label],
});
// Drop the other line's label if the base was changed after opening.
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: context.payload.pull_request.number,
name: other,
});
} catch (e) {
core.info(`No ${other} label to remove.`);
// Drop other line labels if the base was changed after opening.
for (const other of lines.filter((l) => l !== label)) {
try {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: context.payload.pull_request.number,
name: other,
});
} catch (e) {
core.info(`No ${other} label to remove.`);
}
}
+7 -2
View File
@@ -6,8 +6,13 @@ The transcript (`frontend/src/components/Transcript.tsx`, react-virtuoso) has
one structural rule set, earned across #8657/#8688 and the follow-up refactors.
Keep to it when touching anything that can move the transcript viewport.
- **Single writer**: only `frontend/src/lib/useTranscriptScrollArbiter.ts` may
call `virtuosoRef.current.scrollTo/scrollBy/scrollToIndex`. Everything else
- **Single writer**: only the scroll arbiter — `frontend/src/lib/useTranscriptScrollArbiter.ts`
and its extracted controllers (`transcriptTailSettle.ts`,
`transcriptAnchorCompensation.ts`) — may call
`virtuosoRef.current.scrollTo/scrollBy/scrollToIndex`, and raw
`scroller.scrollTop` assignments on transcript surfaces are equally
off-limits (route through the arbiter's `SCROLL_TO_OFFSET` channel, e.g.
owner `"anchor-compensation"` / `"block-window-prepend"`). Everything else
(jumps, tail-follow, selection edge scrolls, layout recovery) submits
requests to the arbiter. `frontend/scripts/check-single-scroll-writer.mjs`
enforces it statically; `lib/transcriptScrollProbe.ts` observes it at
+19 -2
View File
@@ -2982,6 +2982,8 @@ func channelDisplayName(provider, domain string) string {
return "WeChat"
case "qq":
return "QQ"
case "dingtalk":
return "DingTalk"
default:
return provider
}
@@ -3640,12 +3642,27 @@ func (a *App) ResumeSessionForTab(tabID, path string) ([]HistoryMessage, error)
return a.HistoryForTab(tab.ID), nil
}
// validateChannelSessionPath 校验 bot/channel 会话路径:channel 会话可能位于
// 当前 controller 的 session dir(project scope)或全局 session dir
// (global scope),单 tab 无法同时覆盖两者,因此都放行。
func validateChannelSessionPath(ctrlDir, path string) (string, string, error) {
if p, b, err := validateSessionPath(ctrlDir, path); err == nil {
return p, b, nil
}
if globalDir := config.SessionDir(); globalDir != "" && globalDir != ctrlDir {
if p, b, err := validateSessionPath(globalDir, path); err == nil {
return p, b, nil
}
}
return validateSessionPath(ctrlDir, path)
}
func (a *App) OpenChannelSessionForTab(tabID, path string) ([]HistoryMessage, error) {
tab, ctrl := a.tabAndCtrlByID(tabID)
if tab == nil || ctrl == nil {
return []HistoryMessage{}, fmt.Errorf("tab is not ready")
}
sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
sessionPath, _, err := validateChannelSessionPath(controllerSessionDir(ctrl), path)
if err != nil {
return nil, err
}
@@ -3667,7 +3684,7 @@ func (a *App) OpenChannelSessionPageForTab(tabID, path string, limit int) (Histo
if tab == nil || ctrl == nil {
return HistoryPage{}, fmt.Errorf("tab is not ready")
}
sessionPath, _, err := validateSessionPath(controllerSessionDir(ctrl), path)
sessionPath, _, err := validateChannelSessionPath(controllerSessionDir(ctrl), path)
if err != nil {
return HistoryPage{}, err
}
+18 -2
View File
@@ -43,6 +43,8 @@ type WorkspaceConflictView struct {
State string `json:"state"`
OwnerTabID string `json:"ownerTabId,omitempty"`
OwnerTitle string `json:"ownerTitle,omitempty"`
OwnerScope string `json:"ownerScope,omitempty"`
OwnerLabel string `json:"ownerLabel,omitempty"`
OwnerWork ActiveWorkView `json:"ownerWork"`
CanReveal bool `json:"canReveal"`
CanCreateWorktree bool `json:"canCreateWorktree"`
@@ -201,6 +203,7 @@ func (a *App) RevealBackgroundRuntime(tabID string) (TabMeta, error) {
type workspaceLeaseReporter interface {
WorkspaceLeaseState() workspacelease.State
WorkspaceLeaseHeldKeys() []string
}
func controllerWorkspaceLeaseState(ctrl control.SessionAPI) workspacelease.State {
@@ -210,6 +213,10 @@ func controllerWorkspaceLeaseState(ctrl control.SessionAPI) workspacelease.State
return workspacelease.State{}
}
func leaseDomainsOverlap(waitingRoot string, waiting workspacelease.State, holderRoot string, holder workspacelease.State) bool {
return workspacelease.LeaseStatesOverlap(waitingRoot, waiting, holderRoot, holder)
}
// WorkspaceConflictForTab classifies the owner that a Delivery writer is
// currently waiting for. An acquired process-local owner is actionable; when no
// local owner matches, the OS lock is treated as external.
@@ -233,7 +240,7 @@ func (a *App) WorkspaceConflictForTab(tabID string) WorkspaceConflictView {
return empty
}
targetState := controllerWorkspaceLeaseState(targetCtrl)
if !targetState.Waiting || targetState.Acquired {
if !targetState.Waiting {
return empty
}
targetRoot, err := workspacelease.CanonicalWorkspace(targetWorkspaceRoot)
@@ -266,11 +273,20 @@ func (a *App) WorkspaceConflictForTab(tabID string) WorkspaceConflictView {
for _, candidate := range candidates {
root, err := workspacelease.CanonicalWorkspace(candidate.root)
if err != nil || root != targetRoot || !controllerWorkspaceLeaseState(candidate.ctrl).Acquired {
ownerState := controllerWorkspaceLeaseState(candidate.ctrl)
if err != nil || !ownerState.Acquired {
continue
}
if !leaseDomainsOverlap(targetRoot, targetState, root, ownerState) {
continue
}
ownerScope, ownerLabel := ownerState.HeldScope, ownerState.HeldLabel
if ownerScope == "" {
ownerScope, ownerLabel = ownerState.Scope, ownerState.Label
}
return WorkspaceConflictView{
State: "local", OwnerTabID: candidate.id, OwnerTitle: candidate.title,
OwnerScope: ownerScope, OwnerLabel: ownerLabel,
OwnerWork: activeWorkForController(candidate.ctrl), CanReveal: true,
CanCreateWorktree: availability.Available,
}
+88 -1
View File
@@ -1,6 +1,8 @@
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/control"
@@ -13,6 +15,7 @@ type backgroundRuntimeController struct {
status control.RuntimeStatus
jobs []jobs.View
lease workspacelease.State
leaseKeys []string
cancelled []string
turnCancel int
}
@@ -32,7 +35,12 @@ func (c *backgroundRuntimeController) CancelJob(id string) bool {
}
func (c *backgroundRuntimeController) Cancel() { c.turnCancel++ }
func (c *backgroundRuntimeController) WorkspaceLeaseState() workspacelease.State {
return c.lease
state := c.lease
state.HeldKeys = append([]string(nil), c.leaseKeys...)
return state
}
func (c *backgroundRuntimeController) WorkspaceLeaseHeldKeys() []string {
return append([]string(nil), c.leaseKeys...)
}
func TestBackgroundRuntimeAPIsKeepDetachedJobsActionable(t *testing.T) {
@@ -135,3 +143,82 @@ func TestWorkspaceConflictIdentifiesExactLocalWriterWithoutIdentity(t *testing.T
t.Fatalf("owner work = %+v, want sanitized active work", conflict.OwnerWork)
}
}
func TestWorkspaceConflictMatchesTheWaitingFile(t *testing.T) {
root := t.TempDir()
aPath := filepath.Join(root, "a.go")
bPath := filepath.Join(root, "b.go")
waiterCtrl := &backgroundRuntimeController{lease: workspacelease.State{
Waiting: true, Scope: "file", Label: "a.go", WaitingKeys: []string{aPath},
}}
wrongCtrl := &backgroundRuntimeController{
lease: workspacelease.State{Acquired: true, Scope: "file", Label: "b.go"}, leaseKeys: []string{bPath},
}
rightCtrl := &backgroundRuntimeController{
lease: workspacelease.State{Acquired: true, Scope: "file", Label: "a.go"}, leaseKeys: []string{aPath},
}
app := &App{
tabs: map[string]*WorkspaceTab{
"waiter": {ID: "waiter", WorkspaceRoot: root, Ctrl: waiterCtrl},
"wrong": {ID: "wrong", WorkspaceRoot: root, Ctrl: wrongCtrl},
"right": {ID: "right", WorkspaceRoot: root, Ctrl: rightCtrl},
},
tabOrder: []string{"waiter", "wrong", "right"}, activeTabID: "waiter",
}
conflict := app.WorkspaceConflictForTab("waiter")
if conflict.State != "local" || conflict.OwnerTabID != "right" {
t.Fatalf("WorkspaceConflictForTab = %+v, want right file owner", conflict)
}
}
func TestWorkspaceConflictMatchesWaitingFileAcrossNestedWorkspaceRoots(t *testing.T) {
parent := t.TempDir()
nested := filepath.Join(parent, "nested")
if err := os.MkdirAll(filepath.Join(nested, ".git"), 0o700); err != nil {
t.Fatal(err)
}
path := filepath.Join(nested, "shared.go")
waiterCtrl := &backgroundRuntimeController{lease: workspacelease.State{
Waiting: true, Scope: "file", Label: "shared.go", WaitingKeys: []string{path},
}}
ownerCtrl := &backgroundRuntimeController{
lease: workspacelease.State{Acquired: true, Scope: "file", Label: "shared.go"},
leaseKeys: []string{path},
}
app := &App{
tabs: map[string]*WorkspaceTab{
"waiter": {ID: "waiter", WorkspaceRoot: parent, Ctrl: waiterCtrl},
"owner": {ID: "owner", WorkspaceRoot: nested, Ctrl: ownerCtrl},
},
tabOrder: []string{"waiter", "owner"}, activeTabID: "waiter",
}
conflict := app.WorkspaceConflictForTab("waiter")
if conflict.State != "local" || conflict.OwnerTabID != "owner" {
t.Fatalf("WorkspaceConflictForTab = %+v, want nested local owner", conflict)
}
}
func TestWorkspaceConflictAllowsAcquiredOwnerToWaitForAnotherFile(t *testing.T) {
root := t.TempDir()
aPath := filepath.Join(root, "a.go")
waiterCtrl := &backgroundRuntimeController{lease: workspacelease.State{
Acquired: true, Waiting: true, Scope: "file", Label: "a.go", WaitingKeys: []string{aPath},
}}
ownerCtrl := &backgroundRuntimeController{
lease: workspacelease.State{Acquired: true, Scope: "file", Label: "a.go"}, leaseKeys: []string{aPath},
}
app := &App{
tabs: map[string]*WorkspaceTab{
"waiter": {ID: "waiter", WorkspaceRoot: root, Ctrl: waiterCtrl},
"owner": {ID: "owner", WorkspaceRoot: root, Ctrl: ownerCtrl},
},
tabOrder: []string{"waiter", "owner"}, activeTabID: "waiter",
}
conflict := app.WorkspaceConflictForTab("waiter")
if conflict.State != "local" || conflict.OwnerTabID != "owner" {
t.Fatalf("WorkspaceConflictForTab = %+v, want local owner", conflict)
}
}
+57
View File
@@ -294,6 +294,63 @@ func (a *App) TestBotConnection(id, target string) (BotConnectionDiagnostic, err
return diag, nil
}
// TestDingtalkBot 向最近交互过的钉钉会话发送测试消息,验证凭据与发送链路。
func (a *App) TestDingtalkBot() (BotConnectionDiagnostic, error) {
cfg, err := a.loadDesktopBotConfig()
if err != nil {
return botConnectionDiagnostic(nil, "dingtalk", "error", "config", "config_load_failed", err.Error(), true), nil
}
conn, connID, connOK := dingtalkRuntimeConnection(cfg.Bot.Connections)
clientID := strings.TrimSpace(cfg.Bot.Dingtalk.ClientID)
if clientID == "" {
clientID = os.Getenv(strings.TrimSpace(cfg.Bot.Dingtalk.ClientIDEnv))
}
secret := strings.TrimSpace(cfg.Bot.Dingtalk.ClientSecret)
if secret == "" {
secret = os.Getenv(strings.TrimSpace(cfg.Bot.Dingtalk.SecretEnv))
}
if connOK {
if v := strings.TrimSpace(conn.Credential.AppID); v != "" {
clientID = v
}
if v := strings.TrimSpace(conn.Credential.AppSecretEnv); v != "" {
secret = os.Getenv(v)
}
}
if clientID == "" || secret == "" {
return botConnectionDiagnostic(nil, "dingtalk", "warning", "credential", "dingtalk_secret_missing", "钉钉凭据未配置完整(AppKey / AppSecret)。", false), nil
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
// domain 传空:匹配任意 domain 的钉钉 adapter(空 domain 视为匹配任意)。
result, err := a.botRuntime.TestSendToAdapter(ctx, connID, "", "Reasonix bot 测试消息:钉钉配置与发送链路可用。")
if err != nil {
if strings.Contains(err.Error(), "requires a known chat") {
return botConnectionDiagnostic(nil, "dingtalk", "warning", "send", "dingtalk_test_send_no_chat", "还没有可发送的钉钉会话:请先在钉钉中给机器人发一条消息,之后即可发送测试消息。", false), nil
}
return botConnectionDiagnostic(nil, "dingtalk", "error", "send", "dingtalk_test_send_failed", err.Error(), true), nil
}
diag := botConnectionDiagnostic(nil, "dingtalk", "ok", "send", "dingtalk_test_send_ok", "测试消息已发送,请检查钉钉会话。", false)
diag.MessageID = result.MessageID
return diag, nil
}
// dingtalkRuntimeConnection 返回第一个启用的钉钉 connection 与运行时 id。
func dingtalkRuntimeConnection(connections []config.BotConnectionConfig) (config.BotConnectionConfig, string, bool) {
for _, conn := range connections {
if !conn.Enabled || strings.TrimSpace(conn.Provider) != string(bot.PlatformDingtalk) {
continue
}
return conn, botruntime.ConnectionRuntimeID(conn), true
}
return config.BotConnectionConfig{}, string(bot.PlatformDingtalk), false
}
func dingtalkRuntimeConnectionID(connections []config.BotConnectionConfig) string {
_, id, _ := dingtalkRuntimeConnection(connections)
return id
}
func botConnectionDiagnostic(conn *config.BotConnectionConfig, id, status, phase, code, message string, reportable bool) BotConnectionDiagnostic {
id = strings.TrimSpace(id)
label := ""
+85 -21
View File
@@ -15,11 +15,12 @@ import (
)
type BotRuntimeStatusView struct {
Running bool `json:"running"`
Status string `json:"status"`
Message string `json:"message"`
Connections int `json:"connections"`
StartedAt string `json:"startedAt"`
Running bool `json:"running"`
Status string `json:"status"`
Message string `json:"message"`
Connections int `json:"connections"`
StartedAt string `json:"startedAt"`
Platforms map[string]string `json:"platforms,omitempty"`
}
type desktopBotRuntime struct {
@@ -62,6 +63,34 @@ func desktopBotChannelsWithLegacyQQ(qq config.QQBotConfig, channels map[bot.Plat
return channels, connectionChannels
}
// desktopBotChannelsWithLegacyDingtalk 把 legacy [bot.dingtalk] 的模型/权限/
// 工作目录合成进 Channels 与 ConnectionChannels,使直配(无 connection)的
// 钉钉 bot 也能从设置面板配置这些运行选项(与 legacy QQ 同路径)。
func desktopBotChannelsWithLegacyDingtalk(dt config.DingtalkBotConfig, channels map[bot.Platform]bot.ChannelConfig, connectionChannels map[string]bot.ChannelConfig) (map[bot.Platform]bot.ChannelConfig, map[string]bot.ChannelConfig) {
channel := bot.ChannelConfig{
Model: strings.TrimSpace(dt.Model),
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(dt.ToolApprovalMode),
WorkspaceRoot: strings.TrimSpace(dt.WorkspaceRoot),
SessionMappings: botruntime.SessionMappings(dt.SessionMappings),
}
if channel.Model == "" && channel.ToolApprovalMode == "" && channel.WorkspaceRoot == "" && len(channel.SessionMappings) == 0 {
return channels, connectionChannels
}
if channels == nil {
channels = make(map[bot.Platform]bot.ChannelConfig)
}
if _, ok := channels[bot.PlatformDingtalk]; !ok {
channels[bot.PlatformDingtalk] = channel
}
if connectionChannels == nil {
connectionChannels = make(map[string]bot.ChannelConfig)
}
if _, ok := connectionChannels[string(bot.PlatformDingtalk)]; !ok {
connectionChannels[string(bot.PlatformDingtalk)] = channel
}
return channels, connectionChannels
}
func (a *App) refreshBotRuntimeAsync() {
if a.ctx == nil {
return
@@ -144,6 +173,7 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
channels := botruntime.ChannelConfigs(cfg.Bot.Connections, true, true)
connectionChannels := botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true)
channels, connectionChannels = desktopBotChannelsWithLegacyQQ(cfg.Bot.QQ, channels, connectionChannels)
channels, connectionChannels = desktopBotChannelsWithLegacyDingtalk(cfg.Bot.Dingtalk, channels, connectionChannels)
gwCfg := bot.GatewayConfig{
Model: modelName,
ToolApprovalMode: cfg.Bot.ToolApprovalMode,
@@ -156,9 +186,10 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
PairingMaxPending: cfg.Bot.Pairing.MaxPendingPerPlatform,
IgnoreSelfMessages: cfg.Bot.IgnoreSelfMessages,
SelfUserIDs: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.SelfUserIDs.QQ,
bot.PlatformFeishu: cfg.Bot.SelfUserIDs.Feishu,
bot.PlatformWeixin: cfg.Bot.SelfUserIDs.Weixin,
bot.PlatformQQ: cfg.Bot.SelfUserIDs.QQ,
bot.PlatformFeishu: cfg.Bot.SelfUserIDs.Feishu,
bot.PlatformWeixin: cfg.Bot.SelfUserIDs.Weixin,
bot.PlatformDingtalk: cfg.Bot.SelfUserIDs.Dingtalk,
},
ControlEnabled: cfg.Bot.Control.Enabled,
ControlAddr: cfg.Bot.Control.Addr,
@@ -173,27 +204,32 @@ func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, wo
Enabled: cfg.Bot.Allowlist.Enabled,
AllowAll: cfg.Bot.Allowlist.AllowAll,
Users: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.Allowlist.QQUsers,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuUsers,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinUsers,
bot.PlatformQQ: cfg.Bot.Allowlist.QQUsers,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuUsers,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinUsers,
bot.PlatformDingtalk: cfg.Bot.Allowlist.DingtalkUsers,
},
Approvers: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.Allowlist.QQApprovers,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuApprovers,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinApprovers,
bot.PlatformQQ: cfg.Bot.Allowlist.QQApprovers,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuApprovers,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinApprovers,
bot.PlatformDingtalk: cfg.Bot.Allowlist.DingtalkApprovers,
},
Admins: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.Allowlist.QQAdmins,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuAdmins,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinAdmins,
bot.PlatformQQ: cfg.Bot.Allowlist.QQAdmins,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuAdmins,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinAdmins,
bot.PlatformDingtalk: cfg.Bot.Allowlist.DingtalkAdmins,
},
Groups: map[bot.Platform][]string{
bot.PlatformQQ: cfg.Bot.Allowlist.QQGroups,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuGroups,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinGroups,
bot.PlatformQQ: cfg.Bot.Allowlist.QQGroups,
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuGroups,
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinGroups,
bot.PlatformDingtalk: cfg.Bot.Allowlist.DingtalkGroups,
},
},
Debounce: time.Duration(cfg.Bot.DebounceMs) * time.Millisecond,
ModelResolver: botruntime.ModelResolver(cfg),
OnInbound: botruntime.NewRemoteRememberer(logger),
OnSessionReady: botruntime.NewSessionRemembererWithWorkspace(logger, workspaceRoot),
OnToolApprovalModeChange: onToolApprovalModeChange,
@@ -335,7 +371,23 @@ func (r *desktopBotRuntime) setStatus(status BotRuntimeStatusView) {
func (r *desktopBotRuntime) snapshot() BotRuntimeStatusView {
r.mu.Lock()
defer r.mu.Unlock()
return r.status
s := r.status
if r.gw != nil {
s.Platforms = botAdapterPlatformStatuses(r.gw.AdapterHealth())
}
return s
}
// botAdapterPlatformStatuses 把 gateway 的适配器健康快照收敛为
// platform → status 映射(如 dingtalk → running),供设置面板显示在线状态。
func botAdapterPlatformStatuses(health []bot.AdapterHealthSnapshot) map[string]string {
out := make(map[string]string, len(health))
for _, h := range health {
if strings.TrimSpace(string(h.Platform)) != "" {
out[string(h.Platform)] = h.Status
}
}
return out
}
// updateConnectionToolApprovalMode updates a connection's tool approval mode
@@ -367,6 +419,18 @@ func (r *desktopBotRuntime) SendToAdapter(ctx context.Context, connID, domain st
return gw.SendToAdapter(ctx, connID, domain, msg)
}
// TestSendToAdapter sends a test message through the running gateway's adapter
// identified by connID. The adapter must implement bot.TestSender (dingtalk).
func (r *desktopBotRuntime) TestSendToAdapter(ctx context.Context, connID, domain, text string) (bot.SendResult, error) {
r.mu.Lock()
gw := r.gw
r.mu.Unlock()
if gw == nil {
return bot.SendResult{}, fmt.Errorf("bot runtime is not running")
}
return gw.TestSendToAdapter(ctx, connID, domain, text)
}
// Running returns true if the bot gateway is currently active.
func (r *desktopBotRuntime) Running() bool {
r.mu.Lock()
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/config"
"reasonix/internal/control"
)
// TestOpenChannelSessionGlobalBotAcrossDirs 回归:bot/channel 会话可能位于
// 全局 session dir(global scope),而 tab 的 controller 位于项目 dir。
// OpenChannelSession* 必须放行跨目录的 channel 会话,否则前端报
// 「加载会话历史失败」(session path outside session dir)。
func TestOpenChannelSessionGlobalBotAcrossDirs(t *testing.T) {
isolateDesktopUserDirs(t)
globalDir := config.SessionDir()
if err := os.MkdirAll(globalDir, 0o755); err != nil {
t.Fatalf("mkdir global: %v", err)
}
botPath := filepath.Join(globalDir, "bot-abc123.jsonl")
if err := os.WriteFile(botPath, []byte(`{"role":"user","content":"from channel"}`+"\n"), 0o644); err != nil {
t.Fatalf("write bot session: %v", err)
}
// tab 的 controller 位于"项目"session dir,与全局 bot 会话不同目录。
projDir := filepath.Join(globalDir, "..", "project-sessions")
if err := os.MkdirAll(projDir, 0o755); err != nil {
t.Fatalf("mkdir proj: %v", err)
}
app := NewApp()
ctrl := control.New(control.Options{SessionDir: projDir, SessionPath: filepath.Join(projDir, "active.jsonl"), Label: "test"})
app.setTestCtrl(ctrl, "")
defer app.activeCtrl().Close()
if _, err := app.OpenChannelSessionForTab("test", botPath); err != nil {
t.Fatalf("open global bot session from project tab: %v", err)
}
if _, err := app.OpenChannelSessionPageForTab("test", botPath, 60); err != nil {
t.Fatalf("open paged global bot session: %v", err)
}
}
+191
View File
@@ -0,0 +1,191 @@
package main
import (
"testing"
"reasonix/internal/config"
)
// TestSetBotSettingsDingtalkRoundTrip 验证钉钉配置经 SetBotSettings 后能正确落盘并被读回。
// 这是排查「钉钉配置界面几个字段写进去无法保存」的回归测试。
func TestSetBotSettingsDingtalkRoundTrip(t *testing.T) {
isolateDesktopUserDirs(t)
cfg := config.Default()
cfg.Bot.Dingtalk = config.DingtalkBotConfig{
Enabled: true,
ClientID: "appkey-initial",
SecretEnv: "DINGTALK_CLIENT_SECRET",
BotName: "InitialBot",
RequireMention: true,
SessionMappings: []config.BotConnectionSessionMapping{{
RemoteID: "cid-preserved",
Scope: "global",
SessionID: "path:/sessions/preserved.jsonl",
SessionSource: "auto",
}},
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save initial: %v", err)
}
app := NewApp()
view := botSettingsView(cfg.Bot)
view.Dingtalk.ClientID = "appkey-edited"
view.Dingtalk.BotName = "EditedBot"
view.Dingtalk.RequireMention = false
view.Dingtalk.ClientSecretEnv = "DINGTALK_CLIENT_SECRET"
if err := app.SetBotSettings(view); err != nil {
t.Fatalf("SetBotSettings: %v", err)
}
got := config.LoadForEditWithoutCredentials(config.UserConfigPath())
if got.Bot.Dingtalk.ClientID != "appkey-edited" {
t.Fatalf("clientId = %q, want appkey-edited", got.Bot.Dingtalk.ClientID)
}
if got.Bot.Dingtalk.BotName != "EditedBot" {
t.Fatalf("botName = %q, want EditedBot", got.Bot.Dingtalk.BotName)
}
if got.Bot.Dingtalk.RequireMention {
t.Fatalf("requireMention = true, want false")
}
if got.Bot.Dingtalk.SecretEnv != "DINGTALK_CLIENT_SECRET" {
t.Fatalf("secretEnv = %q, want DINGTALK_CLIENT_SECRET", got.Bot.Dingtalk.SecretEnv)
}
if mappings := got.Bot.Dingtalk.SessionMappings; len(mappings) != 1 || mappings[0].RemoteID != "cid-preserved" || mappings[0].SessionID != "path:/sessions/preserved.jsonl" {
t.Fatalf("session mappings = %+v, want hidden legacy mapping preserved", mappings)
}
}
// TestDesktopBotConfigConfiguredDetectsDingtalk 防止回归:用户只配置钉钉(其他平台
// 均未配置)时,desktopBotConfigConfigured 必须返回 true,否则 legacy 合并路径会
// 用 legacy config 整体覆盖 userCfg.Bot,导致钉钉 ClientID 等配置被丢弃
// (表现为「输入 ClientID 后消失」)。
func TestDesktopBotConfigConfiguredDetectsDingtalk(t *testing.T) {
cfg := config.Default()
cfg.Bot.Dingtalk.ClientID = "dingappkey123"
if !desktopBotConfigConfigured(cfg.Bot) {
t.Fatalf("dingtalk-only bot config must count as configured")
}
// 仅 SecretEnv 配了也应算已配置(环境变量形式的密钥)。
cfg = config.Default()
cfg.Bot.Dingtalk.SecretEnv = "DINGTALK_CLIENT_SECRET"
if !desktopBotConfigConfigured(cfg.Bot) {
t.Fatalf("dingtalk secret-env-only config must count as configured")
}
}
// TestDingtalkRuntimeConnectionID 验证测试发送时解析到真实运行的 connection id
// (自定义 id 如 dingtalk-test 必须命中),而不是硬编码退回 "dingtalk"。
func TestDingtalkRuntimeConnectionID(t *testing.T) {
// 自定义 id:优先命中启用的钉钉连接。
got := dingtalkRuntimeConnectionID([]config.BotConnectionConfig{
{ID: "feishu-feishu", Provider: "feishu", Enabled: true},
{ID: "dingtalk-test", Provider: "dingtalk", Enabled: true},
})
if got != "dingtalk-test" {
t.Fatalf("runtime connection id = %q, want dingtalk-test", got)
}
// 未启用:跳过。
got = dingtalkRuntimeConnectionID([]config.BotConnectionConfig{
{ID: "dingtalk-disabled", Provider: "dingtalk", Enabled: false},
})
if got != "dingtalk" {
t.Fatalf("disabled connection must be skipped, got %q", got)
}
// 无 id 的钉钉连接:退回 provider 名。
got = dingtalkRuntimeConnectionID([]config.BotConnectionConfig{
{Provider: "dingtalk", Enabled: true},
})
if got != "dingtalk" {
t.Fatalf("id-less connection should fall back to %q, got %q", "dingtalk", got)
}
// 无任何钉钉连接:退回 "dingtalk"(legacy [bot.dingtalk] 路径)。
got = dingtalkRuntimeConnectionID(nil)
if got != "dingtalk" {
t.Fatalf("no connection should fall back to %q, got %q", "dingtalk", got)
}
}
// TestDingtalkRuntimeConnectionResolvesCredentials 验证测试发送凭据从所选
// connection 解析(而不是 legacy [bot.dingtalk] 块)——否则 connection 驱动的
// 运行时在 legacy 块为空时误报 dingtalk_secret_missing。
func TestDingtalkRuntimeConnectionResolvesCredentials(t *testing.T) {
conns := []config.BotConnectionConfig{
{ID: "feishu-feishu", Provider: "feishu", Enabled: true},
{ID: "dingtalk-main", Provider: "dingtalk", Enabled: true,
Credential: config.BotConnectionCredential{AppID: "app-from-conn", AppSecretEnv: "DINGTALK_CONN_SECRET"}},
}
conn, id, ok := dingtalkRuntimeConnection(conns)
if !ok {
t.Fatal("enabled dingtalk connection not found")
}
if id != "dingtalk-main" {
t.Fatalf("runtime id = %q, want dingtalk-main", id)
}
if conn.Credential.AppID != "app-from-conn" || conn.Credential.AppSecretEnv != "DINGTALK_CONN_SECRET" {
t.Fatalf("resolved connection credentials = %q/%q, want app-from-conn/DINGTALK_CONN_SECRET",
conn.Credential.AppID, conn.Credential.AppSecretEnv)
}
// 未启用的钉钉连接不应命中。
if _, _, ok := dingtalkRuntimeConnection([]config.BotConnectionConfig{
{ID: "dingtalk-off", Provider: "dingtalk", Enabled: false},
}); ok {
t.Fatal("disabled dingtalk connection must not be selected")
}
}
// TestSetBotSettingsDingtalkRuntimeOptionsRoundTrip 验证 legacy [bot.dingtalk]
// 的模型/工具审批模式/工作目录经 SetBotSettings 落盘并被读回。这是排查
// 「钉钉面板模型、权限切换无法保存」的回归测试:config 结构体与 settings
// view 都支持这些字段,但 render.go 漏写导致 SaveTo 丢弃(已被修复)。
func TestSetBotSettingsDingtalkRuntimeOptionsRoundTrip(t *testing.T) {
isolateDesktopUserDirs(t)
cfg := config.Default()
cfg.Bot.Dingtalk = config.DingtalkBotConfig{
Enabled: true,
ClientID: "ding-appkey",
ClientSecret: "secret",
SecretEnv: "DINGTALK_CLIENT_SECRET",
BotName: "安博特",
RequireMention: true,
Model: "deepseek-flash/deepseek-v4-flash",
ToolApprovalMode: "yolo",
WorkspaceRoot: "/tmp/probe-ws",
}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save initial: %v", err)
}
app := NewApp()
view := botSettingsView(cfg.Bot)
if view.Dingtalk.Model != "deepseek-flash/deepseek-v4-flash" {
t.Fatalf("view model = %q, want deepseek-flash", view.Dingtalk.Model)
}
if view.Dingtalk.ToolApprovalMode != "yolo" {
t.Fatalf("view approval = %q, want yolo", view.Dingtalk.ToolApprovalMode)
}
view.Dingtalk.Model = "deepseek-flash/deepseek-v4-flash"
view.Dingtalk.ToolApprovalMode = "yolo"
view.Dingtalk.WorkspaceRoot = "/tmp/probe-ws"
if err := app.SetBotSettings(view); err != nil {
t.Fatalf("SetBotSettings: %v", err)
}
got := config.LoadForEditWithoutCredentials(config.UserConfigPath())
if got.Bot.Dingtalk.Model != "deepseek-flash/deepseek-v4-flash" {
t.Fatalf("model = %q, want deepseek-flash (render must persist)", got.Bot.Dingtalk.Model)
}
if got.Bot.Dingtalk.ToolApprovalMode != "yolo" {
t.Fatalf("toolApprovalMode = %q, want yolo (render must persist)", got.Bot.Dingtalk.ToolApprovalMode)
}
if got.Bot.Dingtalk.WorkspaceRoot != "/tmp/probe-ws" {
t.Fatalf("workspaceRoot = %q, want /tmp/probe-ws (render must persist)", got.Bot.Dingtalk.WorkspaceRoot)
}
// 原有字段不能丢。
if got.Bot.Dingtalk.ClientSecret != "secret" {
t.Fatalf("clientSecret lost: %q", got.Bot.Dingtalk.ClientSecret)
}
if got.Bot.Dingtalk.BotName != "安博特" {
t.Fatalf("botName lost: %q", got.Bot.Dingtalk.BotName)
}
}
+24
View File
@@ -0,0 +1,24 @@
package main
import (
"strings"
"reasonix/internal/config"
)
func dingtalkConfigFromView(view DingtalkBotView, current config.DingtalkBotConfig) config.DingtalkBotConfig {
return config.DingtalkBotConfig{
Enabled: view.Enabled,
ClientID: strings.TrimSpace(view.ClientID),
ClientSecret: current.ClientSecret,
ClientIDEnv: current.ClientIDEnv,
SecretEnv: strings.TrimSpace(view.ClientSecretEnv),
BotName: strings.TrimSpace(view.BotName),
RequireMention: view.RequireMention,
Model: strings.TrimSpace(view.Model),
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode),
WorkspaceRoot: strings.TrimSpace(view.WorkspaceRoot),
Access: botAccessConfigFromView(view.Access),
SessionMappings: append([]config.BotConnectionSessionMapping(nil), current.SessionMappings...),
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ func historyLocalOnlyRows(m provider.Message) ([]HistoryMessage, bool) {
if recovery := m.FinalReadinessRecovery; recovery != nil && recovery.Pending {
return []HistoryMessage{{
Role: "notice", Code: event.NoticeCodeFinalReadiness, Level: "info", Pending: true,
Content: "Task status needs one more check; continue the remaining work.",
Content: "Task is not complete; continue the remaining work or checks.",
Readiness: &event.FinalReadiness{
Attempts: 1,
Missing: append([]string(nil), recovery.Missing...),
@@ -208,8 +208,14 @@ try {
const settle = () => frames-- <= 0 ? resolve() : requestAnimationFrame(settle);
requestAnimationFrame(settle);
}));
await page.mouse.move(hydrationBox.x + hydrationBox.width / 2, hydrationBox.y + hydrationBox.height / 2);
await page.mouse.wheel(0, 100_000);
await moveToOuterReaderGutter(page, hydrationTranscript);
for (let attempt = 0; attempt < 32; attempt += 1) {
await page.mouse.wheel(0, 10_000);
const atBottom = await hydrationTranscript.evaluate((element) =>
element.scrollHeight - element.scrollTop - element.clientHeight <= 1);
if (atBottom) break;
await page.waitForTimeout(16);
}
await page.waitForFunction(() => {
const element = document.querySelector(".transcript");
return element instanceof HTMLElement
@@ -488,16 +494,13 @@ try {
assert(beforeGrowth.anchorKey && beforeGrowth.grownKey, "bench exposes a visible anchor and mounted dynamic row above it");
const gestureStart = beforeGrowth.top;
await transcript.evaluate((element) => {
const viewport = element.getBoundingClientRect();
const rows = [...element.querySelectorAll(".transcript__row")];
const visible = rows
.filter((row) => row.getBoundingClientRect().bottom > viewport.top && row.getBoundingClientRect().top < viewport.bottom)
.sort((left, right) => left.getBoundingClientRect().top - right.getBoundingClientRect().top);
const above = rows
.filter((row) => row.getBoundingClientRect().bottom <= viewport.top)
.sort((left, right) => right.getBoundingClientRect().bottom - left.getBoundingClientRect().bottom)[0];
if (above instanceof HTMLElement) above.style.paddingBottom = `${Number.parseFloat(above.style.paddingBottom || "0") + 1200}px`;
await transcript.evaluate((element, grownKey) => {
const above = [...element.querySelectorAll(".transcript__row")]
.find((row) => row.dataset.rowKey === grownKey);
if (above instanceof HTMLElement) {
above.dataset.growthReplayBasePadding = above.style.paddingBottom;
above.style.paddingBottom = `${Number.parseFloat(above.style.paddingBottom || "0") + 1200}px`;
}
window.__reasonixScrollSamples = [];
const sample = () => {
const currentRows = [...element.querySelectorAll(".transcript__row")];
@@ -510,7 +513,7 @@ try {
};
element.addEventListener("scroll", sample, { passive: true });
sample();
});
}, beforeGrowth.grownKey);
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
const afterGrowth = await transcript.evaluate((element) => ({
top: element.scrollTop,
@@ -518,6 +521,120 @@ try {
}));
assert(afterGrowth.top >= gestureStart - 2, `dynamic measurement never reverses an upward gesture into a multi-screen jump (${gestureStart} → ${afterGrowth.top})`);
assert(afterGrowth.samples.every((sample) => sample.occupied), "dynamic measurement never exposes a blank transcript viewport");
await transcript.evaluate((element, grownKey) => {
const grown = [...element.querySelectorAll(".transcript__row")]
.find((row) => row.dataset.rowKey === grownKey);
if (!(grown instanceof HTMLElement)) return;
grown.style.paddingBottom = grown.dataset.growthReplayBasePadding || "";
delete grown.dataset.growthReplayBasePadding;
}, beforeGrowth.grownKey);
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
// Replay the returned 146-row Windows failure with real DOM geometry. A
// downward wheel lands while the tail extent briefly loses 2,500px and regains
// it 50ms later. Native clamping may move for the collapsed frame, but the
// rebound must restore the user's logical direction without blank recovery.
await transcript.evaluate((element) => {
element.dataset.extentReplayOverflowAnchor = element.style.overflowAnchor;
element.style.overflowAnchor = "none";
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight - 500);
element.dispatchEvent(new Event("scroll"));
});
await page.waitForTimeout(100);
await transcript.evaluate((element) => new Promise((resolve) => {
const style = document.createElement("style");
style.id = "reader-extent-replay-style";
style.textContent = '.transcript[data-extent-replay-expanded="true"] > [data-viewport-type="element"]::after { content: ""; display: block; height: 2500px; }';
document.head.append(style);
element.dataset.extentReplayExpanded = "true";
let stableFrames = 0;
let previousHeight = element.scrollHeight;
const settle = () => {
const currentHeight = element.scrollHeight;
stableFrames = Math.abs(currentHeight - previousHeight) <= 0.5 ? stableFrames + 1 : 0;
previousHeight = currentHeight;
if (stableFrames >= 4) {
resolve();
return;
}
requestAnimationFrame(settle);
};
requestAnimationFrame(settle);
}));
await page.waitForTimeout(100);
await moveToOuterReaderGutter(page, transcript);
const beforeExtentReplay = await transcript.evaluate((element) => {
const viewport = element.getBoundingClientRect();
const rows = [...element.querySelectorAll(".transcript__row")];
const visible = rows
.filter((row) => row.getBoundingClientRect().bottom > viewport.top && row.getBoundingClientRect().top < viewport.bottom)
.sort((left, right) => left.getBoundingClientRect().top - right.getBoundingClientRect().top);
const anchor = visible.find((row) => row.getBoundingClientRect().top >= viewport.top) ?? visible[0];
window.__readerExtentProbe = { writes: [], samples: [], done: false };
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => window.__readerExtentProbe.writes.push(write);
const sample = () => {
const current = document.querySelector(".transcript");
if (!(current instanceof HTMLElement)) return;
const rect = current.getBoundingClientRect();
const occupied = [...current.querySelectorAll(".transcript__row")].some((row) => {
const rowRect = row.getBoundingClientRect();
return rowRect.bottom > rect.top && rowRect.top < rect.bottom;
});
window.__readerExtentProbe.samples.push({ top: current.scrollTop, height: current.scrollHeight, occupied });
if (!window.__readerExtentProbe.done) requestAnimationFrame(sample);
};
element.addEventListener("wheel", () => requestAnimationFrame(() => {
delete element.dataset.extentReplayExpanded;
const collapsedTop = Math.max(0, element.scrollTop - 2_000);
element.scrollTop = collapsedTop;
setTimeout(() => {
element.dataset.extentReplayExpanded = "true";
// Chromium restores its own scroll anchor on macOS. Keep the returned
// WebView2 trace's clamped landing so this replay exercises our guard.
element.scrollTop = collapsedTop;
}, 50);
}), { capture: true, once: true });
requestAnimationFrame(sample);
return {
top: element.scrollTop,
anchorKey: anchor?.dataset.rowKey ?? null,
anchorOffset: anchor ? anchor.getBoundingClientRect().top - viewport.top : null,
};
});
assert(beforeExtentReplay.anchorKey, "extent replay starts from a visible logical anchor");
await page.mouse.wheel(0, 133);
await page.waitForTimeout(260);
const afterExtentReplay = await transcript.evaluate((element, before) => {
window.__readerExtentProbe.done = true;
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = undefined;
element.style.overflowAnchor = element.dataset.extentReplayOverflowAnchor || "";
delete element.dataset.extentReplayOverflowAnchor;
const anchor = [...element.querySelectorAll(".transcript__row")]
.find((row) => row.dataset.rowKey === before.anchorKey);
return {
top: element.scrollTop,
anchorOffset: anchor ? anchor.getBoundingClientRect().top - element.getBoundingClientRect().top : null,
writes: window.__readerExtentProbe.writes,
samples: window.__readerExtentProbe.samples,
mode: element.dataset.scrollMode,
};
}, beforeExtentReplay);
const readerStabilityWrites = afterExtentReplay.writes.filter((write) => write.owner === "reader-stability");
const preservedDirection = afterExtentReplay.top >= beforeExtentReplay.top - 2;
assert(preservedDirection, preservedDirection
? `transient extent rebound cannot reverse a downward wheel (${beforeExtentReplay.top} → ${afterExtentReplay.top})`
: `transient extent rebound cannot reverse a downward wheel (${beforeExtentReplay.top} → ${afterExtentReplay.top}; anchor=${beforeExtentReplay.anchorOffset}→${afterExtentReplay.anchorOffset}; mode=${afterExtentReplay.mode}; writes=${JSON.stringify(afterExtentReplay.writes)}; samples=${JSON.stringify(afterExtentReplay.samples)})`);
assert(readerStabilityWrites.length === 1, readerStabilityWrites.length === 1
? "transient extent rebound uses one bounded reader-stability write"
: `transient extent rebound uses one bounded reader-stability write (${readerStabilityWrites.length}; ${JSON.stringify(afterExtentReplay.samples)})`);
assert(afterExtentReplay.writes.every((write) => write.owner !== "recovery"),
"nonblank extent rebound never enters blank recovery");
assert(afterExtentReplay.samples.every((sample) => sample.occupied),
"transient extent rebound keeps mounted coverage in every sampled frame");
await transcript.evaluate((element) => {
delete element.dataset.extentReplayExpanded;
document.getElementById("reader-extent-replay-style")?.remove();
});
// Rapid direction changes are the exact user report. Sample every frame and
// require that Virtuoso always maintains mounted coverage. Keep a real
@@ -587,8 +704,8 @@ try {
// A native scrollbar thumb drag owns the browser's scroll range. Keep
// Virtuoso's estimated size tree fixed until pointer release so newly
// visited variable-height rows cannot resize the thumb under the pointer.
// This is deliberately pointer-gutter-specific; the wheel assertions above
// continue to exercise ordinary chat-content scrolling and live measuring.
// Browser themes clamp the held thumb only after the pointer crosses the
// track end, so the target below deliberately overshoots the visible gutter.
await transcript.evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll"));
@@ -604,7 +721,7 @@ try {
return {
x: Math.min(rect.right - 1, contentRight + Math.max(1, (rect.right - contentRight) / 2)),
y: rect.top + 5,
bottomY: rect.bottom - 5,
bottomY: Math.min(window.innerHeight - 1, rect.bottom + Math.max(24, rect.height * 0.1)),
knownSize: Number.parseFloat(row.dataset.knownSize || "0"),
gutter: rect.right - contentRight,
scrollHeight: element.scrollHeight,
@@ -971,7 +1088,7 @@ try {
// frames before watching the idle tail. A single bottom sample can race a
// late markdown/row remeasurement on loaded CI runners and contaminate the
// idle probe with legitimate convergence motion.
await waitForStableTranscriptGeometry(reducedPage, { requireTail: true });
await waitForStableTranscriptGeometry(reducedPage, { timeout: 30_000, requireTail: true });
const reducedIdle = await reducedPage.evaluate(() => new Promise((resolve) => {
const element = document.querySelector(".transcript");
const writes = [];
@@ -1020,16 +1137,26 @@ try {
`reduced-motion tail rests on the physical bottom (${reducedIdle.finalDistance}px)`,
);
// The #9089 gesture: wheel up, wheel back to the bottom, release, idle.
await moveToOuterReaderGutter(reducedPage, reducedPage.locator(".transcript"));
const reducedTranscript = reducedPage.locator(".transcript");
const reducedBottomTop = await reducedTranscript.evaluate((element) => element.scrollTop);
await moveToOuterReaderGutter(reducedPage, reducedTranscript);
await reducedPage.mouse.wheel(0, -900);
await reducedPage.waitForTimeout(120);
await reducedPage.mouse.wheel(0, 100_000);
await reducedPage.waitForFunction(() => {
await reducedPage.waitForFunction((bottomTop) => {
const element = document.querySelector(".transcript");
return element instanceof HTMLElement
&& element.dataset.scrollMode === "tail-follow"
&& element.scrollHeight - element.scrollTop - element.clientHeight <= 4;
}, undefined, { timeout: 10_000 });
&& element.dataset.scrollMode === "manual"
&& element.scrollTop < bottomTop - 1
&& element.scrollHeight - element.scrollTop - element.clientHeight > 4;
}, reducedBottomTop, { timeout: 5_000 });
let reducedReachedBottom = false;
for (let attempt = 0; attempt < 20 && !reducedReachedBottom; attempt += 1) {
await reducedPage.mouse.wheel(0, 640);
await reducedPage.waitForTimeout(50);
reducedReachedBottom = await reducedTranscript.evaluate((element) =>
element.dataset.scrollMode === "tail-follow"
&& element.scrollHeight - element.scrollTop - element.clientHeight <= 4);
}
assert(reducedReachedBottom, "reduced-motion repeated downward wheels return to the physical bottom (#9089)");
const reducedReturn = await reducedPage.evaluate(() => new Promise((resolve) => {
const element = document.querySelector(".transcript");
const tops = [];
+34 -14
View File
@@ -19,6 +19,17 @@ function assert(condition, message) {
process.stdout.write(` PASS ${message}\n`);
}
async function clickIfPresent(page, selector) {
// Keep optional-control discovery and activation in one browser task so a
// state update cannot unmount the element between separate Playwright calls.
return page.evaluate((target) => {
const element = document.querySelector(target);
if (!(element instanceof HTMLElement)) return false;
element.click();
return true;
}, selector);
}
async function waitForVisibleSelectionStart(page, { preferHighest, wheelDelta, timeout = 15_000 } = {}) {
const box = await page.locator(".transcript").boundingBox();
if (box) await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
@@ -161,13 +172,10 @@ try {
if (transcript) transcript.scrollTop = 0;
});
await page.waitForTimeout(100);
const older = page.locator(".transcript__older");
if (await older.count() === 0) break;
await older.click();
if (!(await clickIfPresent(page, ".transcript__older"))) break;
await page.waitForTimeout(350);
}
const jumpBottom = page.locator(".transcript__jump-bottom");
if (await jumpBottom.count()) await jumpBottom.click();
await clickIfPresent(page, ".transcript__jump-bottom");
try {
await page.waitForFunction(() => {
const transcript = document.querySelector(".transcript");
@@ -195,9 +203,8 @@ try {
}
// Tool-dense fixtures often land the native tail on non-selectable tool
// rows. Wheel off the pinned tail until a mounted "bench turn N" row is
// visible, then snapshot the virtual window for the later bound check.
// visible before beginning the logical selection gesture.
const points = await waitForVisibleSelectionStart(page, { preferHighest: true });
const baselineRows = await page.locator(".transcript__row").count();
assert(points != null, "bench transcript exposes a selectable visible message");
await page.evaluate(() => {
@@ -337,7 +344,6 @@ try {
}, logicalFocusPoint);
assert(during.collapsed, `cross-row drag releases the browser Range after logical promotion (${JSON.stringify(during)})`);
assert(during.mode === "selection", `cross-page drag remains owned by selection (${during.mode})`);
assert(during.rows <= Math.ceil(baselineRows * 1.1) + 2, `logical selection keeps the virtual DOM bounded (${baselineRows} → ${during.rows})`);
assert(during.overlayRects > 0, `logical selection paints mounted-row overlay rectangles (${JSON.stringify(during)})`);
if (during.writeOwners.some((owner) => owner !== "selection-edge-scroll")) {
throw new Error(`logical gesture admitted non-selection scroll owners: ${JSON.stringify(during.writeOwners)}`);
@@ -368,8 +374,11 @@ try {
transcript.dispatchEvent(new Event("scroll"));
}, settled.scrollTop);
await page.waitForTimeout(250);
const restoredRects = await page.locator(".transcript-selection-overlay__rect").count();
assert(restoredRects > 0, "logical overlay restores after selected rows scroll out and back in");
const beforeCopy = await page.evaluate(() => ({
overlayRects: document.querySelectorAll(".transcript-selection-overlay__rect").length,
scrollTop: document.querySelector(".transcript")?.scrollTop ?? 0,
}));
assert(beforeCopy.overlayRects > 0, "logical overlay restores after selected rows scroll out and back in");
await page.evaluate(() => {
window.__logicalClipboardText = null;
@@ -392,11 +401,19 @@ try {
collapsed: document.getSelection()?.isCollapsed ?? true,
rows: document.querySelectorAll(".transcript__row").length,
overlayRects: document.querySelectorAll(".transcript-selection-overlay__rect").length,
scrollTop: document.querySelector(".transcript")?.scrollTop ?? 0,
};
});
assert(after.collapsed, "logical copy leaves no synthetic browser Range behind");
assert(after.overlayRects === 0, "successful copy clears the logical overlay");
assert(after.rows <= Math.ceil(baselineRows * 1.1) + 2, "clearing logical selection preserves the normal virtual DOM window");
assert(
Math.abs(after.scrollTop - beforeCopy.scrollTop) <= 1,
`copy cleanup preserves the selection viewport (${beforeCopy.scrollTop} -> ${after.scrollTop})`,
);
assert(
during.rows <= Math.ceil(after.rows * 1.1) + 2,
`logical selection keeps the virtual DOM bounded (${after.rows} normal → ${during.rows} selecting)`,
);
const selectionHeapBaseline = await retainedHeap();
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.scrollMode === "manual", undefined, { timeout: 10_000 });
@@ -409,8 +426,7 @@ try {
const topDeadline = Date.now() + 15_000;
let forwardPoints = null;
while (Date.now() < topDeadline && (!forwardPoints || forwardPoints.anchorTurn > 17)) {
const older = page.locator(".transcript__older");
if (await older.count()) await older.click();
await clickIfPresent(page, ".transcript__older");
await page.mouse.wheel(0, -700);
await page.waitForTimeout(80);
try {
@@ -455,7 +471,6 @@ try {
owners: [...new Set((window.__transcriptProgrammaticWrites ?? []).map((write) => write.owner))],
}));
assert(forwardDuring.mode === "selection", "downward cross-page drag also promotes to logical selection");
assert(forwardDuring.rows <= Math.ceil(baselineRows * 1.1) + 2, "forward logical selection also keeps the virtual DOM bounded");
assert(forwardDuring.owners.every((owner) => owner === "selection-edge-scroll"), "forward logical gesture preserves scroll ownership");
await page.mouse.up();
await page.keyboard.press(process.platform === "darwin" ? "Meta+C" : "Control+C");
@@ -463,6 +478,11 @@ try {
const forwardCopiedTurns = await page.evaluate(() => (window.__logicalClipboardText.match(/bench turn /g) ?? []).length);
assert(forwardCopiedTurns >= 20, `forward logical copy resolves a 20+ turn frozen snapshot (${forwardCopiedTurns} turns)`);
await page.waitForFunction(() => document.querySelectorAll(".transcript-selection-overlay__rect").length === 0);
const forwardAfterRows = await page.locator(".transcript__row").count();
assert(
forwardDuring.rows <= Math.ceil(forwardAfterRows * 1.1) + 2,
`forward logical selection also keeps the virtual DOM bounded (${forwardAfterRows} normal → ${forwardDuring.rows} selecting)`,
);
const retainedSelectionBytes = Math.max(0, (await retainedHeap()) - selectionHeapBaseline);
assert(retainedSelectionBytes <= 2 * 1024 * 1024, `cleared logical selection retains at most 2MiB (${(retainedSelectionBytes / 1024 / 1024).toFixed(2)}MiB)`);
await page.evaluate(() => { window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = undefined; });
+5 -4
View File
@@ -17,14 +17,16 @@
"test:terminal": "tsx src/__tests__/terminal-events.test.ts && tsx src/__tests__/terminal-store.test.ts && tsx src/__tests__/terminal-output.test.ts && tsx src/__tests__/terminal-theme.test.ts && tsx src/__tests__/terminal-selection.test.ts && tsx src/__tests__/workspace-layout.test.ts",
"test:usage-stats": "tsx src/__tests__/usage-stats-format.test.ts && tsx src/__tests__/usage-stats-panel.test.tsx",
"test:task-monitor": "tsx src/components/TaskMonitorPanel.test.tsx && tsx src/__tests__/task-monitor-navigation.test.ts",
"test:workspace": "tsx src/__tests__/keyed-resource.test.ts && tsx src/__tests__/workspace-tree-memory.test.ts && tsx src/__tests__/workspace-refresh-store.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-context-menu.test.tsx && tsx src/__tests__/workspace-panel-swr-contract.test.ts",
"test:workspace": "tsx src/__tests__/keyed-resource.test.ts && tsx src/__tests__/workspace-tree-memory.test.ts && tsx src/__tests__/workspace-refresh-store.test.ts && tsx src/__tests__/workspace-selection-isolation.test.tsx && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-turn-verification.test.tsx && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/workspace-context-menu.test.tsx && tsx src/__tests__/workspace-resize-interaction.test.tsx && tsx src/__tests__/workspace-panel-swr-contract.test.ts",
"test:settings-responsive": "tsx src/__tests__/settings-responsive-layout.test.ts",
"test:stream": "tsx src/__tests__/raf-batch.test.ts && tsx src/__tests__/stream-delta-batch.test.ts && tsx src/__tests__/use-controller-stream-progress.test.ts",
"test:motion": "node scripts/check-motion-ci-contract.mjs && node scripts/check-waapi-contract.mjs --self-test && tsx src/__tests__/native-motion.test.tsx && tsx src/__tests__/approval-animation.test.tsx",
"test:motion-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/approval-animation.mjs",
"test:transcript": "tsx src/__tests__/transcript-virtuoso-index.test.ts && tsx src/__tests__/transcript-measured-sizes.test.ts && tsx src/__tests__/transcript-state-snapshot.test.ts && tsx src/__tests__/transcript-layout-recovery.test.ts && tsx src/__tests__/transcript-recovery-race.test.tsx && tsx src/__tests__/transcript-scroll-release.test.ts && tsx src/__tests__/transcript-native-scrollbar.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/question-jump-bar.test.tsx && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx && tsx src/__tests__/transcript-question-jump.test.tsx",
"test:transcript": "tsx src/__tests__/transcript-virtuoso-index.test.ts && tsx src/__tests__/transcript-measured-sizes.test.ts && tsx src/__tests__/transcript-state-snapshot.test.ts && tsx src/__tests__/transcript-layout-recovery.test.ts && tsx src/__tests__/transcript-reader-extent-stability.test.ts && tsx src/__tests__/transcript-reader-extent-race.test.tsx && tsx src/__tests__/transcript-recovery-race.test.tsx && tsx src/__tests__/transcript-anchor-compensation-race.test.tsx && tsx src/__tests__/transcript-scroll-release.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/transcript-native-scrollbar.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/creation-transcript-scrollbar.test.ts && tsx src/__tests__/question-jump-bar.test.tsx && tsx src/__tests__/transcript-question-nav.test.ts && tsx src/__tests__/transcript-question-nav-integration.test.ts && tsx src/__tests__/markdown-table-virtual.test.tsx && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/transcript-selection-retention.test.tsx && tsx src/__tests__/transcript-logical-selection.test.ts && tsx src/__tests__/transcript-selection-overlay.test.tsx && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/transcript-selection-menu.test.tsx && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-virtualization.test.tsx && tsx src/__tests__/transcript-question-jump.test.tsx",
"test:transcript-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/transcript-selection.mjs && PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/transcript-scroll-stability.mjs",
"pretest": "pnpm test:terminal && pnpm test:task-monitor && tsx src/__tests__/rich-composer-selection.test.tsx && tsx src/__tests__/context-center-contract.test.ts && tsx src/__tests__/provider-model-cache.test.ts && tsx src/__tests__/format-tokens.test.ts && pnpm test:usage-stats && pnpm test:settings-responsive && pnpm test:composer-menu-viewport && pnpm test:transcript",
"pretest": "pnpm test:terminal && pnpm test:task-monitor && pnpm test:composer && tsx src/__tests__/context-center-contract.test.ts && tsx src/__tests__/provider-model-cache.test.ts && tsx src/__tests__/format-tokens.test.ts && pnpm test:usage-stats && pnpm test:settings-responsive && pnpm test:composer-menu-viewport && pnpm test:transcript",
"test:composer-menu-viewport": "tsx src/__tests__/composer-menu-viewport.test.ts && tsx src/__tests__/virtual-menu-identity.test.tsx",
"test:composer": "tsx src/__tests__/rich-composer-selection.test.tsx && tsx src/__tests__/composer-resized-input-css.test.ts && tsx src/__tests__/composer-ime.test.tsx && tsx src/__tests__/composer-autosize-scrollbar-css.test.ts",
"test": "node scripts/test-todo-visibility.mjs && node scripts/run-tests.mjs",
"test:remote": "tsx src/__tests__/remote-workspace-launch.test.ts && tsx src/__tests__/remote-store.test.ts && tsx src/__tests__/remote-error-ux.test.tsx && tsx src/__tests__/remote-hosts-page.test.tsx && tsx src/__tests__/remote-secret-dialog.test.tsx && node --import ./scripts/svg-stub-register.mjs --import tsx src/__tests__/remote-server-panel.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx",
"test:performance": "tsx src/__tests__/history-performance-benchmark.tsx",
@@ -55,7 +57,6 @@
"remark-math": "^6.0.0",
"remark-parse": "11.0.0",
"remark-rehype": "11.1.2",
"svg-pan-zoom": "^3.6.2",
"unified": "11.0.5",
"unist-util-visit": "^5.1.0",
"vfile": "6.0.3",
-8
View File
@@ -70,9 +70,6 @@ importers:
remark-rehype:
specifier: 11.1.2
version: 11.1.2
svg-pan-zoom:
specifier: ^3.6.2
version: 3.6.2
unified:
specifier: 11.0.5
version: 11.0.5
@@ -1874,9 +1871,6 @@ packages:
[email protected]:
resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==}
[email protected]:
resolution: {integrity: sha512-JwnvRWfVKw/Xzfe6jriFyfey/lWJLq4bUh2jwoR5ChWQuQoOH8FEh1l/bEp46iHHKHEJWIyFJETbazraxNWECg==}
[email protected]:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
@@ -4097,8 +4091,6 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -31,7 +31,7 @@ function assertBudget(label, actual, budget) {
const initialJS = initialAssetPaths(".js");
const initialCSS = initialAssetPaths(".css");
if (!initialJS.length) throw new Error("no initial JavaScript assets found in dist/index.html");
if (!initialCSS.length) throw new Error("no initial CSS assets found in dist/index.html");
// initial CSS 允许为空:styles.css 走 ?url 延迟加载,feature 样式走 lazy chunk。
// main.tsx intentionally loads styles.css before mounting React so the inline
// boot shell can paint without waiting for the full application stylesheet.
@@ -68,9 +68,32 @@ console.log("\nbundle budgets");
// blank-project flow landed; project-topic sort invalidation and request
// ordering add another bounded 0.2 KiB. Retain both owner boundaries with a
// narrowly rounded 1 KiB ratchet.
assertBudget("initial JavaScript gzip", initialJSGzip, 425.0 * 1024);
// Diagnostic builds intentionally keep content-free row geometry and scroll
// transition probes in the initial transcript path. Stable builds retain the
// existing production ratchet. Per-row measurement versions and a bounded
// recovery probe add less than 0.1% gzip; retain them with a 0.5 KiB (0.118%)
// production ratchet rather than weakening either recovery contract. The
// bounded allowance also covers small gzip drift from the embedded build SHA.
// Reader extent stabilization adds 1.2 KiB gzip (0.28%) in production for its
// bounded input, collapse, rebound, and ownership transaction. Retain it with
// a 1.5 KiB (0.35%) ratchet instead of weakening the Windows scroll invariant.
// Complete-history navigation adds 0.3 KiB gzip (0.070%) to that production
// path while keeping its 1.68 KiB question rail lazy-loaded. Test diagnostics
// plus the navigation owner add 0.7 KiB gzip (0.164%) over the merged test gate.
// DingTalk channel status and locale wiring move the current-base production
// build from 427.2 to 427.7 KiB and test from 428.6 to 429.1 KiB. Keep about
// 0.1 KiB of build-SHA headroom with a 0.5 KiB (about 0.117%) ratchet per gate.
const initialJSBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 429.2 : 427.8;
assertBudget("initial JavaScript gzip", initialJSGzip, initialJSBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk gzip", largestInitialJS, 280 * 1024);
assertBudget("render-blocking CSS gzip", initialCSSGzip, 4 * 1024);
// Render-blocking CSS is intentionally absent: styles.css loads deferred via
// ?url, and feature styles (heartbeat) live in lazy chunks loaded on demand.
// An empty initial CSS list is the desired state, not a build error.
if (initialCSS.length > 0) {
assertBudget("render-blocking CSS gzip", initialCSSGzip, 4 * 1024);
} else {
process.stdout.write(" PASS render-blocking CSS: none (all styles deferred)\n");
}
// Extension surfaces, Task Monitor, and compact decision receipts share the
// application stylesheet loaded before React mounts. Keep their combined
// allowance bounded even though the file is no longer render-blocking.
@@ -89,15 +112,31 @@ for (const path of localeChunks) {
// The three StepFun presets add localized names/descriptions (~0.1 KiB
// gzip); the two pay-as-you-go presets add the same again. The delivery
// floor segmented control adds two labels plus one explanatory tooltip,
// measured at 23 B gzip for zh and 8 B for zh-TW. Retain all with the
// smallest 0.1 KiB ratchet increments.
const budget = name.startsWith("zh-TW-") ? 56.2 * 1024 : 55.5 * 1024;
// measured at 23 B gzip for zh and 8 B for zh-TW. Completion receipts add
// six short status labels in each locale, requiring another 0.2 KiB per
// language. DingTalk setup and mention guidance add at most 0.2 KiB more
// (0.36%); retain the complete security and group-chat copy instead of
// abbreviating user-facing instructions to fit the old locale ratchet.
// Recovery-copy and catalog-only sidebar labels can move the simplified
// Chinese chunk across the rounded 55.9 KiB boundary on CI's Node/zlib;
// retain a narrow 0.1 KiB headroom rather than making gzip output a
// platform-dependent gate.
const budget = name.startsWith("zh-TW-") ? 56.6 * 1024 : 56.0 * 1024;
assertBudget(`${name} gzip`, gzipBytes(path), budget);
}
const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
.reduce((total, path) => total + statSync(path).size, 0);
// The maintained Virtuoso engine adds 49.1 KiB raw (2.2%) over the previous
// 2268.7 KiB gate. Retain 1% headroom to bound hash/minifier drift.
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, 2_341 * 1024);
// 2268.7 KiB gate. Navigation remains inside the 2341 KiB production ceiling;
// its combined diagnostic wiring adds 2.2 KiB (0.094%) to the test channel.
// DingTalk startup wiring moves current-base production from 2341.0 to 2343.6
// KiB and test from 2346.2 to 2348.8 KiB; the pinned heading adds 0.5 KiB raw
// (0.021%). The workspace panel rework (change-row hover/revert, status badges,
// More menu, completion summary) makes the latest-base merge 2353.1 KiB in
// production and 2358.3 KiB in test: about 9.0 KiB (0.38%) over main-v2's
// channel gates. Retain that attributable UI capacity with 0.1 KiB of build-SHA
// headroom without widening the gzip or largest-chunk exceptions.
const rawInitialBudgetKiB = process.env.REASONIX_CHANNEL === "test" ? 2_358.4 : 2_353.2;
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
@@ -5,7 +5,9 @@
*
* Only the files in ALLOWED_WRITERS may issue imperative scroll calls
* (scrollTo / scrollBy / scrollToIndex) against the transcript Virtuoso
* handle. Every other module must route through the scroll coordinator's
* handle: the arbiter hook plus its extracted controllers (split out for the
* file-size budget — still one logical writer, driven only by the arbiter).
* Every other module must route through the scroll coordinator's
* dispatch/writeOffset API. This guards the "one writer owns scrollTop"
* invariant that keeps user scrolls, tail-follow, and anchor recovery from
* fighting each other (#8657).
@@ -21,12 +23,31 @@ import { fileURLToPath } from "node:url";
const SOURCE_ROOT = fileURLToPath(new URL("../src", import.meta.url));
// Only the scroll-arbiter hook may write to the transcript Virtuoso handle;
// every other module routes through its dispatch/writeOffset/recovery API.
// Only the scroll-arbiter hook and its extracted controllers may write to the
// transcript Virtuoso handle; every other module routes through the
// dispatch/writeOffset/recovery API.
const ALLOWED_WRITERS = new Set([
"lib/useTranscriptScrollArbiter.ts",
"lib/transcriptTailSettle.ts",
]);
// Raw `.scrollTop` writes bypass the Virtuoso handle entirely. The allowed
// set is deliberate and non-transcript (or natively paired with the arbiter):
// - lib/nestedScrollHandoff.ts: the trackpad handoff lane; every write is
// paired with onParentScrollIntent so the arbiter sees the gesture.
// - components/SettingsPanel.tsx: the settings overlay's own scroller.
// - components/WorkspacePanel.tsx: the project tree's own scroller.
// - components/editors/LineNumberCode.tsx: the file viewer's own scroller —
// resets scroll when a virtual file is replaced by a non-virtual one.
// - custom/features/heartbeat/HeartbeatPanel.tsx: the heartbeat list's custom
// scrollbar thumb drag, mapped to its own scroller.
const ALLOWED_RAW_SCROLLTOP = new Set([
"lib/nestedScrollHandoff.ts",
"components/SettingsPanel.tsx",
"components/WorkspacePanel.tsx",
"components/editors/LineNumberCode.tsx",
"custom/features/heartbeat/HeartbeatPanel.tsx",
]);
// Matches imperative scroll calls on the transcript Virtuoso handle, whether
// reached through `virtuosoRef.current` directly or a local `handle` alias.
const VIRTUOSO_SCROLL_RE = /(?:virtuoso[A-Za-z]*\.current|\bhandle)\??\.\s*scroll(?:To|By|ToIndex)\s*\(/;
+9
View File
@@ -0,0 +1,9 @@
// Loader hook for tsx-based tests: `.css` imports resolve to an empty module
// so component modules (e.g. HeartbeatPanel) can statically import their CSS
// without failing under node. Vite handles the real CSS in browser builds.
export async function load(url, context, nextLoad) {
if (url.endsWith(".css")) {
return { format: "module", source: "export default \"\";", shortCircuit: true };
}
return nextLoad(url, context);
}
@@ -0,0 +1,5 @@
// Registers the css-loader hook before tsx boots, so `.css` imports in
// component modules resolve to an empty module instead of failing on the
// extension. Mirrors svg-loader.mjs; used by css-stub-register.mjs.
import { register } from "node:module";
register(new URL("./css-loader.mjs", import.meta.url));
+19 -2
View File
@@ -8,13 +8,15 @@
import { spawnSync } from "node:child_process";
import { readdirSync } from "node:fs";
import { createRequire } from "node:module";
import { join } from "node:path";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
// Resolve the local tsx entry directly so the runner works both under pnpm
// scripts and when invoked as plain `node scripts/run-tests.mjs`.
const tsxCli = createRequire(import.meta.url).resolve("tsx/cli");
const TESTS_DIR = "src/__tests__";
const SCRIPTS_DIR = "scripts";
const OWNED_ELSEWHERE = new Map(Object.entries({
"terminal-events.test.ts": "test:terminal",
@@ -23,8 +25,11 @@ const OWNED_ELSEWHERE = new Map(Object.entries({
"terminal-theme.test.ts": "test:terminal",
"task-monitor-navigation.test.ts": "test:task-monitor",
"workspace-refresh-store.test.ts": "test:workspace",
"workspace-selection-isolation.test.tsx": "test:workspace",
"workspace-changes-errors.test.tsx": "test:workspace",
"workspace-preview-css.test.ts": "test:workspace",
"workspace-context-menu.test.tsx": "test:workspace",
"workspace-resize-interaction.test.tsx": "test:workspace",
"rich-composer-selection.test.tsx": "pretest",
"context-center-contract.test.ts": "pretest",
"provider-model-cache.test.ts": "pretest",
@@ -32,6 +37,9 @@ const OWNED_ELSEWHERE = new Map(Object.entries({
"usage-stats-format.test.ts": "test:usage-stats",
"usage-stats-panel.test.tsx": "test:usage-stats",
"settings-responsive-layout.test.ts": "test:settings-responsive",
"raf-batch.test.ts": "test:stream",
"stream-delta-batch.test.ts": "test:stream",
"use-controller-stream-progress.test.ts": "test:stream",
"transcript-virtuoso-index.test.ts": "test:transcript",
"transcript-scroll-release.test.ts": "test:transcript",
"transcript-virtualization.test.tsx": "test:transcript",
@@ -64,6 +72,10 @@ for (const [name, owner] of OWNED_ELSEWHERE) {
}
}
// Suites that statically import CSS (e.g. HeartbeatPanel's heartbeat.css) need
// the css-stub loader hook so tsx resolves the import under node.
const CSS_STUB_SUITES = new Set(["heartbeat-editor.test.tsx", "heartbeat-next-run.test.ts"]);
const suites = files.filter((name) => !OWNED_ELSEWHERE.has(name));
console.log(`run-tests: ${suites.length} discovered suites (${OWNED_ELSEWHERE.size} owned by dedicated scripts)`);
@@ -75,7 +87,12 @@ for (const name of suites) {
// Node's built-in navigator.language follows the machine's ICU locale, and
// suites assert English UI strings.
const env = { ...process.env, LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" };
const result = spawnSync(process.execPath, [tsxCli, path], { stdio: "inherit", env });
const extraArgs = CSS_STUB_SUITES.has(name)
// --import needs an absolute file URL: a bare relative path is resolved as
// a package specifier by Node and fails with ERR_MODULE_NOT_FOUND.
? ["--import", pathToFileURL(resolve(SCRIPTS_DIR, "css-stub-register.mjs")).href]
: [];
const result = spawnSync(process.execPath, [tsxCli, ...extraArgs, path], { stdio: "inherit", env });
if (result.error) console.error(`run-tests: spawn failed for ${path}: ${result.error.message}`);
if (result.status !== 0) {
if (!keepGoing) {
+202 -223
View File
@@ -5,7 +5,6 @@ import {
Activity,
Command,
Copy as RestoreIcon,
Download,
Minus,
Search,
Server,
@@ -13,14 +12,10 @@ import {
SquarePen,
PanelLeft,
PanelRight,
FileDown,
FileImage,
FileText,
FileJson,
GitBranch,
MessageSquare,
Settings as SettingsIcon,
Pencil,
RotateCw,
Trash2,
AlarmClock,
@@ -71,10 +66,9 @@ import { dismissOnboarding, shouldOpenOnboarding } from "./lib/onboarding";
import { AppChrome } from "./components/AppChrome";
import { ShortcutsCheatsheet } from "./components/ShortcutsCheatsheet";
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, shouldMountExternalOpener } from "./components/ExternalOpener";
import { TopicbarMoreMenu } from "./components/TopicbarMoreMenu";
import { startTerminalEventBridge } from "./lib/terminalEvents";
import { applyTerminalThemePreference } from "./lib/terminalTheme";
import { formatTerminalOutputForComposer } from "./lib/terminalOutput";
@@ -109,8 +103,10 @@ import {
type QualityFloor,
type TabMeta,
type ToolApprovalMode,
type WireCompletionSummary,
type WorkspaceConflictView,
} from "./lib/types";
import type { WorkspaceVerificationRevealRequest } from "./components/WorkspacePanel";
import type { InvocationMetadataMap, StructuredInvocationSubmit } from "./lib/invocationDisplay";
import type { RewindUndoState } from "./lib/rewindTypes";
import { formatSelectionReference, type SelectedTextInsertRequest } from "./lib/selectedTextContext";
@@ -143,11 +139,7 @@ import {
CREATION_RIGHT_DOCK_MIN_RENDER_WIDTH,
CREATION_RIGHT_DOCK_TREE_MIN_WIDTH,
CREATION_SIDEBAR_MIN_WIDTH,
RIGHT_DOCK_MAX_WIDTH,
RIGHT_DOCK_MIN_RENDER_WIDTH,
RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH,
RIGHT_DOCK_PREVIEW_MIN_WIDTH,
RIGHT_DOCK_TREE_MAX_WIDTH,
RIGHT_DOCK_TREE_MIN_WIDTH,
type RightDockMode,
SIDEBAR_MAX_WIDTH,
@@ -157,7 +149,6 @@ import {
applyLayoutStyleDefaults,
clampCreationRightDockTreeWidth,
clampCreationSidebarWidth,
clampRightDockPreviewWidth,
clampRightDockTreeWidth,
clampSidebarWidth,
clampTerminalHeight,
@@ -165,7 +156,6 @@ import {
defaultCreationSidebarWidth,
defaultRightDockTreeWidth,
defaultSidebarWidth,
saveRightDockPreviewWidth,
saveRightDockTreeWidth,
saveSidebarCollapsed,
saveSidebarWidth,
@@ -173,6 +163,7 @@ import {
saveTerminalPanelOpen,
terminalMaxHeight,
saveWorkspacePanelOpen,
loadWorkspacePanelOpen,
useLayoutStore,
} from "./store/layout";
import { useOverlayStore } from "./store/overlays";
@@ -197,9 +188,9 @@ import { applyConfiguredBaseAppearance, applyThemePack, applyThemeScene, clearTh
import { ThemeBackground } from "./components/ThemeBackground";
import { applyTextSize, DEFAULT_TEXT_SIZE, getTextSize, nextTextSize } from "./lib/textSize";
import { useViewportHeightVar, useWindowStatePersistence } from "./lib/windowState";
import { resolveLiveWorkspacePanelWidth, resolveWorkspacePanelPlacement, workspacePanelAriaMinWidth } from "./lib/workspaceLayout";
import { availableWorkspacePanelWidth, resolveLiveWorkspacePanelWidth, resolveWorkspacePanelPlacement, workspacePanelAriaMinWidth } from "./lib/workspaceLayout";
import { createPointerResizeLifecycle, createRafResizeUpdater } from "./lib/resizeDrag";
import { useGlobalShortcut } from "./lib/keyboardShortcuts";
import { formatShortcutCombo, resolvedShortcutCombo, useGlobalShortcut } from "./lib/keyboardShortcuts";
import { useWarmTerminalPanel } from "./lib/useWarmTerminalPanel";
import { topicShortcutIndexFromEvent, useTopicShortcuts, type TopicShortcutEntry } from "./lib/topicShortcuts";
import { composerDraftKeyForTab } from "./lib/composerDraftKey";
@@ -285,6 +276,7 @@ function NoticePreviewPanel() {
const TranscriptSelectionMenu = lazy(() => import("./components/TranscriptSelectionMenu").then((module) => ({ default: module.TranscriptSelectionMenu })));
const ContextPanel = lazy(() => import("./components/ContextPanel").then((module) => ({ default: module.ContextPanel })));
const HistoryPanel = lazy(() => import("./components/HistoryPanel").then((module) => ({ default: module.HistoryPanel })));
const HeartbeatView = lazy(() => import("./custom/features/heartbeat/HeartbeatPanel").then((module) => ({ default: module.HeartbeatView })));
const SettingsPanel = lazy(() => import("./components/SettingsPanelEntry").then((module) => ({ default: module.SettingsPanel })));
const RemotePanel = lazy(() => import("./components/RemotePanel").then((module) => ({ default: module.RemotePanel })));
const TerminalPanel = lazy(() => import("./components/TerminalPanel").then((module) => ({ default: module.TerminalPanel })));
@@ -298,7 +290,6 @@ const WorkspacePanel = lazy(async () => {
});
const CHAT_MIN_WIDTH = 400;
const CHAT_COMFORT_MIN_WIDTH = 560;
const WORKSPACE_RESIZER_WIDTH = 8;
function stripLegacyGoalBudgetFlags(arg: string): string {
@@ -1177,8 +1168,8 @@ export default function App() {
const [sidebarImDetailConnectionId, setSidebarImDetailConnectionId] = useState("");
const sidebarCollapsed = useLayoutStore((s) => s.sidebarCollapsed);
const setSidebarCollapsed = useLayoutStore((s) => s.setSidebarCollapsed);
const heartbeatOpen = useOverlayStore((s) => s.heartbeatOpen);
const setHeartbeatOpen = useOverlayStore((s) => s.setHeartbeatOpen);
const mainView = useOverlayStore((s) => s.mainView);
const setMainView = useOverlayStore((s) => s.setMainView);
type TimeFilter = "all" | "10" | "20" | "1h" | "3h" | "5h" | "1d";
const [topicTimeFilter, setTopicTimeFilter] = useState<TimeFilter>(() => {
try {
@@ -1202,7 +1193,6 @@ export default function App() {
const rightDockTreeWidth = useLayoutStore((s) => s.rightDockTreeWidth);
const setRightDockTreeWidth = useLayoutStore((s) => s.setRightDockTreeWidth);
const rightDockPreviewWidth = useLayoutStore((s) => s.rightDockPreviewWidth);
const setRightDockPreviewWidth = useLayoutStore((s) => s.setRightDockPreviewWidth);
const workspacePreviewActive = useLayoutStore((s) => s.workspacePreviewActive);
const setWorkspacePreviewActive = useLayoutStore((s) => s.setWorkspacePreviewActive);
const attentionChimeEvents = useRef(new Set<string>());
@@ -1292,10 +1282,24 @@ export default function App() {
const setTopicExportOpen = useOverlayStore((s) => s.setTopicExportOpen);
const sidebarSearchOpen = useOverlayStore((s) => s.sidebarSearchOpen);
const setSidebarSearchOpen = useOverlayStore((s) => s.setSidebarSearchOpen);
// Leaving the automation view: any overlay/sidebar surface that signals the
// user is returning to the chat workspace switches mainView back to "chat".
// Navigation paths (open topic / new session / resume) are covered by
// enqueueNavigation.
useEffect(() => {
if (mainView !== "automation") return;
// Any chat-side overlay/surface counts as "returning to the chat
// workspace": settings, palette, sidebar search, history, the shortcuts
// cheatsheet and the topic export menu all switch mainView back to chat.
if (settingsTarget !== null || paletteOpen || sidebarSearchOpen || histView !== null
|| shortcutsOpen || topicExportOpen) {
setMainView("chat");
}
}, [mainView, settingsTarget, paletteOpen, sidebarSearchOpen, histView, shortcutsOpen, topicExportOpen, setMainView]);
const sidebarSearchFocusSignal = useOverlayStore((s) => s.sidebarSearchFocusSignal);
const setSidebarSearchFocusSignal = useOverlayStore((s) => s.setSidebarSearchFocusSignal);
const [sidebarTogglePressed, setSidebarTogglePressed] = useState(false);
const [workspaceTogglePressed, setWorkspaceTogglePressed] = useState(false);
const [clearContextPending, setClearContextPending] = useState(false);
const [backgroundRuntimes, setBackgroundRuntimes] = useState<BackgroundRuntimeView[]>([]);
const [workspaceConflict, setWorkspaceConflict] = useState<WorkspaceConflictView | null>(null);
@@ -1308,7 +1312,6 @@ export default function App() {
const layoutRef = useRef<HTMLDivElement>(null);
const workspacePanelResizeFinishRef = useRef<(() => void) | null>(null);
const sidebarTogglePressTimerRef = useRef<number | null>(null);
const workspaceTogglePressTimerRef = useRef<number | null>(null);
// Persist window geometry across launches.
useWindowStatePersistence();
@@ -1408,18 +1411,6 @@ export default function App() {
}, 260);
}, []);
const pulseWorkspaceToggle = useCallback(() => {
if (typeof window === "undefined") return;
if (workspaceTogglePressTimerRef.current !== null) {
window.clearTimeout(workspaceTogglePressTimerRef.current);
}
setWorkspaceTogglePressed(true);
workspaceTogglePressTimerRef.current = window.setTimeout(() => {
workspaceTogglePressTimerRef.current = null;
setWorkspaceTogglePressed(false);
}, 260);
}, []);
const anchorAppScrollToChat = useCallback(() => {
if (typeof window === "undefined") return;
const el = appRef.current;
@@ -1437,9 +1428,6 @@ export default function App() {
if (sidebarTogglePressTimerRef.current !== null) {
window.clearTimeout(sidebarTogglePressTimerRef.current);
}
if (workspaceTogglePressTimerRef.current !== null) {
window.clearTimeout(workspaceTogglePressTimerRef.current);
}
};
}, []);
@@ -1590,17 +1578,30 @@ export default function App() {
});
}, []);
const rightDockDetailActive = rightDockMode !== "context" && workspacePreviewActive;
const preferredWorkspacePanelWidth = rightDockDetailActive ? rightDockPreviewWidth : rightDockTreeWidth;
// The dock keeps one width across tab switches (context/files/changed):
// the tree width is the single source so toggling tabs never resizes the
// sidebar. Preview detail stays inside the dock without widening it.
const preferredWorkspacePanelWidth = rightDockTreeWidth;
const rightDockTreeMinWidth = desktopLayoutStyle === "creation" ? CREATION_RIGHT_DOCK_TREE_MIN_WIDTH : RIGHT_DOCK_TREE_MIN_WIDTH;
const rightDockTreeWidthClamp = desktopLayoutStyle === "creation" ? clampCreationRightDockTreeWidth : clampRightDockTreeWidth;
const rightDockMinRenderWidth = desktopLayoutStyle === "creation" && !rightDockDetailActive
? CREATION_RIGHT_DOCK_MIN_RENDER_WIDTH
: RIGHT_DOCK_MIN_RENDER_WIDTH;
const workspacePanelMinWidth = rightDockDetailActive ? RIGHT_DOCK_PREVIEW_MIN_WIDTH : rightDockTreeMinWidth;
const chatReservedWidth = workspacePanelOpen && !workspacePanelMaximized ? CHAT_COMFORT_MIN_WIDTH : CHAT_MIN_WIDTH;
const workspacePanelMinWidth = rightDockTreeMinWidth;
const chatReservedWidth = CHAT_MIN_WIDTH;
const workspacePanelAvailableWidth = availableWorkspacePanelWidth({
viewportWidth,
sidebarCollapsed,
sidebarWidth,
chatMinWidth: chatReservedWidth,
resizerWidth: WORKSPACE_RESIZER_WIDTH,
});
const {
renderWidth: workspacePanelRenderWidth,
overlay: workspacePanelOverlay,
// The automation page fills the main content area; the workbench dock must
// not overlay it. main-v2 keeps automation as a popup so its placement
// helper has no view concept — apply the exclusion here on top.
renderable: workspacePanelRenderable,
gridOpen: workspacePanelGridOpen,
} = resolveWorkspacePanelPlacement({
@@ -1610,6 +1611,9 @@ export default function App() {
minWidth: workspacePanelMinWidth, minRenderWidth: rightDockMinRenderWidth,
liveWidth: liveWorkspacePanelRenderWidth,
});
const automationView = mainView === "automation";
const effectiveWorkspacePanelRenderable = automationView ? false : workspacePanelRenderable;
const effectiveWorkspacePanelGridOpen = automationView ? false : workspacePanelGridOpen;
const resolveLiveWorkspacePanelRenderWidth = useCallback(
(preferredWidth: number, nextSidebarWidth = sidebarWidth) =>
resolveLiveWorkspacePanelWidth({
@@ -1665,10 +1669,12 @@ export default function App() {
activeTab?.scope ?? "",
activeTab?.workspaceRoot ?? state.meta?.cwd ?? "",
].join("\u0000");
const restoreWorkspaceDockWidths = useCallback((treeWidth: number, previewWidth: number) => {
setRightDockTreeWidth(rightDockTreeWidthClamp(treeWidth));
setRightDockPreviewWidth(clampRightDockPreviewWidth(previewWidth));
}, [rightDockTreeWidthClamp]);
const restoreWorkspaceDockWidths = useCallback((treeWidth: number, _previewWidth: number) => {
// Single-width dock: only the tree width is meaningful; clamp it to the
// dynamic available width (chat keeps its 400px floor), never a fixed
// 560 ceiling, so the user's remembered width is preserved when reopened.
setRightDockTreeWidth(rightDockTreeWidthClamp(treeWidth, workspacePanelAvailableWidth));
}, [rightDockTreeWidthClamp, workspacePanelAvailableWidth]);
const sidebarImDetailConnection = useMemo(
() => sidebarImConnections.find((connection) => connection.id === sidebarImDetailConnectionId) ?? null,
[sidebarImConnections, sidebarImDetailConnectionId],
@@ -2715,28 +2721,22 @@ export default function App() {
const setSavedWorkspacePanelWidth = useCallback(
(width: number) => {
closeTransientOverlays();
if (rightDockDetailActive) {
const next = clampRightDockPreviewWidth(width);
setRightDockPreviewWidth(next);
saveRightDockPreviewWidth(next);
return;
}
const next = rightDockTreeWidthClamp(width);
const next = rightDockTreeWidthClamp(width, workspacePanelAvailableWidth);
setRightDockTreeWidth(next);
saveRightDockTreeWidth(next);
},
[closeTransientOverlays, rightDockDetailActive, rightDockTreeWidthClamp],
[closeTransientOverlays, rightDockTreeWidthClamp, workspacePanelAvailableWidth],
);
const ensureWorkspacePanelWidth = useCallback(
(width: number) => {
closeTransientOverlays();
if (rightDockMode === "context") return;
const next = clampRightDockPreviewWidth(width);
setRightDockPreviewWidth(next);
saveRightDockPreviewWidth(next);
const next = rightDockTreeWidthClamp(width, workspacePanelAvailableWidth);
setRightDockTreeWidth(next);
saveRightDockTreeWidth(next);
},
[closeTransientOverlays, rightDockMode],
[closeTransientOverlays, rightDockTreeWidthClamp, workspacePanelAvailableWidth],
);
const startWorkspacePanelResize = useCallback(
@@ -2762,11 +2762,7 @@ export default function App() {
const onMove = (moveEvent: PointerEvent) => {
const delta = moveEvent.clientX - startX;
nextDockWidth = startDockWidth - delta;
if (rightDockDetailActive) {
nextDockWidth = clampRightDockPreviewWidth(nextDockWidth);
} else {
nextDockWidth = rightDockTreeWidthClamp(nextDockWidth);
}
nextDockWidth = rightDockTreeWidthClamp(nextDockWidth, workspacePanelAvailableWidth);
liveResize.schedule(resolveLiveWorkspacePanelRenderWidth(nextDockWidth));
};
const lifecycle = createPointerResizeLifecycle({
@@ -2787,7 +2783,7 @@ export default function App() {
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
},
[closeTransientOverlays, resolveLiveWorkspacePanelRenderWidth, rightDockDetailActive, rightDockTreeWidthClamp, setSavedWorkspacePanelWidth, workspacePanelOpen, workspacePanelRenderWidth],
[closeTransientOverlays, resolveLiveWorkspacePanelRenderWidth, rightDockDetailActive, rightDockTreeWidthClamp, setSavedWorkspacePanelWidth, workspacePanelAvailableWidth, workspacePanelOpen, workspacePanelRenderWidth],
);
const resizeWorkspacePanelWithKeyboard = useCallback(
@@ -2797,13 +2793,13 @@ export default function App() {
setSavedWorkspacePanelWidth(workspacePanelRenderWidth + (event.key === "ArrowLeft" ? 16 : -16));
} else if (event.key === "Home") {
event.preventDefault();
setSavedWorkspacePanelWidth(rightDockDetailActive ? RIGHT_DOCK_PREVIEW_MIN_WIDTH : rightDockTreeMinWidth);
setSavedWorkspacePanelWidth(rightDockTreeMinWidth);
} else if (event.key === "End") {
event.preventDefault();
setSavedWorkspacePanelWidth(rightDockDetailActive ? RIGHT_DOCK_MAX_WIDTH : RIGHT_DOCK_TREE_MAX_WIDTH);
setSavedWorkspacePanelWidth(workspacePanelAvailableWidth);
}
},
[rightDockDetailActive, rightDockTreeMinWidth, setSavedWorkspacePanelWidth, workspacePanelRenderWidth],
[rightDockDetailActive, rightDockTreeMinWidth, setSavedWorkspacePanelWidth, workspacePanelAvailableWidth, workspacePanelRenderWidth],
);
const terminalRenderHeight = clampTerminalHeight(terminalHeight, viewportHeight);
@@ -2874,6 +2870,8 @@ export default function App() {
[setSavedTerminalHeight, terminalPanelOpen, terminalRenderHeight, terminalResizeMaxHeight],
);
const activeWorkspaceRoot = activeTab?.workspaceRoot ?? state.meta?.cwd ?? "";
const openWorkspacePanel = useCallback(
(mode: RightDockMode = rightDockMode) => {
closeTransientOverlays();
@@ -2895,9 +2893,9 @@ export default function App() {
return;
}
setWorkspacePanelOpen(true);
saveWorkspacePanelOpen(true);
saveWorkspacePanelOpen(true, activeWorkspaceRoot);
},
[closeTransientOverlays, rightDockMode, workspacePanelMaximized, workspacePanelOpen],
[activeWorkspaceRoot, closeTransientOverlays, rightDockMode, workspacePanelMaximized, workspacePanelOpen],
);
const closeWorkspacePanel = useCallback(() => {
@@ -2908,12 +2906,19 @@ export default function App() {
setLiveWorkspacePanelRenderWidth(null);
setWorkspacePanelMaximized(false);
setWorkspacePanelOpen(false);
saveWorkspacePanelOpen(false);
}, [closeTransientOverlays, workspacePanelOpen]);
saveWorkspacePanelOpen(false, activeWorkspaceRoot);
}, [activeWorkspaceRoot, closeTransientOverlays, workspacePanelOpen]);
// Restore the right dock's open/closed state per project: switching to a
// different workspace root (or a global session) restores that scope's own
// preference instead of carrying the previous project's state over.
useEffect(() => {
setWorkspacePanelOpen(loadWorkspacePanelOpen(activeWorkspaceRoot));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeWorkspaceRoot]);
const toggleWorkspacePanel = useCallback(() => {
pulseWorkspaceToggle();
if (workspacePanelRenderable) {
if (effectiveWorkspacePanelRenderable) {
closeWorkspacePanel();
return;
}
@@ -2923,8 +2928,10 @@ export default function App() {
openWorkspacePanel(rightDockMode === "changed" ? "changed" : "files");
return;
}
openWorkspacePanel("context");
}, [closeWorkspacePanel, desktopLayoutStyle, openWorkspacePanel, pulseWorkspaceToggle, rightDockMode, workspacePanelRenderable]);
// Reopen with the previously active tab (rightDockMode is kept in the
// store across close/open) instead of forcing "context".
openWorkspacePanel();
}, [closeWorkspacePanel, desktopLayoutStyle, effectiveWorkspacePanelRenderable, openWorkspacePanel, rightDockMode]);
const openRightDockMode = useCallback(
(mode: RightDockMode) => {
@@ -2933,6 +2940,22 @@ export default function App() {
[openWorkspacePanel],
);
const verificationRevealSequenceRef = useRef(0);
const [verificationRevealRequest, setVerificationRevealRequest] = useState<WorkspaceVerificationRevealRequest | null>(null);
const openTurnVerification = useCallback((summary: WireCompletionSummary) => {
openRightDockMode("changed");
verificationRevealSequenceRef.current += 1;
setVerificationRevealRequest({
id: verificationRevealSequenceRef.current,
summary,
tabId: activeTabId ?? "",
turnStartAt: state.turnStartAt,
currentSummary: state.completionSummary,
});
}, [activeTabId, openRightDockMode, state.completionSummary, state.turnStartAt]);
useEffect(() => { setVerificationRevealRequest(null); }, [activeTabId, state.completionSummary, state.turnStartAt]);
const toggleTerminalPanel = useCallback(() => {
setTerminalPanelOpen((prev) => {
const next = !prev;
@@ -3140,8 +3163,12 @@ export default function App() {
const tabSwitchSeqRef = useRef(0);
const tabSwitchRunningRef = useRef(false);
const tabSwitchPendingRef = useRef<PendingNavigationRequest<{ tabId: string; optimisticTab?: TabMeta; navigationIntentSeq: number }> | null>(null);
const enterChatViewForTabNavigation = useCallback(() => {
setMainView("chat");
}, [setMainView]);
const enqueueTabSwitch = useCallback(
(tabId: string, optimisticTab?: TabMeta): Promise<void> => {
enterChatViewForTabNavigation();
// Claim the shared navigation epoch at click time, before this request
// can wait behind an older tab switch. That immediately invalidates any
// in-flight blank/topic completion from a previous user intent.
@@ -3165,10 +3192,11 @@ export default function App() {
},
);
},
[beginNavigationSurface, isNavigationIntentCurrent, noteNavigationIntent, refreshTabMetas, settleNavigationSurface, switchTab],
[beginNavigationSurface, enterChatViewForTabNavigation, isNavigationIntentCurrent, noteNavigationIntent, refreshTabMetas, settleNavigationSurface, switchTab],
);
const revealBackgroundRuntime = useCallback(async (tabId: string): Promise<void> => {
enterChatViewForTabNavigation();
const navigationIntentSeq = noteNavigationIntent();
beginNavigationSurface(navigationIntentSeq);
try {
@@ -3191,7 +3219,7 @@ export default function App() {
} finally {
settleNavigationSurface(navigationIntentSeq);
}
}, [beginNavigationSurface, isNavigationIntentCurrent, noteNavigationIntent, reassertVisibleTabAfterStaleNavigation, refreshTabMetas, settleNavigationSurface, showToast, switchTab]);
}, [beginNavigationSurface, enterChatViewForTabNavigation, isNavigationIntentCurrent, noteNavigationIntent, reassertVisibleTabAfterStaleNavigation, refreshTabMetas, settleNavigationSurface, showToast, switchTab]);
const handleTabChange = useCallback((id: string) => {
closeTransientOverlays();
@@ -3258,6 +3286,7 @@ export default function App() {
const revealWorkspaceWriter = useCallback(async () => {
if (!activeTabId) return;
enterChatViewForTabNavigation();
const navigationIntentSeq = noteNavigationIntent();
beginNavigationSurface(navigationIntentSeq);
try {
@@ -3281,7 +3310,7 @@ export default function App() {
} finally {
settleNavigationSurface(navigationIntentSeq);
}
}, [activeTabId, beginNavigationSurface, isNavigationIntentCurrent, noteNavigationIntent, reassertVisibleTabAfterStaleNavigation, refreshTabMetas, settleNavigationSurface, showToast, switchTab]);
}, [activeTabId, beginNavigationSurface, enterChatViewForTabNavigation, isNavigationIntentCurrent, noteNavigationIntent, reassertVisibleTabAfterStaleNavigation, refreshTabMetas, settleNavigationSurface, showToast, switchTab]);
const continueInDeliveryWorktree = useCallback(async () => {
const root = state.meta?.workspaceRoot || state.meta?.workspacePath || state.meta?.cwd;
@@ -3399,6 +3428,9 @@ export default function App() {
!transcriptHydrating &&
!hydratePlaceholderActive;
const transcriptItems = hydratePlaceholderActive ? state.hydratePlaceholderItems! : state.items;
const handleLoadOlderHistory = useCallback((targetTurn?: number) => (
activeTabId ? loadOlderHistory(activeTabId, targetTurn) : Promise.resolve(false)
), [activeTabId, loadOlderHistory]);
// Display items: backend history is authoritative after immediate commit.
// rewindState only drives the undo banner, not optimistic truncation.
@@ -3785,6 +3817,9 @@ export default function App() {
}, [beginNavigationSurface, runNavigationRequest, settleNavigationSurface]);
const enqueueNavigation = useCallback((input: DesktopNavigationIntent): Promise<void> => {
// Any navigation (open topic / new session / resume) leaves the automation
// view and returns to the chat workspace.
setMainView("chat");
// Invalidate any in-flight activation's stale apply at ENQUEUE time. The
// queue serializes requests, so a click made while another request runs
// only advances the controller's navigation epoch when it eventually
@@ -3793,7 +3828,7 @@ export default function App() {
// newer surface's cached state (#6613 review).
const navigationIntentSeq = noteNavigationIntent();
return enqueueNavigationWithIntent(input, navigationIntentSeq);
}, [enqueueNavigationWithIntent, noteNavigationIntent]);
}, [enqueueNavigationWithIntent, noteNavigationIntent, setMainView]);
const openBlankSession = useCallback((scope: string, workspaceRoot: string): Promise<void> =>
enqueueNavigation({ kind: "blank", scope, workspaceRoot: scope === "project" ? workspaceRoot : "" }),
@@ -4200,14 +4235,36 @@ export default function App() {
const browserPreviewChrome = typeof window !== "undefined" && !window.runtime;
const browserMockScenario = browserPreviewChrome ? browserMockScenarioParam() : "";
const guidanceQueueMockItems = isGuidanceMockScenario(browserMockScenario) ? GUIDANCE_QUEUE_MOCK_ITEMS : undefined;
const workspacePanelResetWidth = rightDockDetailActive
? RIGHT_DOCK_PREVIEW_DEFAULT_WIDTH
: desktopLayoutStyle === "creation"
? defaultCreationRightDockTreeWidth()
: defaultRightDockTreeWidth();
const workspacePanelResetWidth = desktopLayoutStyle === "creation"
? defaultCreationRightDockTreeWidth()
: defaultRightDockTreeWidth();
const workspacePanelResizeMinWidth = workspacePanelAriaMinWidth(workspacePanelMinWidth, workspacePanelRenderWidth);
const workspacePanelMaxWidth = rightDockDetailActive ? RIGHT_DOCK_MAX_WIDTH : RIGHT_DOCK_TREE_MAX_WIDTH;
const workspacePanelResizeMaxWidth = workspacePanelAvailableWidth;
const sidebarCreation = desktopLayoutStyle === "creation";
// Command palette shortcut label (⌘K / Ctrl+K), platform-aware.
const commandPaletteShortcut = formatShortcutCombo(
resolvedShortcutCombo("commandPalette.open", desktopPlatform),
desktopPlatform,
);
// Dock collapse/expand toggle. Rendered in the dock's own tools row when the
// dock is open (its top-right corner), and in the topic bar when closed.
const dockToggleButton = (
<Tooltip label={effectiveWorkspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>
<button
className={[
"topicbar__chrome-btn",
"topicbar__chrome-btn--workspace",
effectiveWorkspacePanelRenderable ? "topicbar__chrome-btn--active" : "",
].filter(Boolean).join(" ")}
type="button"
onClick={toggleWorkspacePanel}
aria-label={effectiveWorkspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}
aria-pressed={effectiveWorkspacePanelRenderable}
>
<PanelRight size={15} />
</button>
</Tooltip>
);
const topicbarTitle = sidebarImDetailConnection ? t("botDetail.title", { name: sidebarImDetailConnection.title }) : topicDisplayTitle(activeTab);
const topicbarWorkspaceLabel = sidebarImDetailConnection ? t("botDetail.subtitle") : activeTab ? tabWorkspaceTitle(activeTab) : "";
const topicbarWorkspacePath = activeTab?.scope === "project" ? activeTab.workspaceRoot || state.meta?.cwd : "";
@@ -4216,7 +4273,7 @@ export default function App() {
? sidebarImDetailConnection.platformLabel
: topicbarImSource ? t("msg.fromIm", { source: topicbarImSource.label }) : "";
const topicbarImSourcePlatform = sidebarImDetailConnection?.platform ?? topicbarImSource?.platform;
const topicbarSubtitleVisible = !sidebarCreation && Boolean(topicbarWorkspaceLabel || topicbarImSourceLabel);
const topicbarSubtitleVisible = !sidebarCreation && Boolean(activeTab?.isolatedWorktree || topicbarImSourceLabel);
const topicbarSubtitleTitle = sidebarImDetailConnection
? [topicbarWorkspaceLabel, topicbarImSourceLabel, sidebarImScopeLabel(sidebarImDetailConnection, t)].filter(Boolean).join(" · ")
: [topicbarWorkspacePath || topicbarWorkspaceLabel, topicbarImSourceLabel].filter(Boolean).join(" · ");
@@ -4268,6 +4325,7 @@ export default function App() {
].filter(Boolean).join(" ")}
>
<ThemeBackground />
{sidebarWorkbench && <div className="app__dock-toggle">{dockToggleButton}</div>}
<div
ref={layoutRef}
className={[
@@ -4278,7 +4336,7 @@ export default function App() {
sidebarImDetailConnection ? "layout--statusbar-hidden" : "",
sidebarCollapsed ? "layout--sidebar-collapsed" : "",
sidebarResizing ? "layout--resizing layout--sidebar-resizing" : "",
workspacePanelGridOpen ? "layout--workspace-open" : "",
effectiveWorkspacePanelGridOpen ? "layout--workspace-open" : "",
workspacePanelOverlay ? "layout--workspace-overlay" : "",
"layout--terminal-drawer-open",
terminalPanelOpen ? "layout--terminal-drawer-expanded" : "",
@@ -4304,9 +4362,8 @@ export default function App() {
sidebarCollapsed={sidebarCollapsed}
sidebarToggleTitle={sidebarToggleTitle}
workspacePanelMaximized={workspacePanelMaximized}
workspacePanelRenderable={workspacePanelRenderable}
workspaceTogglePressed={workspaceTogglePressed}
workspacePanelLabel={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}
workspacePanelRenderable={effectiveWorkspacePanelRenderable}
workspacePanelLabel={effectiveWorkspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}
onToggleSidebar={toggleSidebar}
onToggleWorkspacePanel={toggleWorkspacePanel}
onTabChange={(id) => void handleTabChange(id)}
@@ -4401,7 +4458,7 @@ export default function App() {
<button
className="sidebar-feature-zone__item"
type="button"
onClick={() => setHeartbeatOpen(true)}
onClick={() => setMainView("automation")}
>
<AlarmClock size={14} aria-hidden="true" />
<span>{t("sidebar.automation")}</span>
@@ -4454,7 +4511,7 @@ export default function App() {
<button
className="sidebar__utility-button"
type="button"
onClick={() => setHeartbeatOpen(true)}
onClick={() => setMainView("automation")}
>
<AlarmClock size={16} aria-hidden="true" />
<span className="sr-only">{t("sidebar.automation")}</span>
@@ -4507,7 +4564,7 @@ export default function App() {
<Tooltip label={t("heartbeat.scheduler")} fill side="right" disabled={sidebarNavTooltipDisabled}>
<button
className="sidebar__navitem"
onClick={() => setHeartbeatOpen(true)}
onClick={() => setMainView("automation")}
>
<AlarmClock size={15} />
<span>{t("sidebar.automation")}</span>
@@ -4557,6 +4614,13 @@ export default function App() {
)}
<section className={`chat-pane${creationEmptyHero ? " chat-pane--creation-empty" : ""}`}>
{mainView === "automation" ? (
<Suspense fallback={<div className="heartbeat-page" />}>
<HeartbeatView onOpenTopic={(scope, workspaceRoot, topicId) => {
void handleOpenTopic(scope, workspaceRoot, topicId);
}} />
</Suspense>
) : (
<>
<header className="topicbar">
{workbenchChromeHidden && (
@@ -4588,6 +4652,7 @@ export default function App() {
size={sidebarCreation ? topicbarTitleEditSize : undefined}
value={topicTitleDraft}
onChange={(event) => setTopicTitleDraft(event.target.value)}
onFocus={(event) => event.currentTarget.select()}
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
@@ -4601,7 +4666,7 @@ export default function App() {
onBlur={() => void commitActiveTopicRename()}
/>
</div>
) : sidebarCreation && topicbarCanRename ? (
) : topicbarCanRename ? (
<h1 title={topicTitle(activeTab)}>
<button
className="topicbar__title-button"
@@ -4615,23 +4680,14 @@ export default function App() {
) : (
<h1 title={sidebarImDetailConnection ? topicbarTitle : topicTitle(activeTab)}>{topicbarTitle}</h1>
)}
{!sidebarCreation && (
<Tooltip label={t("topicBar.renameSession")}>
<button
className="topicbar__icon-btn"
type="button"
disabled={!topicbarCanRename || topicbarEditing}
onClick={startActiveTopicRename}
aria-label={t("topicBar.renameSession")}
>
<Pencil size={14} />
</button>
</Tooltip>
{topicbarWorkspaceLabel && (
<span className="topicbar__workspace-label" title={topicbarWorkspaceLabel}>
{topicbarWorkspaceLabel}
</span>
)}
</div>
{topicbarSubtitleVisible && (
<div className="topicbar__subtitle" title={topicbarSubtitleTitle}>
{topicbarWorkspaceLabel && <span>{topicbarWorkspaceLabel}</span>}
{activeTab?.isolatedWorktree && <WorktreeBadge size={11} />}
{topicbarImSourcePlatform && (
<span className={`topicbar__source-chip topicbar__source-chip--${topicbarImSourcePlatform}`}>
@@ -4643,116 +4699,32 @@ export default function App() {
</div>
<div className="topicbar__spacer" />
<div className="topicbar__actions">
{sidebarCreation && shouldMountExternalOpener(activeTab, Boolean(sidebarImDetailConnection)) && activeTab && (
<ExternalOpener key={activeTab.id} tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
)}
{!sidebarImDetailConnection && (
<>
<Tooltip label={t("topicBar.copyAll")}>
<CopyButton
getText={getSessionMarkdown}
label={t("topicBar.copyAll")}
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
showInlineLabel={false}
/>
</Tooltip>
<div className={`topicbar__export${topicExportOpen ? " topicbar__export--open" : ""}`}>
<Tooltip label={t("topicBar.export")}>
<button
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
type="button"
disabled={!sessionHasContent}
aria-label={t("topicBar.export")}
aria-haspopup="menu"
aria-expanded={topicExportOpen}
onClick={() => setTopicExportOpen((open) => !open)}
>
<Download size={14} />
</button>
</Tooltip>
{topicExportOpen && (
<div className="topicbar__export-menu" role="menu">
<button type="button" role="menuitem" onClick={() => void exportSession("markdown")}>
<FileText size={13} />
<span>{t("topicBar.exportMarkdown")}</span>
</button>
<button type="button" role="menuitem" onClick={() => void exportSession("json")}>
<FileJson size={13} />
<span>{t("topicBar.exportJson")}</span>
</button>
<button type="button" role="menuitem" onClick={() => void exportSession("pdf")}>
<FileDown size={13} />
<span>{t("topicBar.exportPdf")}</span>
</button>
<button type="button" role="menuitem" onClick={() => void exportSession("image")}>
<FileImage size={13} />
<span>{t("topicBar.exportImage")}</span>
</button>
</div>
)}
</div>
</>
)}
{!sidebarCreation && (
<Tooltip label={t("workspace.changedTab")}>
<button
className="topicbar__action-btn topicbar__action-btn--label"
type="button"
aria-label={t("workspace.changedTab")}
aria-pressed={workspacePanelRenderable && rightDockMode === "changed"}
onClick={() => openRightDockMode("changed")}
>
<GitBranch size={14} />
<span>{t("workspace.changedTab")}</span>
</button>
</Tooltip>
)}
{!sidebarImDetailConnection && (
<Tooltip label={t("rightDock.terminal")}>
<button
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
type="button"
aria-label={t("rightDock.terminal")}
aria-pressed={terminalPanelOpen}
onPointerEnter={prefetchTerminalPanel} onFocus={prefetchTerminalPanel}
onClick={toggleTerminalPanel}
>
<TerminalSquare size={14} />
</button>
</Tooltip>
)}
{!sidebarCreation && shouldMountExternalOpener(activeTab, Boolean(sidebarImDetailConnection)) && activeTab && (
<ExternalOpener key={activeTab.id} tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
)}
<Tooltip label={t("summary.session")}>
<Tooltip label={`${t("shortcuts.action.commandPalette")} ${commandPaletteShortcut}`}>
<button
className={`topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility${tasksOpen ? " topicbar__action-btn--active" : ""}`}
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
type="button"
aria-label={t("summary.session")}
aria-expanded={Boolean(tasksOpen)}
onClick={() => setTasksOpen((open) => open ? false : "session")}
aria-label={t("shortcuts.action.commandPalette")}
onClick={() => void openPalette()}
>
<Activity size={14} />
<Search size={15} />
</button>
</Tooltip>
{(sidebarCreation || workbenchChromeHidden) && (
<Tooltip label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>
<button
className={[
"topicbar__chrome-btn",
"topicbar__chrome-btn--workspace",
workspacePanelRenderable ? "topicbar__chrome-btn--active" : "",
workspaceTogglePressed ? "topicbar__chrome-btn--pressed" : "",
].filter(Boolean).join(" ")}
type="button"
onClick={toggleWorkspacePanel}
aria-label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}
aria-pressed={workspacePanelRenderable}
>
<PanelRight size={15} />
</button>
</Tooltip>
{shouldMountExternalOpener(activeTab, Boolean(sidebarImDetailConnection)) && activeTab && (
<ExternalOpener key={activeTab.id} tabId={activeTab.id} dismissSignal={transientOverlayDismissSignal} />
)}
{!sidebarImDetailConnection && (
<TopicbarMoreMenu
sessionHasContent={sessionHasContent}
getSessionMarkdown={getSessionMarkdown}
exportSession={(format) => void exportSession(format)}
openChangedDock={() => openRightDockMode("changed")}
toggleTerminal={toggleTerminalPanel}
prefetchTerminal={prefetchTerminalPanel}
openSessionSummary={() => setTasksOpen((open) => open ? false : "session")}
tasksOpen={Boolean(tasksOpen)}
/>
)}
{sidebarCreation && dockToggleButton}
{tasksOpen && (
<div className="taskmonitor-popover" role="dialog" aria-label={t("summary.session")}>
<Suspense fallback={null}>
@@ -4853,6 +4825,7 @@ export default function App() {
onDeliveryContinue={() => void handleDeliveryContinue()}
onAcceptDelivery={() => void app.AcceptDeliveryToTab(activeTabIdRef.current ?? "")}
onOpenChanges={() => openRightDockMode("changed")}
onOpenVerification={openTurnVerification}
onEditPrompt={handleEditPrompt}
onRewind={handleMessageAction}
checkpoints={state.checkpoints}
@@ -4860,6 +4833,7 @@ export default function App() {
rewindDisabled={Boolean(activeTab?.readOnly) || !controllerReady || hydratePlaceholderActive || rewindState != null || rewindCommitting || state.running || state.messageAction != null || state.approval != null || state.ask != null || clearContextPending}
running={state.running || rewindCommitting}
turnStartAt={state.turnStartAt}
contentRevision={state.historyLayoutRevision}
welcomeVariant={sidebarCreation ? "creation" : "default"}
creationMode={sidebarCreation}
actionHoverMenus={sidebarCreation && !hydratePlaceholderActive && !runtimeTransitioning}
@@ -4867,9 +4841,11 @@ export default function App() {
revealSignal={transcriptRevealSignal}
hydrating={runtimeTransitioning || transcriptHydrating}
hasOlderHistory={!runtimeTransitioning && state.historyHasOlder && !rewindState}
olderHistoryCount={state.historyStartTurn}
historyStartTurn={state.historyStartTurn}
historyTotalTurns={state.historyTotalTurns}
loadingOlderHistory={state.historyOlderLoading}
onLoadOlderHistory={() => activeTabId && loadOlderHistory(activeTabId)}
olderHistoryError={state.historyOlderError}
onLoadOlderHistory={handleLoadOlderHistory}
invocationMetadata={activeTabId ? invocationMetadataByTab[activeTabId] : undefined}
/>
{!runtimeTransitioning && state.hydrateError ? <div className="history-load-error" role="alert"><span>{state.hydrateError}</span><button type="button" className="btn btn--small" onClick={() => void retrySessionHistory(activeTabId)}>{t("common.retry")}</button></div> : null}
@@ -4986,7 +4962,7 @@ export default function App() {
title={t("runtime.workspaceConflictTitle")}
badge={t("runtime.workspaceConflictBadge")}
meta={workspaceConflict.state === "local"
? t("runtime.workspaceConflictLocal", { title: workspaceConflict.ownerTitle || t("runtime.unknownTask") })
? t("runtime.workspaceConflictLocal", { title: workspaceConflict.ownerTitle || t("runtime.unknownTask"), label: workspaceConflict.ownerLabel || t("workspace.title") })
: t("runtime.workspaceConflictExternal")}
note={t("runtime.workspaceConflictNote")}
onCancel={() => {
@@ -5127,9 +5103,10 @@ export default function App() {
</footer>
)}
</>
)}
</section>
{workspacePanelGridOpen && (
{effectiveWorkspacePanelGridOpen && (
<button
className="workspace-panel-resizer"
type="button"
@@ -5137,7 +5114,7 @@ export default function App() {
aria-orientation="vertical"
aria-label={t("rightDock.resize")}
aria-valuemin={workspacePanelResizeMinWidth}
aria-valuemax={Math.max(workspacePanelMaxWidth, workspacePanelRenderWidth)}
aria-valuemax={Math.max(workspacePanelResizeMaxWidth, workspacePanelRenderWidth)}
aria-valuenow={workspacePanelRenderWidth}
onPointerDown={startWorkspacePanelResize}
onKeyDown={resizeWorkspacePanelWithKeyboard}
@@ -5145,7 +5122,7 @@ export default function App() {
/>
)}
{workspacePanelRenderable && (
{effectiveWorkspacePanelRenderable && (
<aside
className={[
"workbench-dock",
@@ -5229,7 +5206,8 @@ export default function App() {
) : (
<Suspense fallback={null}>
<WorkspacePanel
open={workspacePanelRenderable}
key={workspaceTreeMemoryKey}
open={effectiveWorkspacePanelRenderable}
tabId={activeTabId}
cwd={state.meta?.cwd}
workspaceScopeKey={workspaceScopeKey}
@@ -5253,6 +5231,9 @@ export default function App() {
onOpenInTerminal={openTerminalForPath}
initialViewMode={rightDockMode === "changed" ? "changed" : "files"}
completionSummary={state.completionSummary}
turnStartAt={state.turnStartAt}
verificationRevealRequest={verificationRevealRequest}
qualityFloor={composerProfile.qualityFloor}
showViewTabs={false}
creationMode={sidebarCreation}
/>
@@ -5433,14 +5414,12 @@ export default function App() {
/>
)}
<HeartbeatPanel open={heartbeatOpen} onClose={() => setHeartbeatOpen(false)} onOpenTopic={(scope, workspaceRoot, topicId) => {
void handleOpenTopic(scope, workspaceRoot, topicId);
}} />
<Suspense fallback={null}><TranscriptSelectionMenu
enabled={Boolean(activeTabId && !activeTab?.readOnly && !decisionSurface && !sidebarImDetailConnection && !hydratePlaceholderActive)}
resetKey={activeTabId ?? ""}
onAddToChat={addSelectedTextToComposer}
/>
<Suspense fallback={null}>
<TranscriptSelectionMenu
enabled={Boolean(activeTabId && !activeTab?.readOnly && !decisionSurface && !sidebarImDetailConnection && !hydratePlaceholderActive)}
resetKey={activeTabId ?? ""}
onAddToChat={addSelectedTextToComposer}
/>
</Suspense>
{windowsFramelessChrome && (
<WindowsWindowControls
@@ -279,10 +279,10 @@ ok(
ok(
/const WORKSPACE_PANEL_DEFAULT_OPEN = true;/.test(layoutStoreSource) &&
/workspacePanelOpen:\s*loadWorkspacePanelOpen\(\)/.test(layoutStoreSource) &&
/export function saveWorkspacePanelOpen\(open: boolean\)/.test(layoutStoreSource) &&
/workspacePanelOpen:\s*loadWorkspacePanelOpen\(""\)/.test(layoutStoreSource) &&
/export function saveWorkspacePanelOpen\(open: boolean, workspaceRoot = ""\)/.test(layoutStoreSource) &&
/reasonix\.workspacePanel\.open/.test(layoutStoreSource),
"right dock open state is restored from localStorage with expanded first-launch default",
"right dock open state is restored from per-project localStorage with expanded first-launch default",
);
ok(
@@ -502,7 +502,7 @@ ok(
ok(
/const transcriptHydrating = state\.hydrating && !state\.hydrateHistoryLoaded;/.test(appSource) &&
/hydrating=\{transcriptHydrating\}/.test(appSource),
/hydrating=\{runtimeTransitioning \|\| transcriptHydrating\}/.test(appSource),
"Welcome is suppressed only until transcript history has loaded",
);
@@ -517,7 +517,7 @@ ok(
);
ok(
/if \(heroMode\) \{[\s\S]*?const maxHeight = 96;[\s\S]*?setTextareaAutoHeight/.test(composerSource) &&
/if \(heroMode\) \{[\s\S]*?const maxHeight = composerHeroInputMaxHeight\(\);[\s\S]*?setTextareaAutoHeight/.test(composerSource) &&
!/if \(heroMode\) \{\s*setTextareaAutoHeight\(20\);/.test(composerSource),
"Creation hero composer auto-grows multi-line drafts instead of clipping at 20px",
);
@@ -565,13 +565,21 @@ ok(
"topic, blank, IM, and history navigation all use the shared coalescing path",
);
ok(
/const enterChatViewForTabNavigation = useCallback\(\(\) => \{\s*setMainView\("chat"\);/.test(appSource) &&
/const enqueueTabSwitch = useCallback\([\s\S]*?enterChatViewForTabNavigation\(\);[\s\S]*?enqueueNavigationRequest/.test(appSource) &&
/const revealBackgroundRuntime = useCallback[\s\S]*?enterChatViewForTabNavigation\(\);[\s\S]*?RevealBackgroundRuntime/.test(appSource) &&
/const revealWorkspaceWriter = useCallback[\s\S]*?enterChatViewForTabNavigation\(\);[\s\S]*?RevealWorkspaceWriterForTab/.test(appSource),
"every direct tab activation returns overlay pages to the chat view",
);
ok(
!/await resumeSession\(session\.path, targetTab\.id\);/.test(navigationBlock),
"history navigation does not re-resume a session that OpenTopicSession already pinned",
);
ok(
/<HeartbeatPanel[\s\S]*onOpenTopic=\{\(scope, workspaceRoot, topicId\) => \{[\s\S]*void handleOpenTopic\(scope, workspaceRoot, topicId\);[\s\S]*\}\}/.test(appSource),
/<HeartbeatView[\s\S]*onOpenTopic=\{\(scope, workspaceRoot, topicId\) => \{[\s\S]*void handleOpenTopic\(scope, workspaceRoot, topicId\);[\s\S]*\}\}/.test(appSource),
"heartbeat topic navigation uses the guarded open-topic path",
);
@@ -689,8 +697,8 @@ ok(
);
ok(
finalDeclaration(":root[data-theme-style] .workbench-dock__tab--active::after", "bottom") === "1px",
"active dock underline stays inside the visible dock edge",
finalDeclaration(":root[data-theme-style] .workbench-dock__tab--active::after", "display") === "none",
"active dock tab underline is removed in favor of the rounded-rect selected state",
);
for (const selector of [
@@ -0,0 +1,16 @@
import { botConnectionLabel } from "../components/botConnectionSettings";
import type { useT } from "../lib/i18n";
import type { BotConnectionView } from "../lib/types";
const t = ((key: string) => key === "settings.botDingtalk" ? "DingTalk" : key) as ReturnType<typeof useT>;
const connection = {
provider: "dingtalk",
domain: "dingtalk",
} as BotConnectionView;
const label = botConnectionLabel(connection, t);
if (label !== "DingTalk") {
throw new Error(`DingTalk connection label = ${JSON.stringify(label)}, want \"DingTalk\"`);
}
console.log("bot connection settings: DingTalk label passed");
@@ -0,0 +1,27 @@
import { completionSummaryChangeNotice } from "../lib/completionSummary";
import type { DictKey, Translator } from "../lib/i18n";
const messages: Partial<Record<DictKey, string>> = {
"notice.completionChangesTitle": "Changes this turn",
"notice.completionChangesBody": "{count} changes",
};
const translate: Translator = (key, vars) => {
const value = messages[key] ?? key;
return value.replace(/\{(\w+)\}/g, (_, name: string) => String(vars?.[name] ?? `{${name}}`));
};
const notice = completionSummaryChangeNotice({
preset: "balanced",
verdict: "complete",
mutations: 3,
checks_passed: 0,
checks_failed: 0,
checks_suppressed: 0,
review: "passed",
constraint_degraded: false,
}, translate);
if (notice.body !== "3 changes" || notice.body.includes("file")) {
throw new Error(`mutation receipt notice = ${notice.body}, want receipt-neutral changes wording`);
}
@@ -2,6 +2,7 @@
import { createTranscriptHarness } from "./transcript-dom-harness";
import type { Item } from "../lib/useController";
import type { WireCompletionSummary } from "../lib/types";
let passed = 0;
let failed = 0;
@@ -20,22 +21,51 @@ console.log("\ncompletion summary UI");
const harness = await createTranscriptHarness();
let opens = 0;
const earlierSummary = {
preset: "balanced",
verdict: "partial",
mutations: 1,
checks_passed: 2,
checks_failed: 1,
checks_suppressed: 0,
review: "passed",
} satisfies WireCompletionSummary;
const laterSummary = {
preset: "balanced",
verdict: "partial",
mutations: 3,
checks_passed: 4,
checks_failed: 0,
checks_suppressed: 1,
review: "passed",
} satisfies WireCompletionSummary;
type CompletionNotice = Extract<Item, { kind: "notice" }> & { completionSummary: WireCompletionSummary };
const completionNotice = (id: string, summary: WireCompletionSummary): CompletionNotice => ({
kind: "notice",
id,
level: "warn",
variant: "completion",
title: "This turn still needs attention",
text: "One or more checks did not pass. Review the changes before continuing.",
action: "open_changes",
completionSummary: summary,
});
const items: Item[] = [
{ kind: "user", id: "u1", text: "update it" },
{ kind: "assistant", id: "a1", text: "Updated.", reasoning: "", streaming: false },
{
kind: "notice",
id: "q1",
level: "warn",
variant: "completion",
title: "This turn still needs attention",
text: "One or more checks did not pass. Review the changes before continuing.",
action: "open_changes",
},
completionNotice("q1", earlierSummary),
{ kind: "user", id: "u2", text: "update it again" },
{ kind: "assistant", id: "a2", text: "Updated again.", reasoning: "", streaming: false },
completionNotice("q2", laterSummary),
];
try {
await harness.render(items, { running: false, onOpenChanges: () => { opens += 1; } });
const verificationOpens: WireCompletionSummary[] = [];
await harness.render(items, {
running: false,
onOpenChanges: () => { opens += 1; },
onOpenVerification: (summary: WireCompletionSummary) => { verificationOpens.push(summary); },
});
ok(harness.container.textContent?.includes("This turn still needs attention"), "actionable summary stays visible outside the process fold");
ok(!harness.container.textContent?.includes("balanced"), "compact notice exposes no internal enum values");
const button = Array.from(harness.container.querySelectorAll("button")).find((node) => node.textContent?.includes("View changes"));
@@ -43,6 +73,13 @@ try {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await harness.flush();
ok(opens === 1, "View changes delegates to the workspace panel action");
const verifyButtons = Array.from(harness.container.querySelectorAll("button")).filter((node) => node.textContent?.includes("Turn verification"));
ok(verifyButtons.length === 2, "each completion notice offers a Turn verification action");
verifyButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
verifyButtons[1]?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await harness.flush();
ok(verificationOpens[0] === earlierSummary, "an older notice opens its own turn summary");
ok(verificationOpens[1] === laterSummary, "the latest notice opens its own turn summary");
} finally {
await harness.unmount();
await harness.close();
@@ -0,0 +1,87 @@
// Run: tsx src/__tests__/composer-autosize-scrollbar-css.test.ts
//
// Contract: in autosize mode the composer textarea hides its scrollbar while
// the content fits, but once the content exceeds the max height (the JS
// inline overflow-y flips to auto and the card gains
// `.composer-card--auto-overflow`) the thin scrollbar reappears so long
// drafts expose their scrollability (#8494/#8742/#9019). The hero (creation)
// input cap is min(30vh, 160px), not the old 96px hard clip.
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const testDir = dirname(fileURLToPath(import.meta.url));
// Strip comments so declaration parsing never matches prose inside them.
const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
const composerSource = readFileSync(resolve(testDir, "../components/Composer.tsx"), "utf8");
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(a: unknown, b: unknown, label: string) {
if (a === b) {
ok(true, label);
} else {
ok(false, `${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`);
}
}
function matchingBlocks(selector: string): string[] {
const blocks: string[] = [];
const rule = /([^{}]+)\{([^{}]*)\}/g;
let match: RegExpExecArray | null;
while ((match = rule.exec(styles)) !== null) {
const selectors = match[1].split(",").map((part) => part.trim());
if (selectors.includes(selector)) blocks.push(match[2]);
}
return blocks;
}
function finalDeclaration(selector: string, property: string): string | undefined {
let value: string | undefined;
for (const block of matchingBlocks(selector)) {
const declaration = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "g");
let match: RegExpExecArray | null;
while ((match = declaration.exec(block)) !== null) {
value = match[1].trim();
}
}
return value;
}
console.log("\ncomposer autosize scrollbar css");
eq(finalDeclaration(".composer__input", "scrollbar-width"), "none", "autosize textarea hides the scrollbar while content fits");
eq(finalDeclaration(".composer__input::-webkit-scrollbar", "display"), "none", "autosize textarea hides the webkit scrollbar while content fits");
eq(finalDeclaration(".composer-card--auto-overflow .composer__input", "scrollbar-width"), "thin", "overflowing autosize textarea restores the thin scrollbar");
eq(finalDeclaration(".composer-card--auto-overflow .composer__input::-webkit-scrollbar", "display"), "block", "overflowing autosize textarea restores the webkit scrollbar");
eq(finalDeclaration(".composer-card--auto-overflow .composer__input::-webkit-scrollbar", "width"), "6px", "overflowing autosize scrollbar matches the resized-card width");
eq(
finalDeclaration(".app--creation .composer-wrap--hero .composer__input", "max-height"),
"min(30vh, 160px) !important",
"hero input cap is min(30vh, 160px) instead of the 96px hard clip",
);
ok(
/composerAutoOverflow = composerHeight === null && textareaAutoOverflow/.test(composerSource)
&& /composerAutoOverflow \? " composer-card--auto-overflow" : ""/.test(composerSource),
"Composer wires the auto-overflow modifier to the autosize overflow state",
);
ok(
/Math\.min\(Math\.floor\(window\.innerHeight \* 0\.3\), 160\)/.test(composerSource),
"hero input JS cap is min(30vh, 160px)",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,68 @@
// Run: tsx src/__tests__/composer-content-sizing.test.ts
import { resolveComposerContentSizing } from "../lib/composerSizing";
let passed = 0;
let failed = 0;
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`);
failed += 1;
}
}
console.log("\ncomposer content sizing");
const automatic = resolveComposerContentSizing({
contentHeight: 86,
manualLogicalHeight: null,
maxLogicalHeight: 360,
reservedHeight: 58,
});
eq(automatic.inputHeight, 86, "automatic composer follows its content height");
eq(automatic.logicalHeight, null, "automatic composer keeps natural card sizing");
eq(automatic.overflow, false, "automatic composer stays overflow-free below the cap");
const shortManual = resolveComposerContentSizing({
contentHeight: 22,
manualLogicalHeight: 128,
maxLogicalHeight: 360,
reservedHeight: 58,
});
eq(shortManual.inputHeight, 70, "manual height remains the minimum input baseline");
eq(shortManual.logicalHeight, 128, "short content does not shrink below the manual baseline");
const growingManual = resolveComposerContentSizing({
contentHeight: 108,
manualLogicalHeight: 128,
maxLogicalHeight: 360,
reservedHeight: 58,
});
eq(growingManual.inputHeight, 108, "content can grow beyond the manual input baseline");
eq(growingManual.logicalHeight, 166, "manual composer grows to reveal the longer draft");
eq(growingManual.overflow, false, "growing content remains visible before the cap");
const cappedManual = resolveComposerContentSizing({
contentHeight: 420,
manualLogicalHeight: 128,
maxLogicalHeight: 360,
reservedHeight: 58,
});
eq(cappedManual.inputHeight, 302, "manual composer input stops at the viewport-aware cap");
eq(cappedManual.logicalHeight, 360, "manual composer card stops at the logical maximum");
eq(cappedManual.overflow, true, "content scrolls internally only after reaching the cap");
const shrunkManual = resolveComposerContentSizing({
contentHeight: 30,
manualLogicalHeight: 128,
maxLogicalHeight: 360,
reservedHeight: 58,
});
eq(shrunkManual.logicalHeight, 128, "deleting content returns the composer to its manual baseline");
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,320 @@
// Run: tsx src/__tests__/composer-ime.test.tsx
//
// The plain composer textarea must survive CJK IME composition (#8593/#8409).
// While a composition is active the textarea renders uncontrolled so no
// unrelated re-render (autosize, run-strip ticker, selection tracking) can
// write node.value and cancel the in-flight composition; compositionend is
// the authoritative resync point, and a programmatic setText landing
// mid-composition forces a resync instead of being swallowed by the freeze.
//
// jsdom note: these suites install the DOM after react-dom has loaded, so
// React boots with canUseDOM=false and falls back to its keyup-driven
// change-detection polyfill — the input event feeds onInputCapture
// (inputType) while the trailing keyup is what delivers onChange. The
// composition listeners in the IME guard hook are native, so plain
// compositionstart/compositionend Events reach them directly.
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { Composer } from "../components/Composer";
import { LocaleProvider } from "../lib/i18n";
import { ToastProvider } from "../lib/toast";
import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(actual: unknown, expected: unknown, label: string) {
if (actual === expected) ok(true, label);
else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
function flushTimers(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function installDom() {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.InputEvent = dom.window.InputEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
globalThis.MutationObserver = dom.window.MutationObserver;
globalThis.File = dom.window.File;
globalThis.FileReader = dom.window.FileReader;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
globalThis.ResizeObserver = TestResizeObserver;
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: () => ({
matches: true,
media: "(prefers-reduced-motion: reduce)",
onchange: null,
addEventListener() {},
removeEventListener() {},
addListener() {},
removeListener() {},
dispatchEvent: () => false,
}),
});
return dom;
}
async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
const sent: string[] = [];
let currentProps: Parameters<typeof Composer>[0] = {
running: false,
collaborationMode: "normal" as CollaborationMode,
toolApprovalMode: "ask" as ToolApprovalMode,
goal: "",
cwd: "/repo",
modelLabel: "DeepSeek-R1",
onSend: (text) => {
sent.push(text);
},
onCancel: () => {},
onCycleMode: () => {},
onSetMode: () => {},
onSetCollaborationMode: () => {},
onSetToolApprovalMode: () => {},
onToggleYoloApprovalMode: () => {},
onClearGoal: () => {},
onSwitchModel: () => {},
onSetEffort: () => {},
ready: true,
...props,
};
const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
currentProps = { ...currentProps, ...nextProps };
await act(async () => {
root.render(
<LocaleProvider>
<ToastProvider>
<Composer {...currentProps} />
</ToastProvider>
</LocaleProvider>,
);
await flushTimers();
});
};
await paint();
return { root, sent, rerender: paint };
}
// React's value tracker only reports a change (and fires onChange) when the
// DOM value moved outside its patched setter, so IME mutations must go
// through the native prototype setter — exactly how the browser itself
// applies provisional and committed composition text.
function setNativeValue(ta: HTMLTextAreaElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
if (!setter) throw new Error("native textarea value setter unavailable");
setter.call(ta, value);
}
function dispatchInput(ta: HTMLTextAreaElement, data: string, inputType: string, isComposing: boolean) {
ta.dispatchEvent(new window.InputEvent("input", {
bubbles: true,
data,
inputType,
isComposing,
}));
// The change-detection polyfill delivers onChange on keyup.
ta.dispatchEvent(new window.KeyboardEvent("keyup", { key: "Process", bubbles: true }));
}
function pressEnter(ta: HTMLTextAreaElement) {
ta.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
}
function composerTextarea(): HTMLTextAreaElement {
const ta = document.querySelector<HTMLTextAreaElement>("#composer-input");
if (!ta) throw new Error("plain composer textarea did not render");
return ta;
}
console.log("\ncomposer plain textarea IME freeze");
// compositionstart → provisional DOM text → unrelated re-render → the
// provisional text must survive (pre-fix, React's updateTextarea compares
// the controlled value against the live DOM value on every commit and
// writes the stale state back, cancelling the composition — the swallowed
// first keystroke). Commit then resyncs exactly once and leaves a sane caret.
{
const dom = installDom();
const { root, sent, rerender } = await renderComposer();
const ta = composerTextarea();
await act(async () => {
ta.focus();
ta.dispatchEvent(new Event("compositionstart", { bubbles: true }));
// WebView2 ordering: the provisional character is already in the DOM
// before the first input event reaches React.
setNativeValue(ta, "n");
await flushTimers();
});
// An unrelated re-render (parent prop change, run-strip tick) lands while
// the composition is in flight and no input event has fired yet.
await rerender({ modelLabel: "DeepSeek-V4" });
eq(ta.value, "n", "unrelated re-render mid-composition keeps the provisional text");
await act(async () => {
setNativeValue(ta, "ni");
dispatchInput(ta, "i", "insertCompositionText", true);
await flushTimers();
});
eq(ta.value, "ni", "composition input keeps flowing without a DOM clobber");
await act(async () => {
pressEnter(ta);
await flushTimers();
});
eq(sent.length, 0, "Enter during composition does not send");
await act(async () => {
// Commit: the IME replaces the provisional run, fires the commit input
// (Chromium fires it before compositionend) and then compositionend.
setNativeValue(ta, "你");
ta.setSelectionRange(1, 1);
dispatchInput(ta, "你", "insertCompositionText", false);
ta.dispatchEvent(new Event("compositionend", { bubbles: true }));
await flushTimers();
});
eq(ta.value, "你", "committed IME text is intact after compositionend");
eq(ta.selectionStart, 1, "caret sits after the committed character");
await act(async () => {
pressEnter(ta);
await flushTimers();
});
eq(sent.length, 0, "Enter inside the post-composition grace window does not send");
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 150));
pressEnter(ta);
await flushTimers();
});
eq(sent.length, 1, "Enter after the grace window sends");
eq(sent[0], "你", "the sent text is the committed IME text");
await act(async () => {
root.unmount();
});
dom.window.close();
}
// IME cancel: compositionstart, provisional text, then compositionend with
// the DOM restored to the pre-composition value (no commit input). State
// must follow the authoritative DOM, and typing afterwards must keep working.
{
const dom = installDom();
const { root } = await renderComposer();
const ta = composerTextarea();
await act(async () => {
ta.focus();
ta.dispatchEvent(new Event("compositionstart", { bubbles: true }));
setNativeValue(ta, "tmp");
dispatchInput(ta, "p", "insertCompositionText", true);
await flushTimers();
});
eq(ta.value, "tmp", "provisional text is live during composition");
await act(async () => {
setNativeValue(ta, "");
ta.dispatchEvent(new Event("compositionend", { bubbles: true }));
await flushTimers();
});
eq(ta.value, "", "IME cancel restores the pre-composition text");
await act(async () => {
setNativeValue(ta, "x");
ta.setSelectionRange(1, 1);
dispatchInput(ta, "x", "insertText", false);
await flushTimers();
});
eq(ta.value, "x", "typing after an IME cancel keeps working");
await act(async () => {
root.unmount();
});
dom.window.close();
}
// Programmatic setText mid-composition (history recall, menu inserts, draft
// switches) bypasses the textarea's onChange, so it must force an
// authoritative resync through the freeze instead of being swallowed.
{
const dom = installDom();
const { root, sent, rerender } = await renderComposer();
const ta = composerTextarea();
await act(async () => {
ta.focus();
ta.dispatchEvent(new Event("compositionstart", { bubbles: true }));
setNativeValue(ta, "n");
ta.setSelectionRange(1, 1);
dispatchInput(ta, "n", "insertCompositionText", true);
await flushTimers();
});
eq(ta.value, "n", "composition is in flight before the programmatic insert");
await rerender({ insertRequest: { id: 7, text: "snippet body" } });
eq(
ta.value,
"n\n\nsnippet body\n",
"programmatic insert forces a resync and reaches the DOM mid-composition",
);
await act(async () => {
pressEnter(ta);
await flushTimers();
});
eq(sent.length, 1, "the programmatic resync ends the freeze so Enter sends");
ok(sent[0]?.includes("snippet body") === true, "the sent text carries the programmatic insert");
await act(async () => {
root.unmount();
});
dom.window.close();
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -1,10 +1,9 @@
// Run: tsx src/__tests__/composer-resized-input-css.test.ts
//
// Contract: in the user-resized composer (`.composer-card--resized`), the
// input fills the fixed card height and scrolls internally. JS applies no
// inline height in resized mode, so `height: auto` would collapse the
// rows=1 textarea to a single visible line forever (regression shipped in
// desktop-v1.17.11: typing multiline text kept the input one line tall).
// input fills the available card height and can scroll internally. These CSS
// fallbacks remain authoritative during a live resize, when JS temporarily
// removes the content-derived inline height.
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
@@ -196,9 +196,8 @@ console.log("\ncomposer run strip");
dom.window.close();
}
// Execution modes are gone. Composer keeps collaboration (direct/plan/goal)
// and tool-approval controls; it must not render a Light/Balanced/Delivery
// execution-setting trigger or menu.
// Execution modes are gone. Composer keeps collaboration, tool approval, and
// the independent quality floor, but no execution-setting trigger or menu.
{
const dom = installDom();
const { root } = await renderComposer();
@@ -207,7 +206,6 @@ console.log("\ncomposer run strip");
eq(document.querySelector(".composer-profile-menu"), null, "composer has no execution-setting menu");
const chrome = document.body.textContent ?? "";
eq(chrome.includes("Execution setting"), false, "composer chrome does not mention execution setting");
eq(chrome.includes("Delivery"), false, "composer chrome does not mention Delivery mode");
const intentTrigger = document.querySelector(".composer-task-mode-trigger") as HTMLButtonElement | null;
if (!intentTrigger) throw new Error("task intent trigger did not render");
@@ -514,8 +512,8 @@ console.log("\ncomposer run strip");
// Resize consistency: --composer-height always carries the logical height in
// every writer (React render, live drag, keyboard), with the run strip's
// reservation isolated in a CSS calc — so dragging a resized composer during a
// running turn cannot flash-shrink the card.
// reservation isolated in a CSS calc. A manual height is the draft's minimum,
// so content can grow above it without changing the saved resize baseline.
{
const stylesSource = readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../styles.css"), "utf8");
ok(
@@ -557,6 +555,48 @@ console.log("\ncomposer run strip");
eq(card.style.getPropertyValue("--composer-run-strip-reserved"), "0px", "idle card releases the strip reservation");
eq(card.style.getPropertyValue("--composer-height"), "124px", "idle card keeps the user's logical height");
const textarea = document.querySelector(".composer__input") as HTMLTextAreaElement;
let measuredDraftHeight = 108;
Object.defineProperty(textarea, "scrollHeight", {
configurable: true,
get: () => measuredDraftHeight,
});
const updateDraft = async (value: string) => {
await act(async () => {
textarea.focus();
textarea.setSelectionRange(0, textarea.value.length);
const paste = new window.Event("paste", { bubbles: true, cancelable: true });
Object.defineProperty(paste, "clipboardData", {
configurable: true,
value: {
files: [],
items: [],
types: ["text/plain"],
getData: (kind: string) => (kind === "text" || kind === "text/plain" ? value : ""),
},
});
textarea.dispatchEvent(paste);
await flushTimers();
});
};
await updateDraft("a longer pasted draft");
eq(card.style.getPropertyValue("--composer-height"), "166px", "longer draft grows above the manual baseline");
eq(textarea.style.height, "108px", "content-derived input height reveals the longer draft");
eq(textarea.style.overflowY, "hidden", "draft stays scrollbar-free below the cap");
measuredDraftHeight = 22;
await updateDraft("short");
eq(card.style.getPropertyValue("--composer-height"), "124px", "shorter draft returns to the manual baseline");
eq(textarea.style.height, "66px", "manual baseline remains available to short drafts");
measuredDraftHeight = 420;
await updateDraft("an oversized pasted draft");
const viewportCap = Math.min(360, Math.floor(window.innerHeight * 0.4));
eq(card.style.getPropertyValue("--composer-height"), `${viewportCap}px`, "oversized draft stops at the viewport-aware cap");
eq(textarea.style.height, `${viewportCap - 58}px`, "oversized input uses the capped content viewport");
eq(textarea.style.overflowY, "auto", "oversized draft scrolls only after reaching the cap");
await act(async () => {
root.unmount();
});
@@ -0,0 +1,113 @@
import { act } from "react";
import { getTranscriptStore } from "../lib/transcriptStore";
import type { HistorySlice } from "../lib/types";
import { historySliceFromMessages } from "./mockHistorySlice";
type DeferredHistory = {
promise: Promise<HistorySlice>;
resolve: (value: HistorySlice) => void;
};
type OlderHistoryState = {
items: Array<{ kind: string; text?: string }>;
historyOlderLoading: boolean;
historyOlderError?: string;
};
export async function verifyStaleHistoryFingerprint({
olderPage,
loadOlderHistory,
historyCalls,
waitFor,
flushPromises,
equal,
getState,
}: {
olderPage: DeferredHistory;
loadOlderHistory: () => Promise<boolean> | undefined;
historyCalls: () => number;
waitFor: (label: string, predicate: () => boolean) => Promise<void>;
flushPromises: () => Promise<void>;
equal: (actual: unknown, expected: unknown, label: string) => void;
getState: () => OlderHistoryState | undefined;
}) {
let olderLoad: Promise<boolean> | undefined;
await act(async () => {
olderLoad = loadOlderHistory();
await flushPromises();
});
await waitFor("tab-l older page request", () => historyCalls() === 2);
olderPage.resolve({
...historySliceFromMessages(
"tab-l",
[{ role: "user", content: "stale older L" }],
{ cursor: "", turns: 12 },
{ revision: 1, digest: "digest-l-v1" },
),
hasOlder: true,
nextCursor: btoa(JSON.stringify({ v: 1, before: 2 })),
});
await act(async () => {
await olderLoad;
await flushPromises();
});
const state = getState();
equal(state?.items.some((item) => item.kind === "user" && item.text === "stale older L") ?? false, false, "stale older page is discarded after session fingerprint changes");
equal(state?.historyOlderLoading, false, "stale older page releases its loading state");
equal(state?.historyOlderError, "history identity changed", "stale older page enters the explicit retry state instead of silently auto-retrying");
}
export async function verifyDeferredHistoryCloseRace({
olderPage,
loadOlderHistory,
closeTab,
historyCalls,
waitFor,
flushPromises,
equal,
sessionPath,
}: {
olderPage: DeferredHistory;
loadOlderHistory: () => Promise<boolean> | undefined;
closeTab: () => Promise<boolean>;
historyCalls: () => number;
waitFor: (label: string, predicate: () => boolean) => Promise<void>;
flushPromises: () => Promise<void>;
equal: (actual: unknown, expected: unknown, label: string) => void;
sessionPath: string;
}) {
let closingOlderLoad: Promise<boolean> | undefined;
await act(async () => {
closingOlderLoad = loadOlderHistory();
await flushPromises();
});
await waitFor("tab-l closing older page request", () => historyCalls() === 3);
const transcriptStore = getTranscriptStore();
const originalSetPinned = transcriptStore.setPinned.bind(transcriptStore);
let releasedTabStateCommits = 0;
transcriptStore.setPinned = (tabId: string, pinned: boolean) => {
if (tabId === "tab-l") releasedTabStateCommits += 1;
originalSetPinned(tabId, pinned);
};
let tabClosed = false;
await act(async () => {
tabClosed = await closeTab();
await flushPromises();
});
equal(tabClosed, true, "tab-l closes while its older page is deferred");
olderPage.resolve(historySliceFromMessages(
"tab-l",
[{ role: "user", content: "late page after close" }],
{ cursor: "", turns: 12 },
{ revision: 2, digest: "digest-l-v2" },
));
await act(async () => {
await closingOlderLoad;
await flushPromises();
});
transcriptStore.setPinned = originalSetPinned;
equal(releasedTabStateCommits, 0, "late older-page completion cannot recreate the released tab state");
equal(transcriptStore.isResident("tab-l", sessionPath), false, "late older-page completion cannot restore the evicted transcript");
}
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { en } from "../locales/en";
import { zh } from "../locales/zh";
import { zhTW } from "../locales/zh-TW";
assert.equal(en["notice.deliveryIncompleteTitle"], "Task is not complete");
assert.match(en["notice.deliveryIncompleteBody"], /task work or checks that are incomplete/);
assert.equal(en["notice.deliveryIncompleteContinue"], "Continue checks");
assert.equal(en["notice.deliveryRequirementTask"], "remaining implementation work");
assert.equal(zh["notice.deliveryIncompleteTitle"], "任务尚未完成");
assert.match(zh["notice.deliveryIncompleteBody"], /任务或检查未完成/);
assert.equal(zh["notice.deliveryIncompleteContinue"], "继续检查");
assert.equal(zh["notice.deliveryRequirementTask"], "剩余实施工作");
assert.equal(zhTW["notice.deliveryIncompleteTitle"], "任務尚未完成");
assert.match(zhTW["notice.deliveryIncompleteBody"], /任務或檢查未完成/);
assert.equal(zhTW["notice.deliveryIncompleteContinue"], "繼續檢查");
assert.equal(zhTW["notice.deliveryRequirementTask"], "剩餘實作工作");
console.log(" PASS final readiness recovery copy is aligned across locales");
@@ -76,7 +76,7 @@ eq(finalDeclaration(".slashmenu", "max-height"), "min(360px, var(--composer-menu
// AC4 — App.tsx applies footer--decision exactly when a decision surface owns
// the footer, and the pre-existing footer--compact wiring stays intact.
eq(/decisionSurface \? "footer--decision" : ""/.test(appSource), true, "App.tsx toggles footer--decision on decisionSurface");
eq(/visibleDecisionSurface \? "footer--decision" : ""/.test(appSource), true, "App.tsx toggles footer--decision on the visible decision surface");
eq(/terminalPanelOpen && !sidebarCreation \? "footer--compact" : ""/.test(appSource), true, "footer--compact wiring stays intact");
eq(/ref=\{composerWrapRef\}/.test(composerSource), true, "Composer measures the real menu anchor");
eq(/observeComposerMenuViewport\(anchor\)/.test(composerSource), true, "Composer observes geometry while a menu is open");
@@ -0,0 +1,218 @@
// Run: node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/heartbeat-editor.test.tsx
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { HeartbeatView, TaskEditor } from "../custom/features/heartbeat/HeartbeatPanel";
import type { HeartbeatTask } from "../custom/features/heartbeat/heartbeat.types";
import { LocaleProvider } from "../lib/i18n";
let passed = 0;
let failed = 0;
function ok(value: unknown, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function flush(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function button(label: string): HTMLButtonElement | undefined {
return Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((item) => item.textContent?.trim() === label);
}
class NoopResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.HTMLButtonElement = dom.window.HTMLButtonElement;
globalThis.HTMLInputElement = dom.window.HTMLInputElement;
globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
globalThis.Event = dom.window.Event;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.ResizeObserver = NoopResizeObserver as unknown as typeof ResizeObserver;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
let nextID = 0;
let savedUpdate: { tasks?: HeartbeatTask[] } | null = null;
let backendTasks: HeartbeatTask[] = [];
let saveShouldFail = false;
Object.assign(window, {
go: {
main: {
App: {
async HeartbeatReloadConfig() { return { revision: 1, etag: "test", tasks: backendTasks }; },
async HeartbeatSaveConfig(update: { tasks?: HeartbeatTask[] }) {
if (saveShouldFail) throw new Error("conflict");
savedUpdate = update;
backendTasks = update.tasks ?? [];
return { revision: 2, etag: "saved", tasks: backendTasks };
},
async HeartbeatTriggerNow() {},
async HeartbeatGenerateID() { nextID += 1; return `draft-${nextID}`; },
async ListWorkspaces() { return [{ name: "Project One", path: "/project-one", current: true }]; },
},
},
},
});
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("missing root");
const root = createRoot(rootElement);
const noopDelete = async () => true;
function renderEditor(task: HeartbeatTask, onSave: (task: HeartbeatTask) => Promise<boolean>, key: string) {
root.render(
<LocaleProvider>
<TaskEditor
key={key}
task={task}
onSave={onSave}
onDelete={noopDelete}
onCloseDetail={() => {}}
/>
</LocaleProvider>,
);
}
console.log("\nheartbeat editor state ownership");
const originalTask: HeartbeatTask = {
id: "existing",
title: "Saved title",
prompt: "Saved prompt",
interval: "30m",
enabled: true,
createdAt: 1,
topicId: "old-topic",
lastRunAt: 100,
};
let submitted: HeartbeatTask | null = null;
await act(async () => {
renderEditor(originalTask, async (task) => { submitted = task; return true; }, "run-state");
await flush();
});
await act(async () => {
button("Daily")?.click();
await flush();
});
await act(async () => {
renderEditor({
...originalTask,
topicId: "fresh-topic",
lastRunAt: 200,
runHistory: [{ at: 200, topicId: "fresh-topic" }],
}, async (task) => { submitted = task; return true; }, "run-state");
await flush();
});
await act(async () => {
button("Save")?.click();
await flush();
});
ok(submitted?.interval.startsWith("24h|daily") === true, "trigger completion preserves the user's edited schedule");
ok(submitted?.topicId === "fresh-topic" && submitted.lastRunAt === 200, "save carries the latest engine-owned run state");
ok(submitted?.runHistory?.[0]?.topicId === "fresh-topic", "save carries run history added while the editor was open");
console.log("\nheartbeat editor failed save");
let resolveSave: ((saved: boolean) => void) | undefined;
const rejectedSave = () => new Promise<boolean>((resolve) => { resolveSave = resolve; });
await act(async () => {
renderEditor({ ...originalTask, id: "conflict" }, rejectedSave, "conflict");
await flush();
});
await act(async () => {
button("Weekly")?.click();
await flush();
});
await act(async () => {
button("Save")?.click();
await flush();
});
ok(button("Save")?.disabled === true, "save stays pending until persistence resolves");
await act(async () => {
resolveSave?.(false);
await flush();
});
ok(button("Weekly")?.classList.contains("set-seg__btn--on") === true, "failed save preserves the local draft");
ok(document.querySelector('[role="alert"]')?.textContent?.includes("Your draft is still here") === true, "failed save reports an actionable error");
ok(button("Save") != null, "failed save remains dirty and retryable");
console.log("\nheartbeat editor frequency conversion");
await act(async () => {
renderEditor({ ...originalTask, id: "weekly-cron", interval: "0 9 * * 1" }, async () => true, "weekly-cron");
await flush();
});
await act(async () => {
button("Interval")?.click();
await flush();
});
ok(button("Custom")?.classList.contains("set-seg__btn--on") === true, "lossy cron conversion keeps the Custom frequency selected");
ok(document.querySelector<HTMLInputElement>('.heartbeat-editor__freq-input--cron')?.value === "0 9 * * 1", "lossy conversion keeps the original cron expression");
ok(document.querySelector('.heartbeat-editor__inline-error')?.textContent?.includes("cannot be converted") === true, "lossy conversion explains why the editor did not switch");
console.log("\nheartbeat recommendation draft");
await act(async () => {
root.render(<LocaleProvider><HeartbeatView /></LocaleProvider>);
await flush();
await flush();
});
await act(async () => {
document.querySelector<HTMLButtonElement>(".heartbeat-suggestion")?.click();
await flush();
await flush();
});
ok(document.querySelector<HTMLInputElement>('[aria-label="Title"]')?.value === "Daily review", "recommendation keeps its prefilled editor open");
ok(document.body.textContent?.includes("Select a task to view details") !== true, "recommendation is not cleared by the missing-task cleanup effect");
ok(button("Ask")?.classList.contains("set-seg__btn--on") === true, "recommendation defaults to ask approval");
ok(button("Global") != null, "recommendation remains a new draft with editable scope");
await act(async () => {
button("Save")?.click();
await flush();
});
ok(savedUpdate?.tasks?.[0]?.enabled === false, "recommendation stays disabled until the user explicitly enables it");
ok(savedUpdate?.tasks?.[0]?.approvalMode === "ask", "recommendation persists the safe ask approval default");
saveShouldFail = true;
await act(async () => {
const productSuggestion = Array.from(document.querySelectorAll<HTMLButtonElement>(".heartbeat-suggestion"))
.find((item) => item.textContent?.includes("Product update"));
productSuggestion?.click();
await flush();
});
await act(async () => {
button("Save")?.click();
await flush();
await flush();
});
ok(document.querySelector<HTMLInputElement>('[aria-label="Title"]')?.value === "Product update digest", "parent save conflict keeps the unsaved recommendation draft open");
ok(document.querySelector('.heartbeat-editor__save-error')?.textContent?.includes("Your draft is still here") === true, "parent save conflict is reported instead of marking the draft clean");
await act(async () => root.unmount());
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { heartbeatFeatureKeys } from "../custom/features/heartbeat/heartbeat.i18n";
const testDir = dirname(fileURLToPath(import.meta.url));
const globalLocales = ["en.ts", "zh.ts", "zh-TW.ts"].map((name) =>
readFileSync(resolve(testDir, "../locales", name), "utf8"),
);
assert.ok(heartbeatFeatureKeys.length > 0, "Heartbeat owns feature-local translations");
for (const key of heartbeatFeatureKeys) {
for (const source of globalLocales) {
assert.ok(!source.includes(`"${key}"`), `${key} stays out of the global locale chunks`);
}
}
const panel = readFileSync(resolve(testDir, "../custom/features/heartbeat/HeartbeatPanel.tsx"), "utf8");
assert.match(panel, /const t = useHeartbeatT\(\)/, "the lazy Heartbeat page resolves its feature-local translations");
console.log(` PASS ${heartbeatFeatureKeys.length} Heartbeat translations stay feature-local`);
@@ -0,0 +1,81 @@
// Run: tsx src/__tests__/heartbeat-mutation-queue.test.ts
import { JSDOM } from "jsdom";
import type { HeartbeatTask } from "../custom/features/heartbeat/heartbeat.types";
let passed = 0;
let failed = 0;
function ok(value: unknown, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function flush(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "http://localhost/" });
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
type ConfigUpdate = { revision: number; etag: string; tasks: HeartbeatTask[] };
type SaveCall = ConfigUpdate & { resolve: (view: ConfigUpdate) => void };
const initial: HeartbeatTask[] = [
{ id: "a", title: "A", prompt: "a", interval: "1h", enabled: true },
{ id: "b", title: "B", prompt: "b", interval: "1h", enabled: true },
];
const saveCalls: SaveCall[] = [];
Object.assign(window, {
go: {
main: {
App: {
async HeartbeatReloadConfig() {
return { revision: 1, etag: "etag-1", tasks: initial };
},
HeartbeatSaveConfig(update: ConfigUpdate) {
return new Promise<ConfigUpdate>((resolve) => saveCalls.push({ ...update, resolve }));
},
},
},
},
});
const { heartbeatListTasks, heartbeatMutateTasks } = await import("../custom/features/heartbeat/heartbeat.bridge");
console.log("\nheartbeat mutation queue");
await heartbeatListTasks();
const toggle = heartbeatMutateTasks((tasks) => tasks.map((task) => (
task.id === "a" ? { ...task, enabled: !task.enabled } : task
)));
const remove = heartbeatMutateTasks((tasks) => tasks.filter((task) => task.id !== "b"));
await flush();
ok(saveCalls.length === 1, "only one full-table mutation is in flight");
ok(saveCalls[0]?.revision === 1 && saveCalls[0]?.etag === "etag-1", "first mutation uses the loaded config token");
const firstTasks = saveCalls[0]?.tasks ?? [];
saveCalls[0]?.resolve({ revision: 2, etag: "etag-2", tasks: firstTasks });
await flush();
ok(saveCalls.length === 2, "second mutation starts after the first save settles");
ok(saveCalls[1]?.revision === 2 && saveCalls[1]?.etag === "etag-2", "second mutation uses the new config token");
ok(saveCalls[1]?.tasks.length === 1 && saveCalls[1]?.tasks[0]?.id === "a", "second mutation rebases deletion onto the saved task list");
ok(saveCalls[1]?.tasks[0]?.enabled === false, "rebased deletion preserves the earlier toggle");
const secondTasks = saveCalls[1]?.tasks ?? [];
saveCalls[1]?.resolve({ revision: 3, etag: "etag-3", tasks: secondTasks });
const [afterToggle, afterDelete] = await Promise.all([toggle, remove]);
ok(afterToggle.find((task) => task.id === "a")?.enabled === false, "first caller receives its authoritative save");
ok(afterDelete.length === 1 && afterDelete[0]?.id === "a", "second caller receives the combined authoritative state");
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -1,11 +1,23 @@
// Run: tsx src/__tests__/heartbeat-next-run.test.ts
import { heartbeatBuildCycleInterval, heartbeatNextRunAt } from "../custom/features/heartbeat/HeartbeatPanel";
import { changeHeartbeatFrequency, cronToInterval, heartbeatBuildCycleInterval, heartbeatNextRunAt, intervalToCron, mergeEngineRunState, nextCycleRunAt, prepareTasksByNextRun } from "../custom/features/heartbeat/HeartbeatPanel";
let passed = 0;
let failed = 0;
function eq(a: unknown, b: unknown, label: string) {
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null && !Array.isArray(a) && !Array.isArray(b)) {
const ak = JSON.stringify(a, Object.keys(a as object).sort());
const bk = JSON.stringify(b, Object.keys(b as object).sort());
if (ak === bk) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${bk}, got ${ak}\n`);
failed += 1;
}
return;
}
if (a === b) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
@@ -87,5 +99,329 @@ eq(
"weekly default uses one weekday",
);
eq(
heartbeatNextRunAt(
{ interval: "0 9 * * 1-5", lastRunAt: 0 },
localMs(2026, 6, 15, 10, 0),
),
localMs(2026, 6, 16, 9, 0),
"cron weekday schedule computes next business-day run",
);
eq(
heartbeatNextRunAt(
{ interval: "*/15 * * * *", lastRunAt: 0 },
localMs(2026, 6, 18, 10, 7),
),
localMs(2026, 6, 18, 10, 15),
"cron every-15-minutes computes next slot",
);
eq(
heartbeatNextRunAt(
{ interval: "0 9 * * 1-5", lastRunAt: localMs(2026, 6, 15, 9, 0) },
localMs(2026, 6, 15, 9, 0),
),
localMs(2026, 6, 16, 9, 0),
"cron next run ignores lastRunAt and uses wall clock",
);
console.log("\nengine-run merge (SivanCola review: trigger must not clobber draft)");
// 立即运行返回后:磁盘旧快照只贡献 runHistory/topicId/lastRunAt,等待期间的
// 草稿编辑(title/prompt/interval)必须保留
eq(
mergeEngineRunState(
{ id: "t1", title: "草稿新标题", prompt: "草稿新 prompt", interval: "30m", enabled: true, scope: "global", lastRunAt: 100 },
{ id: "t1", title: "旧标题", prompt: "旧 prompt", interval: "1h", enabled: true, scope: "global", runHistory: [{ at: 200, topicId: "topic-2" }], topicId: "topic-2", lastRunAt: 200 },
),
{ id: "t1", title: "草稿新标题", prompt: "草稿新 prompt", interval: "30m", enabled: true, scope: "global", runHistory: [{ at: 200, topicId: "topic-2" }], topicId: "topic-2", lastRunAt: 200 },
"trigger merge keeps draft title while adopting engine run fields",
);
// 没有 runHistory 的旧磁盘快照不得清掉草稿中已有 runHistory
eq(
mergeEngineRunState(
{ id: "t1", title: "草稿标题", prompt: "p", interval: "30m", enabled: true, runHistory: [{ at: 50, topicId: "t" }], lastRunAt: 100 },
{ id: "t1", title: "旧标题", prompt: "旧", interval: "1h", enabled: true, lastRunAt: 110 },
),
{ id: "t1", title: "草稿标题", prompt: "p", interval: "30m", enabled: true, runHistory: [{ at: 50, topicId: "t" }], lastRunAt: 110 },
"trigger merge adopts fresh lastRunAt without dropping draft runHistory",
);
// ── 周期转 cron / 周期 next-run 语义(SivanCola review 回归) ──
console.log("cycle → cron conversion guards");
// biweekly 无法无损转 cron → null
eq(intervalToCron("336h|biweekly:mon@09:00"), null, "biweekly refuses cron conversion");
// 秒级任务无法转 cron → null
eq(intervalToCron("30s"), null, "seconds refuse cron conversion");
// 跨午夜窗口 22:00–06:00 无法表达 → null
eq(intervalToCron("1h", "22:00", "06:00"), null, "cross-midnight window refuses conversion");
// 时间窗口结束时刻 exclusive:09:00–17:00 → 小时 9-16(不含 17 点)
eq(intervalToCron("1h", "09:00", "17:00"), "0 9-16 * * *", "window end hour is exclusive");
// SivanCola review (2026-08-14): 2h + 窗口不能转成 0 9-16(那会把每2小时
// 变成每小时,频率放大)→ 拒绝转换
eq(intervalToCron("2h", "09:00", "17:00"), null, "2h window refuses conversion (frequency would grow)");
// 非整点窗口 09:30-17:30 会被截断成整点 → 拒绝转换
eq(intervalToCron("1h", "09:30", "17:30"), null, "non-top-of-hour window refuses conversion");
eq(intervalToCron("30m", "09:30", "17:30"), null, "non-top-of-hour window refuses m-interval conversion");
// 周期任务带残留窗口:周期任务有自己的时刻(@12:00),引擎忽略窗口,
// 转 cron 必须保持原时刻,不能折叠成窗口小时范围(否则每天变成每小时)
eq(intervalToCron("24h|daily@12:00", "09:00", "17:00"), "0 12 * * *", "daily with stale window keeps its own clock");
// daily 周期正常转换(无窗口)
eq(intervalToCron("24h|daily@09:00"), "0 9 * * *", "daily converts to cron");
// weekly 周期正常转换
eq(intervalToCron("168h|weekly:mon,wed@09:00"), "0 9 * * 1,3", "weekly converts to cron");
// monthly 周期正常转换
eq(intervalToCron("720h|monthly:15@09:00"), "0 9 15 * *", "monthly converts to cron");
console.log("cycle next-run (schedule semantics, not cron)");
// daily: 下个 22:00
eq(
nextCycleRunAt("24h|daily@22:00", localMs(2026, 8, 11, 10, 0)),
localMs(2026, 8, 11, 22, 0),
"daily next run is today's 22:00",
);
// daily: 已过 22:00 → 明天 22:00
eq(
nextCycleRunAt("24h|daily@22:00", localMs(2026, 8, 11, 23, 0)),
localMs(2026, 8, 12, 22, 0),
"daily next run rolls to tomorrow after 22:00",
);
// weekly: 下个周五 16:00(2026-08-14 是周五)
eq(
nextCycleRunAt("168h|weekly:fri@16:00", localMs(2026, 8, 11, 10, 0)),
localMs(2026, 8, 14, 16, 0),
"weekly next run is next Friday 16:00",
);
// monthly: 下个 15 号 09:00
eq(
nextCycleRunAt("720h|monthly:15@09:00", localMs(2026, 8, 11, 10, 0)),
localMs(2026, 8, 15, 9, 0),
"monthly next run is 15th 09:00",
);
// 月末 clamp:monthly:31 在 2026-04-01 之后的下一次是 2026-04-30(4 月只有
// 30 天,后端 monthlyCandidate 将 day=31 clamp 到当月末)——前端镜像后端
// 语义,不能跳到 5 月 31 号。
eq(
nextCycleRunAt("720h|monthly:31@09:00", localMs(2026, 4, 1, 10, 0)),
localMs(2026, 4, 30, 9, 0),
"monthly day-31 clamps to April 30 (mirrors backend monthlyCandidate)",
);
// 闰年 2 月:day=29 在非闰年 2027 年 clamp 到 2 月 28 日
eq(
nextCycleRunAt("720h|monthly:29@09:00", localMs(2027, 2, 1, 10, 0)),
localMs(2027, 2, 28, 9, 0),
"monthly day-29 clamps to February 28 in a non-leap year",
);
// 闰年 2028 年 2 月 29 日保持 29 日
eq(
nextCycleRunAt("720h|monthly:29@09:00", localMs(2028, 2, 1, 10, 0)),
localMs(2028, 2, 29, 9, 0),
"monthly day-29 keeps February 29 in a leap year",
);
// yearly: 下个 1-1 09:00(2027-01-01)
eq(
nextCycleRunAt("8760h|yearly:1-1@09:00", localMs(2026, 8, 11, 10, 0)),
localMs(2027, 1, 1, 9, 0),
"yearly next run is next Jan 1st 09:00",
);
// 离线期间已到期:daily 任务上次运行在 2026-08-16 09:00,schedule 基于
// lastRunAt 求下一次 = 2026-08-17 09:00,而当前已到 8/18 10:00 → next 落在
// 过去。taskNextRun 据此显示 dueSoon(当前应执行),而不是跳过到明天。
eq(
heartbeatNextRunAt(
{ interval: "24h|daily@09:00", lastRunAt: localMs(2026, 8, 16, 9, 0) },
localMs(2026, 8, 18, 10, 0),
),
localMs(2026, 8, 17, 9, 0),
"daily next run falls in the past when the task is overdue offline",
);
// ── review fixes 回归 ──
console.log("cron dow=7 Sunday alias");
eq(
heartbeatNextRunAt(
{ interval: "0 9 * * 7", lastRunAt: 0 },
localMs(2026, 8, 10, 10, 0), // Monday
),
localMs(2026, 8, 16, 9, 0), // next Sunday 09:00
"dow=7 (Sunday alias) computes the next Sunday run",
);
eq(
heartbeatNextRunAt(
{ interval: "0 9 * * 0,7", lastRunAt: 0 },
localMs(2026, 8, 10, 10, 0), // Monday
),
localMs(2026, 8, 16, 9, 0),
"dow=0,7 both spellings compute the next Sunday run",
);
console.log("biweekly parity mirrors the Go engine (Monday week anchor)");
// 2026-08-06 is a Thursday; engine parity is anchored on the Monday of the
// creation week (2026-08-03). Candidate Monday 2026-08-10 is week 1 away →
// skipped; next fire is Monday 2026-08-17 (week 2).
eq(
nextCycleRunAt("336h|biweekly:mon@09:00", localMs(2026, 8, 10, 10, 0), localMs(2026, 8, 6, 9, 0)),
localMs(2026, 8, 17, 9, 0),
"biweekly with Thursday anchor skips the first Monday (Monday-anchored parity)",
);
// Anchor on a Monday itself: creation week = 2026-08-10; next fire is 2026-08-24.
eq(
nextCycleRunAt("336h|biweekly:mon@09:00", localMs(2026, 8, 10, 10, 0), localMs(2026, 8, 10, 9, 0)),
localMs(2026, 8, 24, 9, 0),
"biweekly with Monday anchor fires every other Monday",
);
console.log("cronToInterval refuses lossy conversions");
eq(cronToInterval("0 9 * * 1"), null, "weekly cron refuses interval conversion");
eq(cronToInterval("0 9 * * *"), null, "fixed-time cron refuses interval conversion");
eq(cronToInterval("0 0 1 * *"), null, "dom-restricted cron refuses interval conversion");
eq(cronToInterval("*/15 * * * *"), "15m", "every-N-minutes cron converts");
eq(cronToInterval("0 */2 * * *"), "2h", "every-N-hours cron converts");
eq(cronToInterval("0 * * * *"), null, "top-of-every-hour is not a simple interval");
console.log("frontend isCronExpr field bounds (dom/month 1-based, step>0, lo<=hi)");
eq(
heartbeatNextRunAt(
{ interval: "0 0 0 * *", lastRunAt: 0 }, // dom=0 — "midnight every day" typo
localMs(2026, 8, 10, 10, 0),
),
null,
"dom=0 is rejected: no next run is computed for an invalid expression",
);
// SivanCola review: 永不执行的表达式必须被拒绝
eq(
heartbeatNextRunAt(
{ interval: "*/0 * * * *", lastRunAt: 0 }, // step 0: value % 0 is never 0
localMs(2026, 8, 10, 10, 0),
),
null,
"zero step */0 is rejected (never fires)",
);
eq(
heartbeatNextRunAt(
{ interval: "0 0 5-1 * *", lastRunAt: 0 }, // descending range never matches
localMs(2026, 8, 10, 10, 0),
),
null,
"descending range 5-1 is rejected (never matches)",
);
eq(
heartbeatNextRunAt(
{ interval: "0 0 1 * 8", lastRunAt: 0 }, // dow 8 out of range
localMs(2026, 8, 10, 10, 0),
),
null,
"dow=8 is rejected (out of range)",
);
eq(
heartbeatNextRunAt(
{ interval: "0 0 1 0 *", lastRunAt: 0 }, // month=0
localMs(2026, 8, 10, 10, 0),
),
null,
"month=0 is rejected",
);
eq(
heartbeatNextRunAt(
{ interval: "0 0 1 * *", lastRunAt: 0 },
localMs(2026, 8, 10, 10, 0),
),
localMs(2026, 9, 1, 0, 0),
"valid dom expression still computes a next run",
);
console.log("cron steps use field minima and retain long-horizon matches");
eq(
heartbeatNextRunAt({ interval: "0 0 * */2 *", lastRunAt: 0 }, localMs(2025, 12, 31, 12, 0)),
localMs(2026, 1, 1, 0, 0),
"wildcard month step starts from January",
);
eq(
heartbeatNextRunAt({ interval: "0 0 * */2 *", lastRunAt: 0 }, localMs(2026, 1, 31, 0, 0)),
localMs(2026, 3, 1, 0, 0),
"wildcard month step skips February",
);
eq(
heartbeatNextRunAt({ interval: "1/2 * * * *", lastRunAt: 0 }, localMs(2026, 1, 1, 0, 1)),
localMs(2026, 1, 1, 0, 3),
"single-value step continues from its explicit start",
);
eq(
heartbeatNextRunAt({ interval: "0 0 29 2 *", lastRunAt: 0 }, localMs(2028, 3, 1, 0, 0)),
localMs(2032, 2, 29, 0, 0),
"leap-day cron searches beyond one year",
);
console.log("task list computes each next run once");
const listNow = localMs(2026, 8, 10, 10, 0);
const listTasks = [
{ id: "late", title: "late", prompt: "", interval: "0 0 29 2 *", enabled: true, lastRunAt: 1 },
{ id: "new", title: "new", prompt: "", interval: "0 0 31 2 *", enabled: true },
{ id: "paused", title: "paused", prompt: "", interval: "0 0 31 2 *", enabled: false, lastRunAt: 1 },
];
let nextRunCalls = 0;
const preparedTasks = prepareTasksByNextRun(listTasks, listNow, (task) => {
nextRunCalls += 1;
return task.id === "late" ? listNow + 60000 : null;
});
eq(nextRunCalls, listTasks.length, "list preparation resolves each task exactly once");
eq(preparedTasks.map(({ task }) => task.id).join(","), "new,late,paused", "list preparation preserves next-run ordering");
eq(preparedTasks[1].nextRunAt, listNow + 60000, "list rows reuse the precomputed next-run value");
console.log("frequency conversion keeps the selected editor on lossy paths");
eq(changeHeartbeatFrequency({ id: "t", title: "t", prompt: "p", enabled: true, interval: "0 9 * * 1" }, "interval"), null, "weekly cron cannot switch to interval");
eq(changeHeartbeatFrequency({ id: "t", title: "t", prompt: "p", enabled: true, interval: "30s" }, "cron"), null, "seconds cannot switch to cron");
eq(changeHeartbeatFrequency({ id: "t", title: "t", prompt: "p", enabled: true, interval: "336h|biweekly:mon@09:00" }, "cron"), null, "biweekly cannot switch to cron");
eq(changeHeartbeatFrequency({ id: "t", title: "t", prompt: "p", enabled: true, interval: "1h", timeWindowStart: "22:00", timeWindowEnd: "06:00" }, "cron"), null, "cross-midnight interval cannot switch to cron");
console.log("biweekly parity uses civil dates across DST");
const originalTZ = process.env.TZ;
process.env.TZ = "America/New_York";
eq(
nextCycleRunAt("336h|biweekly:mon@09:00", localMs(2026, 3, 9, 10, 0), localMs(2026, 3, 2, 9, 0)),
localMs(2026, 3, 16, 9, 0),
"spring-forward keeps the even-week Monday",
);
eq(
nextCycleRunAt("336h|biweekly:mon@09:00", localMs(2026, 11, 2, 10, 0), localMs(2026, 10, 26, 9, 0)),
localMs(2026, 11, 9, 9, 0),
"fall-back keeps the even-week Monday",
);
if (originalTZ === undefined) delete process.env.TZ;
else process.env.TZ = originalTZ;
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,32 @@
// Run: tsx src/__tests__/heartbeat-test-runner-contract.test.ts
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
let passed = 0;
let failed = 0;
function ok(value: unknown, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
const testDir = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(resolve(testDir, "heartbeat-next-run.test.ts"), "utf8");
const exitChecks = Array.from(source.matchAll(/if \(failed > 0\) process\.exit\(1\);/g));
const finalAssertion = Math.max(source.lastIndexOf("eq("), source.lastIndexOf("ok("));
const finalExit = source.lastIndexOf("if (failed > 0) process.exit(1);");
console.log("\nheartbeat test runner contract");
ok(exitChecks.length === 1, "focused suite has exactly one failing-exit gate");
ok(finalExit > finalAssertion, "failing-exit gate runs after every assertion");
ok(source.indexOf("passed, ${failed} failed", finalAssertion) > finalAssertion, "final summary runs after every assertion");
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -21,7 +21,7 @@ assert.match(
/if \(!virtuosoReadyRef\.current \|\| !stick\.current\) return;\n followGrowingTail\(\);\n \}, \[footerHeight, followGrowingTail, stick\]\);/,
"footer resize is routed through the coalesced tail-follow path without depending on item appends",
);
assert.match(controller, /shouldPreferResidentHistory\(reset, options\.preserveCachedHistory\)/, "retry hydrates fetch instead of serving the resident snapshot");
assert.match(controller, /shouldPreferResidentHistory\(resetSurface, options\.preserveCachedHistory\)/, "retry hydrates fetch instead of serving the resident snapshot");
assert.match(
controller,
/loadSessionDataForTab\(tabId, false, "startup", \{ preserveCachedHistory: true \}\)/,
@@ -9,6 +9,7 @@ import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import MarkdownHistory from "../components/MarkdownHistory";
import { TranscriptScrollWriteProvider } from "../components/TranscriptLayoutIntentContext";
import { parseMarkdown, markdownContentRevision } from "../lib/markdownPipeline";
import {
disposeMarkdownWorkerClient,
@@ -219,8 +220,16 @@ console.log("\nmarkdown history rendering");
};
const text = Array.from({ length: 420 }, (_, i) => `Paragraph ${i} with some *content*.`).join("\n\n");
const root5 = createRoot(rootEl);
const writeTranscriptOffset = (_owner: "block-window-prepend", top: number) => {
rootEl.scrollTop = top;
return true;
};
await act(async () => {
root5.render(<MarkdownHistory text={text} entryId="md-history-huge" fallback={null} />);
root5.render(
<TranscriptScrollWriteProvider value={writeTranscriptOffset}>
<MarkdownHistory text={text} entryId="md-history-huge" fallback={null} />
</TranscriptScrollWriteProvider>,
);
});
await flush();
const container = rootEl.querySelector(".md[data-markdown-blocks]");
@@ -267,7 +276,11 @@ console.log("\nmarkdown history rendering");
const replacement = Array.from({ length: 420 }, (_, i) => `Replacement ${i} with some *content*.`).join("\n\n");
await act(async () => {
root5.render(<MarkdownHistory text={replacement} entryId="md-history-huge-replacement" fallback={null} />);
root5.render(
<TranscriptScrollWriteProvider value={writeTranscriptOffset}>
<MarkdownHistory text={replacement} entryId="md-history-huge-replacement" fallback={null} />
</TranscriptScrollWriteProvider>,
);
});
await flush();
eq(rootEl.querySelector(".md[data-markdown-blocks]")?.getAttribute("data-markdown-visible-blocks"), "24", "an equal-sized replacement document resets to its own tail");
@@ -136,6 +136,51 @@ console.log("\nmarkdown streaming → worker final parse");
delete (globalThis as { Worker?: unknown }).Worker;
}
console.log("\nstreaming code fence tail styling");
// An unclosed fence stays in the streaming tail (never committed early) and
// renders with code-block styling until the closing fence arrives (#8843).
{
const root = createRoot(rootEl);
await act(async () => {
root.render(<Markdown text={"intro\n\n"} streaming />);
await flush();
});
await act(async () => {
root.render(<Markdown text={"intro\n\n```js\nconst a = 1;"} streaming />);
await flush();
});
const tail = rootEl.querySelector(".md--stream-tail");
ok(tail, "an open code fence keeps the uncommitted tail mounted");
const pre = tail?.querySelector("pre.code.md--stream-tail-code");
ok(pre, "an open code fence renders with code styling before the closing fence arrives");
eq(pre?.getAttribute("data-lang"), "js", "the streaming code block carries the fence language");
eq(pre?.textContent, "const a = 1;", "the streaming code block shows the fence body without the opener line");
await act(async () => {
root.render(<Markdown text={"intro\n\n```js\nconst a = 1;\nconst b = 2;"} streaming />);
await flush();
});
eq(rootEl.querySelector(".md--stream-tail pre code")?.textContent, "const a = 1;\nconst b = 2;", "the streaming code body grows in place");
await act(async () => {
root.render(<Markdown text={"intro\n\n```js\nconst a = 1;\nconst b = 2;\n```\ndone"} streaming />);
await new Promise((resolve) => setTimeout(resolve, 120));
});
// The closed fence commits on the next animation frame after the parse
// budget; let that frame run inside act before asserting.
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 60));
});
const settledTail = rootEl.querySelector(".md--stream-tail");
ok(!settledTail?.querySelector("pre.md--stream-tail-code"), "a closed fence leaves the code styling to the parsed renderer");
eq(settledTail?.textContent, "done", "text after the closing fence rides the plain tail");
await act(async () => root.unmount());
}
await server.close();
dom.window.close();
@@ -9,7 +9,7 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import ReactMarkdown from "react-markdown";
import { splitStableMarkdownSections, streamingCommitTarget, streamingMarkdownCommitInterval, useRenderedMarkdownText } from "../components/Markdown";
import { splitStableMarkdownSections, splitStreamingTailFence, streamingCommitTarget, streamingMarkdownCommitInterval, useRenderedMarkdownText } from "../components/Markdown";
import MermaidDiagram from "../components/MermaidDiagram";
import {
__setMermaidPanZoomFactoryForTest,
@@ -20,6 +20,7 @@ import {
safelySyncPanZoom,
sanitizeMermaidSvg,
} from "../components/MermaidDiagram";
import { createMermaidPanZoom } from "../components/mermaidPanZoom";
import { LocaleProvider } from "../lib/i18n";
import { REMOTE_MARKDOWN_IMAGE_PATH } from "../lib/markdownImage";
@@ -28,6 +29,7 @@ const styles = readFileSync(resolve(testDir, "../styles.css"), "utf8");
const markdownRendererSource = readFileSync(resolve(testDir, "../components/MarkdownRenderer.tsx"), "utf8");
const markdownComponentsSource = readFileSync(resolve(testDir, "../components/markdownComponents.tsx"), "utf8");
const markdownSource = readFileSync(resolve(testDir, "../components/Markdown.tsx"), "utf8");
const mermaidDiagramSource = readFileSync(resolve(testDir, "../components/MermaidDiagram.tsx"), "utf8");
const messageSource = readFileSync(resolve(testDir, "../components/Message.tsx"), "utf8");
let passed = 0;
@@ -132,6 +134,67 @@ function installDom() {
dom.window.close();
}
// Inline pan/zoom (replaces svg-pan-zoom, #8068): transform math on one
// viewport <g>, zoom clamped to 0.3–8, wheel/drag/dblclick interactions.
{
const dom = installDom();
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 160 80");
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
marker.setAttribute("id", "arrow");
defs.appendChild(marker);
svg.appendChild(defs);
const content = document.createElementNS("http://www.w3.org/2000/svg", "g");
content.setAttribute("id", "drawing");
svg.appendChild(content);
// 640x360 layout box: the meet mapping fits at scale 4, origin (0, 20).
Object.defineProperty(svg, "getBoundingClientRect", {
configurable: true,
value: () => ({ x: 0, y: 0, width: 640, height: 360, top: 0, right: 640, bottom: 360, left: 0, toJSON: () => ({}) }),
});
document.body.appendChild(svg);
const panZoom = createMermaidPanZoom(svg, { minZoom: 0.3, maxZoom: 8, zoomScaleSensitivity: 0.3 });
const viewport = () => svg.querySelector("g[data-mermaid-pan-zoom-viewport]");
ok(svg.querySelector("g[data-mermaid-pan-zoom-viewport] g#drawing"), "inline pan/zoom wraps the drawing in one viewport group");
ok(defs.parentNode === svg, "SVG definitions stay outside the transformed viewport");
ok(!viewport()?.querySelector("defs"), "the viewport does not transform SVG definitions");
ok(svg.querySelector("defs marker#arrow") === marker, "definition children remain available by ID");
panZoom.fit();
eq(viewport()?.getAttribute("transform"), "translate(0 0) scale(1)", "fit keeps the browser meet mapping at unit scale");
svg.dispatchEvent(new dom.window.WheelEvent("wheel", { clientX: 320, clientY: 180, deltaY: -120, bubbles: true, cancelable: true }));
eq(viewport()?.getAttribute("transform"), "translate(-24 -12) scale(1.3)", "wheel zooms in around the cursor");
for (let i = 0; i < 40; i += 1) {
svg.dispatchEvent(new dom.window.WheelEvent("wheel", { clientX: 320, clientY: 180, deltaY: 120, bubbles: true, cancelable: true }));
}
ok(viewport()?.getAttribute("transform")?.endsWith("scale(0.3)"), "wheel zoom-out clamps at the minimum zoom");
panZoom.reset();
eq(viewport()?.getAttribute("transform"), "translate(0 0) scale(1)", "reset restores the fitted unit transform");
for (let i = 0; i < 40; i += 1) {
svg.dispatchEvent(new dom.window.WheelEvent("wheel", { clientX: 320, clientY: 180, deltaY: -120, bubbles: true, cancelable: true }));
}
ok(viewport()?.getAttribute("transform")?.endsWith("scale(8)"), "wheel zoom-in clamps at the maximum zoom");
panZoom.reset();
svg.dispatchEvent(new dom.window.MouseEvent("pointerdown", { button: 0, clientX: 100, clientY: 100, bubbles: true }));
svg.dispatchEvent(new dom.window.MouseEvent("pointermove", { clientX: 130, clientY: 110, bubbles: true }));
eq(viewport()?.getAttribute("transform"), "translate(7.5 2.5) scale(1)", "pointer drag pans the drawing in screen pixels");
svg.dispatchEvent(new dom.window.MouseEvent("pointerup", { clientX: 130, clientY: 110, bubbles: true }));
panZoom.destroy();
svg.dispatchEvent(new dom.window.WheelEvent("wheel", { clientX: 320, clientY: 180, deltaY: -120, bubbles: true, cancelable: true }));
eq(viewport()?.getAttribute("transform"), "translate(7.5 2.5) scale(1)", "destroy detaches the interaction listeners");
ok(!mermaidDiagramSource.includes("svg-pan-zoom"), "MermaidDiagram no longer imports svg-pan-zoom");
ok(!styles.includes("svg-pan-zoom"), "styles no longer carry svg-pan-zoom hooks");
dom.window.close();
}
function parseSvg(svg: string): Document {
return new DOMParser().parseFromString(svg, "image/svg+xml");
}
@@ -199,7 +262,7 @@ console.log("\nmermaid rendering");
eq(streamingCommitTarget("intro\n\npartial paragraph"), "intro\n\n", "commit target stops at the last completed block");
eq(streamingCommitTarget("no blank line yet"), "", "no completed block means nothing to parse yet");
eq(streamingCommitTarget("done\n\nalso done\n\n"), "done\n\nalso done\n\n", "trailing boundary commits everything");
eq(streamingCommitTarget("t\n\n```js\nstreaming code"), "t\n\n```js\nstreaming code", "an open fence keeps the whole text parsed for live highlighting");
eq(streamingCommitTarget("t\n\n```js\nstreaming code"), "t\n\n", "an open fence stays in the tail for code-styled streaming");
eq(streamingCommitTarget("t\n\n$$\n\\int_0^1"), "t\n\n$$\n\\int_0^1", "open display math keeps the whole text parsed");
eq(streamingCommitTarget("t\n\n```\nc\n```\nafter"), "t\n\n```\nc\n```\n", "a closed fence promotes immediately without waiting for a blank line");
eq(streamingCommitTarget("t\n\n$$\nx\n$$\nafter"), "t\n\n$$\nx\n$$\n", "closed display math promotes immediately");
@@ -259,7 +322,7 @@ console.log("\nmermaid rendering");
);
eq(
streamingCommitTarget("t\n\n```\n- not a list\n- also not\n"),
"t\n\n```\n- not a list\n- also not\n",
"t\n\n",
"list markers inside an open fence do not create commit boundaries",
);
eq(
@@ -551,5 +614,15 @@ console.log("\nmermaid rendering");
dom.window.close();
}
{
eq(splitStreamingTailFence("still typing"), null, "a plain tail has no code fence split");
eq(splitStreamingTailFence("```\nc\n```\nafter"), null, "a closed fence leaves no open code tail");
const split = splitStreamingTailFence("```js\nconst a = 1;\nconst b");
eq(split?.head, "", "an open fence at the tail start has no plain head");
eq(split?.lang, "js", "the open fence split keeps the info-string language");
eq(split?.code, "const a = 1;\nconst b", "the open fence split drops the opener line from the code body");
eq(splitStreamingTailFence("para\n\n```\nx")?.head, "para\n\n", "text before the open fence stays plain");
}
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const projectTreeSource = readFileSync(new URL("../components/ProjectTree.tsx", import.meta.url), "utf8");
const styles = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
assert.ok(
projectTreeSource.includes('className="project-tree__section-title project-tree__section-title--pinned"'),
"pinned heading uses a non-interactive section-title class",
);
assert.ok(
!projectTreeSource.includes('className="project-tree__folder" style={{ cursor'),
"pinned heading does not inherit interactive folder hover styles",
);
assert.ok(
projectTreeSource.includes('className="project-tree__section-title-icon" aria-hidden="true"'),
"pinned heading marks its decorative icon as hidden from assistive technology",
);
assert.match(
styles,
/\.sidebar--workbench \.project-tree__section-title--pinned,[^{]*\{[^}]*display:\s*flex;[^}]*height:\s*32px;/s,
"pinned heading keeps the folder-aligned layout without folder interaction styles",
);
console.log(" PASS project tree pinned heading contract");
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { projectTreeTopicRecoveryCopyCount } from "../lib/projectTreeTopic";
import type { ProjectNode } from "../lib/types";
import { en } from "../locales/en";
import { zh } from "../locales/zh";
import { zhTW } from "../locales/zh-TW";
const topic = (recoveryCopyCount?: number): ProjectNode => ({
key: "topic_t1",
kind: "topic",
label: "Topic",
topicId: "t1",
recoveryCopyCount,
});
assert.equal(projectTreeTopicRecoveryCopyCount(topic(2)), 2, "topic rows surface the folded copy count");
assert.equal(projectTreeTopicRecoveryCopyCount(topic(0)), 0, "zero renders no badge");
assert.equal(projectTreeTopicRecoveryCopyCount(topic(undefined)), 0, "missing count renders no badge");
assert.equal(projectTreeTopicRecoveryCopyCount(topic(-1)), 0, "negative count renders no badge");
assert.equal(
projectTreeTopicRecoveryCopyCount({ key: "session_s1", kind: "session", label: "S", recoveryCopyCount: 3 }),
0,
"runtime session rows never carry the badge",
);
const styles = readFileSync(fileURLToPath(new URL("../styles.css", import.meta.url)), "utf8");
assert.match(
styles,
/\.project-tree__topic-recovery-copies\s*\{/s,
"the folded recovery-copy badge has a muted pill style",
);
for (const [name, dict] of Object.entries({ en, zh, zhTW })) {
const label = dict["projectTree.recoveryCopies"];
assert.ok(label && label.includes("{count}"), `${name} localizes projectTree.recoveryCopies with a count placeholder`);
}
console.log(" PASS project tree recovery copy badge contract");
@@ -0,0 +1,81 @@
// Run: tsx src/__tests__/project-tree-rename-optimistic.test.ts
import { projectTreeWithTopicTitle } from "../lib/projectTreeTopic";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
let passed = 0;
let failed = 0;
function eq(a: unknown, b: unknown, label: string) {
if (JSON.stringify(a) === JSON.stringify(b)) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
failed += 1;
}
}
console.log("\nproject tree optimistic rename");
eq(
projectTreeWithTopicTitle(
[
{
key: "p",
kind: "project",
label: "P",
children: [
{ key: "topic_rename", kind: "topic", label: "Old name", topicId: "topic_rename" },
{ key: "topic_keep", kind: "topic", label: "Keep me", topicId: "topic_keep" },
],
},
],
"topic_rename",
"New name",
).map((node) => (node.children ?? []).map((child) => child.label)),
[["New name", "Keep me"]],
"a committed rename repaints the topic row label before the catalog event arrives",
);
eq(
projectTreeWithTopicTitle(
[{ key: "p", kind: "project", label: "P", children: [{ key: "t", kind: "topic", label: "Same", topicId: "t" }] }],
"t",
"Same",
)[0].children?.[0].label,
"Same",
"an unchanged rename label leaves the tree identity stable",
);
eq(
projectTreeWithTopicTitle(
[{ key: "p", kind: "project", label: "P", children: [{ key: "t", kind: "topic", label: "T", topicId: "t" }] }],
"topic_missing",
"New name",
)[0].children?.[0].label,
"T",
"a rename for a topic outside the loaded tree changes nothing",
);
const projectTreeSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../components/ProjectTree.tsx"), "utf8");
eq(
projectTreeSource.includes("projectTreeWithTopicTitle(current, topicId, title)"),
true,
"commitRenameTopic paints the new label optimistically before the refresh round-trip",
);
eq(
projectTreeSource.includes("project-tree__skeleton"),
true,
"an expanded folder with a loading first topic page renders skeleton rows",
);
eq(
/projectTreeRevisionIsFresh\(latestRevisionRef\.current, event\.revision\)\) \{\s*void refresh\(\)/.test(projectTreeSource),
true,
"a stale project-tree revision falls back to a full snapshot refetch instead of dropping the event",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -24,5 +24,15 @@ assert.match(
/:root\[data-theme-style\] \.sidebar--workbench \.project-tree__topic\s*\{[^}]*height:\s*34px;/s,
"workbench topic rows keep a fixed height",
);
assert.match(
styles,
/\.project-tree__group-input\s*\{[^}]*font-size:\s*inherit;/s,
"group rename inputs keep the group title font size",
);
assert.doesNotMatch(
styles,
/\.project-tree__group-input\s*\{[^}]*font-size:\s*11px;/s,
"group rename inputs cannot restore the old smaller font size",
);
console.log(" PASS project tree row stability contract");
@@ -257,6 +257,8 @@ const providerUrlLabel = Array.from(rootEl.querySelectorAll<HTMLLabelElement>("l
);
ok(Boolean(providerUrlLabel) && providerUrlInput?.getAttribute("aria-describedby") !== null, "provider URL input has a programmatic label and description");
ok(rootEl.textContent?.includes("Reasonix uses it unchanged.") === true, "provider URL helper explains exact request behavior");
ok(rootEl.querySelector<HTMLInputElement>('input[placeholder="e.g. my-proxy"]')?.disabled !== true, "new custom provider name stays editable");
ok(rootEl.querySelector<HTMLInputElement>('input[placeholder="e.g. my-proxy"]')?.nextElementSibling?.classList.contains("mem-hint") !== true, "new custom provider editor omits the rename hint");
await act(async () => {
root.render(<div />);
@@ -289,6 +291,8 @@ ok(
const customProviderUrlInput = rootEl.querySelector<HTMLInputElement>(".provider-url-input");
ok(rootEl.querySelectorAll('input[type="radio"]').length === 0, "existing custom providers no longer expose an address mode selector");
ok(customProviderUrlInput?.value === "https://eu.deepseek.com/v1/chat/completions", "legacy base-only providers display their previously effective request URL");
ok(rootEl.querySelector<HTMLInputElement>('input[placeholder="e.g. my-proxy"]')?.disabled === true, "existing custom provider name is locked");
ok(rootEl.querySelector<HTMLInputElement>('input[placeholder="e.g. my-proxy"]')?.nextElementSibling?.textContent === "Changing the provider name is not supported yet", "existing custom provider editor shows the rename hint");
let migratedProvider: ProviderView | undefined;
await act(async () => {
@@ -0,0 +1,182 @@
// Run: tsx src/__tests__/provider-name-readonly.test.tsx
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { ProviderEditor } from "../components/SettingsPanel";
import { LocaleProvider } from "../lib/i18n";
import type { ProviderView } from "../lib/types";
import { en } from "../locales/en";
import { zh } from "../locales/zh";
import { zhTW } from "../locales/zh-TW";
const here = dirname(fileURLToPath(import.meta.url));
const styles = readFileSync(resolve(here, "../styles.css"), "utf8");
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function eq(a: unknown, b: unknown, label: string) {
if (a === b) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
failed += 1;
}
}
function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function matchingBlocks(selector: string): string[] {
const blocks: string[] = [];
const rule = /([^{}]+)\{([^{}]*)\}/g;
let match: RegExpExecArray | null;
while ((match = rule.exec(styles)) !== null) {
const selectors = match[1].split(",").map((part) => part.trim());
if (selectors.includes(selector)) blocks.push(match[2]);
}
return blocks;
}
function finalDeclaration(selector: string, property: string): string | undefined {
let value: string | undefined;
for (const block of matchingBlocks(selector)) {
const declaration = new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "g");
let match: RegExpExecArray | null;
while ((match = declaration.exec(block)) !== null) {
value = match[1].trim();
}
}
return value;
}
const customProvider: ProviderView = {
name: "my-proxy",
builtIn: false,
added: true,
kind: "openai",
baseUrl: "https://example.com/v1",
models: ["demo"],
visionModels: [],
visionModelsConfigured: false,
modelsUrl: "",
default: "demo",
apiKeyEnv: "",
keySet: false,
balanceUrl: "",
contextWindow: 128_000,
reasoningProtocol: "",
thinking: "",
supportedEfforts: [],
defaultEffort: "",
};
function nameHint(root: Element): Element | null {
const input = root.querySelector<HTMLInputElement>('input[placeholder="e.g. my-proxy"]');
const next = input?.nextElementSibling;
return next?.classList.contains("mem-hint") ? next : null;
}
function renderEditor(initial?: ProviderView) {
return (
<LocaleProvider>
<ProviderEditor
key={initial?.name ?? "new-provider"}
initial={initial}
kinds={["openai"]}
busy={false}
onCancel={() => undefined}
onSave={() => undefined}
/>
</LocaleProvider>
);
}
console.log("\nprovider name readonly");
eq(en["settings.customProviderNameReadonlyHint"], "Changing the provider name is not supported yet", "English rename hint");
eq(zh["settings.customProviderNameReadonlyHint"], "暂不支持供应商名称修改", "Simplified Chinese rename hint");
eq(zhTW["settings.customProviderNameReadonlyHint"], "暫不支援供應商名稱修改", "Traditional Chinese rename hint");
eq(finalDeclaration(".provider-name-input:disabled", "opacity"), "0.6", "disabled provider-name input is faded");
eq(finalDeclaration(".provider-name-input:disabled", "cursor"), "not-allowed", "disabled provider-name input uses not-allowed cursor");
eq(finalDeclaration(".mem-hint.provider-name-readonly-hint", "color"), "var(--fg-dim)", "readonly hint uses the stronger secondary text color");
eq(finalDeclaration(".mem-input:disabled", "opacity"), undefined, "no global mem-input:disabled fade rule remains");
eq(finalDeclaration(".mem-select:disabled", "opacity"), undefined, "no global mem-select:disabled fade rule remains");
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.localStorage = dom.window.localStorage;
globalThis.sessionStorage = dom.window.sessionStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
window.scrollTo = () => {};
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(renderEditor());
await flushPromises();
});
const newNameInput = rootEl.querySelector<HTMLInputElement>('input[placeholder="e.g. my-proxy"]');
ok(newNameInput?.disabled !== true, "new custom provider name stays editable");
ok(newNameInput?.classList.contains("mem-input") === true, "provider name keeps mem-input base styling");
ok(newNameInput?.classList.contains("provider-name-input") === true, "provider name carries the scoped provider-name-input class");
ok(Boolean(newNameInput?.id), "new custom provider name has a stable input id");
eq(rootEl.querySelector<HTMLLabelElement>(`label[for="${newNameInput?.id}"]`)?.textContent, en["settings.customProviderName"], "new custom provider name has a programmatic label");
eq(newNameInput?.getAttribute("aria-describedby"), null, "editable provider name omits the readonly description reference");
ok(nameHint(rootEl) === null, "new custom provider editor omits the rename hint");
await act(async () => {
root.render(renderEditor(customProvider));
await flushPromises();
});
const existingNameInput = rootEl.querySelector<HTMLInputElement>('input[placeholder="e.g. my-proxy"]');
ok(existingNameInput?.disabled === true, "existing custom provider name is locked");
ok(existingNameInput?.classList.contains("mem-input") === true, "locked provider name keeps mem-input base styling");
ok(existingNameInput?.classList.contains("provider-name-input") === true, "locked provider name carries the scoped provider-name-input class");
eq(rootEl.querySelector<HTMLLabelElement>(`label[for="${existingNameInput?.id}"]`)?.textContent, en["settings.customProviderName"], "locked provider name has a programmatic label");
const existingNameHint = nameHint(rootEl);
eq(existingNameHint?.textContent, en["settings.customProviderNameReadonlyHint"], "existing custom provider editor shows the rename hint");
ok(existingNameHint?.classList.contains("provider-name-readonly-hint") === true, "rename hint carries the stronger contrast class");
ok(Boolean(existingNameHint?.id), "rename hint has a stable id");
eq(existingNameInput?.getAttribute("aria-describedby"), existingNameHint?.id, "locked provider name references the rename hint");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -3,7 +3,7 @@
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { QuestionJumpBar } from "../components/QuestionJumpBar";
import { QUESTION_JUMP_MAX_MARKERS, QuestionJumpBar, questionTurnFromRailY, sampledQuestionTurns } from "../components/QuestionJumpBar";
import { LocaleProvider } from "../lib/i18n";
import type { QuestionAnchor } from "../lib/transcriptGrouping";
@@ -24,6 +24,10 @@ function flushTimers(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
function rect(top: number, height: number, width: number): DOMRect {
return { top, bottom: top + height, left: 0, right: width, height, width } as DOMRect;
}
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
@@ -37,49 +41,106 @@ globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.localStorage = dom.window.localStorage;
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value() {},
});
const questions: QuestionAnchor[] = [
{ id: "u1", text: "first question", turn: 0 },
{ id: "u2", text: "second question", turn: 1 },
{ id: "u3", text: "third question", turn: 2 },
{ id: "u1", text: "first question", turn: 0, loaded: true },
{ id: "u2", text: "second question", turn: 1, loaded: true },
{ id: "u3", text: "third question", turn: 2, loaded: true },
];
const jumps: QuestionAnchor[] = [];
const root = createRoot(document.getElementById("root")!);
function ControlledJumpBar({
loadedQuestions,
totalQuestions,
}: {
loadedQuestions: QuestionAnchor[];
totalQuestions: number;
}) {
const [activeTurn, setActiveTurn] = React.useState<number | null>(totalQuestions - 1);
return (
<QuestionJumpBar
loadedQuestions={loadedQuestions}
totalQuestions={totalQuestions}
activeTurn={activeTurn}
onJump={(question) => {
jumps.push(question);
setActiveTurn(question.turn);
}}
/>
);
}
console.log("\nquestion jump bar");
await act(async () => {
root.render(
<LocaleProvider>
<QuestionJumpBar questions={questions} onJump={(question) => jumps.push(question)} />
<ControlledJumpBar key="short" loadedQuestions={questions} totalQuestions={3} />
</LocaleProvider>,
);
await flushTimers();
});
const rail = document.querySelector(".jump-scroll") as HTMLElement;
let bar = document.querySelector(".jump-bar") as HTMLElement;
let rail = document.querySelector(".jump-scroll") as HTMLElement;
bar.getBoundingClientRect = () => rect(0, 240, 56);
rail.getBoundingClientRect = () => rect(0, 240, 32);
await act(async () => {
// A real mouse activation emits both events. The rail must claim the
// gesture only once or the second navigation can overwrite the first.
rail.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, clientY: 0 }));
rail.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, clientY: 0, detail: 1 }));
// A real mouse activation emits both events. Only mousedown owns the jump.
rail.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY: 1 }));
rail.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, clientY: 1, detail: 1 }));
await flushTimers();
});
eq(jumps.length, 1, "one physical rail click emits one question jump");
eq(jumps[0]?.id, "u1", "the rail keeps the question selected on pointer down");
eq(jumps[0]?.id, "u1", "the rail maps its top directly to the first question");
const second = document.querySelector('[data-turn="1"]') as HTMLButtonElement;
await act(async () => {
second.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, detail: 0 }));
rail.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowDown" }));
await flushTimers();
});
eq(jumps.length, 2, "keyboard activation still emits one question jump");
eq(jumps[1]?.id, "u2", "keyboard activation jumps to the focused question");
eq(jumps.length, 2, "one slider keypress emits one question jump");
eq(jumps[1]?.id, "u2", "slider keyboard navigation advances by one exact question");
eq(rail.getAttribute("aria-valuenow"), "2", "the slider exposes its active absolute question");
const sampled = sampledQuestionTurns(10_000, 6_789);
eq(sampled.length, QUESTION_JUMP_MAX_MARKERS, "very long sessions keep a fixed marker count");
eq(sampled[0], 0, "aggregated markers retain the first question");
eq(sampled.at(-1), 9_999, "aggregated markers retain the last question");
eq(sampled.includes(6_789), true, "aggregated markers retain the exact active question");
eq(questionTurnFromRailY(120, 0, 240, 10_000), 5_000, "rail geometry maps to an absolute turn without reading marker layout");
const longQuestions: QuestionAnchor[] = [questions[0], { id: "u10000", text: "last loaded question", turn: 9_999, loaded: true }];
await act(async () => {
root.render(
<LocaleProvider>
<ControlledJumpBar key="long" loadedQuestions={longQuestions} totalQuestions={10_000} />
</LocaleProvider>,
);
await flushTimers();
});
bar = document.querySelector(".jump-bar") as HTMLElement;
rail = document.querySelector(".jump-scroll") as HTMLElement;
bar.getBoundingClientRect = () => rect(0, 240, 56);
rail.getBoundingClientRect = () => rect(0, 240, 32);
const markers = Array.from(document.querySelectorAll<HTMLElement>(".jump-item"));
eq(markers.length, QUESTION_JUMP_MAX_MARKERS, "10,000 questions render only the bounded marker set");
for (const marker of markers) {
marker.getBoundingClientRect = () => { throw new Error("marker geometry must not be read"); };
}
const unloadedTurn = 6_789;
await act(async () => {
const clientY = ((unloadedTurn + 0.5) / 10_000) * 240;
rail.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientY }));
rail.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY }));
await flushTimers();
});
eq(jumps.at(-1)?.turn, unloadedTurn, "a long aggregated rail still targets the exact unloaded question");
eq(jumps.at(-1)?.loaded, false, "an unloaded aggregate target keeps the lazy-load contract");
await act(async () => root.unmount());
dom.window.close();
@@ -0,0 +1,177 @@
// Run: tsx src/__tests__/raf-batch.test.ts
//
// rafBatch must flush coalesced deltas once per animation frame in the visible
// path and request a best-effort timer flush when rAF stops while the JS task
// queue still runs. The fake scheduler verifies the requested 200ms ordering;
// browser throttling or a blocked main thread may deliver either callback later.
import { createRafBatch } from "../lib/rafBatch";
let passed = 0;
let failed = 0;
function eq(a: unknown, b: unknown, label: string) {
if (a === b) {
process.stdout.write(` PASS ${label}
`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}
`);
failed += 1;
}
}
type RafCb = () => void;
// Fake scheduler: rAF callbacks fire only on frame(), timers only on
// advance(), so a stalled-rAF scenario is fully controllable.
class Scheduler {
rafQueue = new Map<number, RafCb>();
timers = new Map<number, { at: number; cb: RafCb }>();
now = 0;
nextId = 1;
install() {
const s = this;
(globalThis as Record<string, unknown>).requestAnimationFrame = (cb: RafCb) => {
const id = s.nextId++;
s.rafQueue.set(id, cb);
return id;
};
(globalThis as Record<string, unknown>).cancelAnimationFrame = (id: number) => {
s.rafQueue.delete(id);
};
(globalThis as Record<string, unknown>).setTimeout = (cb: RafCb, ms?: number) => {
const id = s.nextId++;
s.timers.set(id, { at: s.now + (ms ?? 0), cb });
return id;
};
(globalThis as Record<string, unknown>).clearTimeout = (id: number) => {
s.timers.delete(id);
};
}
uninstall() {
const g = globalThis as Record<string, unknown>;
delete g.requestAnimationFrame;
delete g.cancelAnimationFrame;
delete g.setTimeout;
delete g.clearTimeout;
}
uninstallRafOnly() {
const g = globalThis as Record<string, unknown>;
delete g.requestAnimationFrame;
delete g.cancelAnimationFrame;
}
frame() {
const pending = [...this.rafQueue.keys()];
for (const id of pending) {
const cb = this.rafQueue.get(id);
this.rafQueue.delete(id);
cb?.();
}
}
advance(ms: number) {
const target = this.now + ms;
for (;;) {
const due = [...this.timers.entries()]
.filter(([, t]) => t.at <= target)
.sort((a, b) => a[1].at - b[1].at);
if (due.length === 0) break;
const [id, t] = due[0];
this.timers.delete(id);
this.now = t.at;
t.cb();
}
this.now = target;
}
}
const sched = new Scheduler();
sched.install();
// --- one flush per animation frame in the visible path ---
{
const flushed: string[][] = [];
const batch = createRafBatch<string>((out) => flushed.push(out));
batch.push("a");
batch.push("b");
eq(flushed.length, 0, "deltas wait for a frame (rAF still scheduled)");
sched.frame();
eq(flushed.length, 1, "one frame produces exactly one flush");
eq(JSON.stringify(flushed[0]), JSON.stringify(["a", "b"]), "same-frame deltas coalesce into one batch");
batch.push("c");
sched.frame();
eq(flushed.length, 2, "next frame flushes the next batch");
eq(JSON.stringify(flushed[1]), JSON.stringify(["c"]), "subsequent push lands in its own batch");
// The stall timer must have been cancelled by the rAF flushes.
sched.advance(1000);
eq(flushed.length, 2, "advancing time does not double-flush after rAF flushes");
}
// --- rAF stalls: the stall timer flushes after STALL_TIMEOUT_MS ---
{
const flushed: string[][] = [];
const batch = createRafBatch<string>((out) => flushed.push(out));
batch.push("thinking");
batch.push("chunk");
eq(flushed.length, 0, "nothing flushes while rAF is stalled");
sched.advance(199);
eq(flushed.length, 0, "still nothing before the stall timeout");
sched.advance(1);
eq(flushed.length, 1, "stall timer flushes the accumulated deltas");
eq(JSON.stringify(flushed[0]), JSON.stringify(["thinking", "chunk"]), "stall flush delivers everything buffered");
sched.advance(1000);
eq(flushed.length, 1, "stall flush is a one-shot; no repeated flushes");
}
// --- drain() flushes immediately and cancels the pending stall timer ---
{
const flushed: string[][] = [];
const batch = createRafBatch<string>((out) => flushed.push(out));
batch.push("x");
batch.drain();
eq(flushed.length, 1, "drain flushes immediately");
eq(JSON.stringify(flushed[0]), JSON.stringify(["x"]), "drain delivers the buffered delta");
eq(batch.size(), 0, "buffer is empty after drain");
sched.advance(1000);
eq(flushed.length, 1, "drain cancels rAF and the stall timer");
}
// --- re-entrant push() during flush lands in the next batch ---
{
const flushed: string[][] = [];
let nextRef: { push: (v: string) => void } | undefined;
const batch = createRafBatch<string>((out) => {
flushed.push(out);
if (out[0] === "first" && nextRef) nextRef.push("reentrant");
});
nextRef = batch;
batch.push("first");
sched.frame();
eq(flushed.length, 1, "first flush ran");
sched.frame();
eq(flushed.length, 2, "re-entrant push was flushed on the next frame");
eq(JSON.stringify(flushed[1]), JSON.stringify(["reentrant"]), "re-entrant delta coalesces into its own batch");
}
// --- microtask fallback still works without rAF (SSR / JSDOM) ---
{
sched.uninstallRafOnly(); // keep fake timers; only rAF is absent
const flushed: string[][] = [];
const batch = createRafBatch<string>((out) => flushed.push(out));
batch.push("y");
await Promise.resolve();
await Promise.resolve();
eq(flushed.length, 1, "microtask fallback flushes without rAF");
sched.install();
}
process.stdout.write(`
${passed} passed, ${failed} failed
`);
if (failed > 0) process.exit(1);
@@ -32,6 +32,12 @@ eq(
eq(formatSearchFootnotesMarkdown([{ title: "bad", url: "javascript:alert(1)" }]), "\n- **bad**\n", "unsafe URLs are dropped");
eq(parseSearchSources("新闻本文\nhttps://example.com/a\nNo URL").length, 2, "output parser keeps title-only hits");
eq(parseSearchSources("新闻本文\nhttps://example.com/a")[0]?.title, "新闻本文", "output parser keeps the title");
eq(parseSearchSources("新闻本文\r\nhttps://example.com/a\r\n")[0]?.url, "https://example.com/a", "output parser tolerates CRLF line endings");
const degraded = parseSearchSources("- **新闻本文**\n <https://example.com/a>");
eq(degraded.length, 1, "degraded footnote-markdown dump still resolves to one source");
eq(degraded[0]?.title, "新闻本文", "degraded dump strips the bullet and bold markers from the title");
eq(degraded[0]?.url, "https://example.com/a", "degraded dump unwraps the autolink URL");
eq(parseSearchSources("<https://example.com/a>")[0]?.url, "https://example.com/a", "autolink-only lines parse as URL sources");
eq(mergeSearchSources([{ title: "A", url: "https://a.example" }], [{ title: "A", url: "https://a.example" }]).length, 1, "duplicate hits collapse");
const history = historyMessagesToItems([{
@@ -525,6 +525,14 @@ const botsRootEl = document.createElement("div");
document.body.appendChild(botsRootEl);
const botsRoot = createRoot(botsRootEl);
const botsSettings = baseSettings("standard");
botsSettings.bot.dingtalk = {
enabled: true,
clientId: "dinghuspf88znepnhwfp",
clientSecretEnv: "DINGTALK_CLIENT_SECRET",
secretSet: true,
botName: "",
requireMention: true,
};
botsSettings.bot.connections = [
{
id: "conn-feishu-1",
@@ -569,7 +577,7 @@ ok(!document.getElementById("bot-step-access"), "bots tab omits the old global a
ok(!document.getElementById("bot-step-behavior"), "bots tab omits global default behavior card");
eq(document.querySelectorAll(".bot-step-chip").length, 0, "hero no longer shows the old two-step chips");
eq(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]").length, 4, "bot manager uses four fixed channel tabs on the left");
eq(document.querySelectorAll(".bot-channel-tabs [role=\"tab\"]").length, 5, "bot manager uses five fixed channel tabs on the left");
ok(document.querySelector(".bot-channel-setup-card")?.textContent?.includes("Configure QQ") === true, "unconfigured QQ tab shows key setup on the right");
ok(document.body.textContent?.includes("Back to entry") === false, "bot manager does not show a return-to-entry action");
@@ -598,6 +606,109 @@ ok(document.body.textContent?.includes("ou_mock_user_001") === true, "selected b
ok(document.body.textContent?.includes("Legacy global allowlist") === true, "advanced area keeps the legacy global allowlist");
ok(document.querySelector(".bot-simple-advanced")?.textContent?.includes("local control API") === false, "advanced area no longer owns mobile/control API setup");
// DingTalk channel: a persisted ClientID must round-trip back into the UI.
// The unconfigured setup form and the configured detail card both show it;
// blur-save and reload must not blank the field.
const persistedDingtalkSettings = () => {
const s = baseSettings("standard");
s.bot.dingtalk = {
enabled: true,
clientId: "dinghuspf88znepnhwfp",
clientSecretEnv: "DINGTALK_CLIENT_SECRET",
secretSet: true,
botName: "",
requireMention: true,
};
return s;
};
let dingtalkSettings = persistedDingtalkSettings();
let dingtalkTestCalls = 0;
window.go = {
main: {
App: {
Settings: async () => dingtalkSettings,
SetBotSettings: async (next: typeof dingtalkSettings) => {
dingtalkSettings = next;
},
SetBotSecret: async () => {},
TestDingtalkBot: async () => {
dingtalkTestCalls += 1;
return { id: "dingtalk", label: "DingTalk", status: "ok", message: "测试消息已发送,请检查钉钉会话。", messageId: "mock-dingtalk-id", phase: "send", code: "dingtalk_test_send_ok", reportKind: "", reportDetail: "", occurredAt: new Date().toISOString() };
},
} as Partial<AppBindings> as AppBindings,
},
};
const dingtalkTab = Array.from(botsRootEl.querySelectorAll(".bot-channel-tabs [role=\"tab\"]")).find((button) => button.textContent?.includes("DingTalk")) as HTMLButtonElement | undefined;
if (!dingtalkTab) throw new Error("DingTalk channel tab did not render");
await act(async () => {
dingtalkTab.click();
await flushPromises();
});
// Secret is set and the bot is enabled: the configured detail card
// shows the persisted ClientID (the "input disappeared" regression).
await waitFor("DingTalk detail card with ClientID", () => {
const card = botsRootEl.querySelector(".bot-channel-manager__detail .bot-detail-card");
const hasClientId = card?.textContent?.includes("dinghuspf88znepnhwfp") === true;
return Boolean(card) && hasClientId;
});
const dingtalkDetailClientId = Array.from(botsRootEl.querySelectorAll("input[aria-label]")).find((input) => input.getAttribute("aria-label")?.includes("Client ID")) as HTMLInputElement | undefined;
eq(dingtalkDetailClientId?.value, "dinghuspf88znepnhwfp", "persisted ClientID is visible in the DingTalk detail card");
// DingTalk test-send entry: the detail card exposes a test-send button that
// calls TestDingtalkBot and surfaces the result notice.
const dingtalkTestButtons = Array.from(botsRootEl.querySelectorAll(".bot-channel-manager__detail .bot-detail-card__actions .btn")).filter((button) => /test|测试|測試|傳送/i.test(button.textContent ?? ""));
eq(dingtalkTestButtons.length, 1, "DingTalk detail card exposes a test-send button");
await act(async () => {
(dingtalkTestButtons[0] as HTMLButtonElement).click();
await flushPromises();
});
eq(dingtalkTestCalls, 1, "test-send button invokes TestDingtalkBot");
await waitFor("DingTalk test-send result notice", () =>
botsRootEl.querySelector(".bot-channel-manager__detail .bot-detail-notice")?.textContent?.includes("测试消息已发送") === true);
// Regression: a ClientID alone must NOT flip the channel into its configured
// detail card. Only an enabled bot with a set secret does. Mount with
// enabled=false + secretSet=true + clientId set; the setup panel (not the
// detail card) must show.
const notEnabledRootEl = document.createElement("div");
document.body.appendChild(notEnabledRootEl);
const notEnabledRoot = createRoot(notEnabledRootEl);
const notEnabledSettings = baseSettings("standard");
notEnabledSettings.bot.dingtalk = {
enabled: false,
clientId: "dinghuspf88znepnhwfp",
clientSecretEnv: "DINGTALK_CLIENT_SECRET",
secretSet: true,
botName: "",
requireMention: true,
};
window.go = {
main: { App: { Settings: async () => notEnabledSettings } } as Partial<AppBindings> as AppBindings,
};
await act(async () => {
notEnabledRoot.render(
<LocaleProvider>
<SettingsPanel initialTab="bots" desktopPlatform="linux" onClose={() => {}} onChanged={() => {}} />
</LocaleProvider>,
);
await flushPromises();
});
const notEnabledTab = Array.from(notEnabledRootEl.querySelectorAll(".bot-channel-tabs [role=\"tab\"]")).find((button) => button.textContent?.includes("DingTalk")) as HTMLButtonElement | undefined;
if (!notEnabledTab) throw new Error("DingTalk channel tab did not render (not-enabled case)");
await act(async () => {
notEnabledTab.click();
await flushPromises();
});
await waitFor("DingTalk setup panel instead of detail card when not enabled", () => {
const detailCard = notEnabledRootEl.querySelector(".bot-channel-manager__detail .bot-detail-card");
const setupCard = notEnabledRootEl.querySelector(".bot-channel-manager__detail .bot-channel-setup-card");
return Boolean(setupCard) && detailCard === null;
});
await act(async () => {
notEnabledRoot.unmount();
});
window.go = {
main: { App: { Settings: async () => botsSettings } } as Partial<AppBindings> as AppBindings,
};
await act(async () => {
botsRoot.unmount();
});
@@ -5,6 +5,7 @@ import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { AppBindings } from "../lib/bridge";
import { useController } from "../lib/useController";
import { verifyDeferredHistoryCloseRace, verifyStaleHistoryFingerprint } from "./deferred-history-close-race";
import { historySliceFromMessages } from "./mockHistorySlice";
import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySlice, HistorySliceRequest, JobView, Meta, TabMeta, TopicActivationEvent, TopicActivationRequest, WireEvent } from "../lib/types";
@@ -381,6 +382,11 @@ window.go = {
cancelCalls.push(tabID);
runningTabs.delete(tabID);
},
CloseTabWithPolicy: async (tabID: string) => {
tabsById.delete(tabID);
runningTabs.delete(tabID);
if (backendActiveId === tabID) backendActiveId = "tab-a";
},
SubmitToTab: async (tabID: string) => {
runningTabs.add(tabID);
if (tabID === "tab-c") await submitTabCGate.promise;
@@ -904,24 +910,18 @@ await act(async () => {
});
await waitFor("tab-l metadata advances", () => controller?.state.meta?.sessionRevision === 2);
historyLOlder = deferred<HistorySlice>();
let olderLoad: Promise<void> | undefined;
await act(async () => {
olderLoad = controller?.loadOlderHistory("tab-l");
await flushPromises();
await verifyStaleHistoryFingerprint({
olderPage: historyLOlder, loadOlderHistory: () => controller?.loadOlderHistory("tab-l"),
historyCalls: () => historyLCalls, waitFor, flushPromises, equal: eq,
getState: () => controller?.state,
});
await waitFor("tab-l older page request", () => historyLCalls === 2);
historyLOlder.resolve(historySliceFromMessages(
"tab-l",
[userMessage("stale older L")],
{ cursor: "", turns: 12 },
{ revision: 1, digest: "digest-l-v1" },
));
await act(async () => {
await olderLoad;
await flushPromises();
historyLOlder = deferred<HistorySlice>();
await verifyDeferredHistoryCloseRace({
olderPage: historyLOlder, loadOlderHistory: () => controller?.loadOlderHistory("tab-l"),
closeTab: async () => Boolean(await controller?.closeTab("tab-l", "stop_and_close")),
historyCalls: () => historyLCalls, waitFor, flushPromises, equal: eq, sessionPath: tabL.sessionPath,
});
ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "stale older L") ?? false), "stale older page is discarded after session fingerprint changes");
eq(controller?.state.historyOlderLoading, false, "stale older page releases its loading state");
// The backend transcript and sidecar are separate durable files. If a save
// advances between those reads, hydration must reconcile the pair instead of
@@ -7,13 +7,17 @@ import { fileURLToPath } from "node:url";
const testDir = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
const moreMenuSource = [
readFileSync(resolve(testDir, "../components/TopicbarMoreMenu.tsx"), "utf8"),
readFileSync(resolve(testDir, "../components/TopicbarMoreMenuContent.tsx"), "utf8"),
].join("\n");
assert.doesNotMatch(appSource, /t\("shortcuts\.cheatsheetTitle"\)|t\("topicBar\.command"\)/);
const taskSummaryControlIndex = appSource.indexOf('<Tooltip label={t("summary.session")}>');
const workspaceToggleIndex = appSource.indexOf('<Tooltip label={workspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>');
const taskSummaryControlIndex = moreMenuSource.indexOf('t("summary.session")');
const workspaceToggleIndex = appSource.indexOf('<Tooltip label={effectiveWorkspacePanelRenderable ? t("rightDock.collapse") : t("rightDock.expand")}>');
assert.ok(taskSummaryControlIndex >= 0, "topic bar renders the localized Session summary control");
assert.ok(workspaceToggleIndex > taskSummaryControlIndex, "Session summary precedes the right-edge workspace toggle");
assert.ok(!appSource.includes('aria-label="Session summary"'), "Session summary does not use a hard-coded English label");
assert.ok(workspaceToggleIndex >= 0, "topic bar keeps the right-edge workspace toggle");
assert.ok(!moreMenuSource.includes('aria-label="Session summary"'), "Session summary does not use a hard-coded English label");
process.stdout.write("topicbar controls: 2 contracts passed\n");
@@ -0,0 +1,170 @@
// Run: tsx src/__tests__/topicbar-more-menu.test.tsx
import { JSDOM } from "jsdom";
import { registerHooks } from "node:module";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { TopicbarMoreMenu } from "../components/TopicbarMoreMenu";
import { LocaleProvider } from "../lib/i18n";
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.endsWith(".css")) {
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
}
return nextResolve(specifier, context);
},
});
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function flushTimers(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 30; attempt += 1) {
await act(async () => {
await flushTimers();
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
function press(target: Element, key: string) {
target.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }));
}
function directMenuItems(menu: HTMLElement): HTMLButtonElement[] {
return Array.from(menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'))
.filter((item) => item.closest('[role="menu"]') === menu);
}
console.log("\ntopicbar more menu keyboard navigation");
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("missing root");
const root = createRoot(rootElement);
await act(async () => {
root.render(
<LocaleProvider>
<TopicbarMoreMenu
sessionHasContent
getSessionMarkdown={() => "# Session"}
exportSession={() => {}}
openChangedDock={() => {}}
toggleTerminal={() => {}}
openSessionSummary={() => {}}
tasksOpen={false}
/>
</LocaleProvider>,
);
await flushTimers();
});
const trigger = rootElement.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]');
if (!trigger) throw new Error("missing more-menu trigger");
await act(async () => {
trigger.click();
await flushTimers();
});
await waitFor("lazy menu content", () => rootElement.querySelector('[role="menu"]') !== null);
let menu = rootElement.querySelector<HTMLElement>('.topicbar__more-menu[role="menu"]');
if (!menu) throw new Error("missing more menu");
let items = directMenuItems(menu);
ok(document.activeElement === items[0], "opening the menu moves focus to the first action");
await act(async () => {
press(items[0]!, "ArrowDown");
});
ok(document.activeElement === items[1], "ArrowDown advances focus to the next action");
await act(async () => {
press(items[1]!, "End");
});
ok(document.activeElement === items[items.length - 1], "End moves focus to the final action");
await act(async () => {
press(items[items.length - 1]!, "Home");
});
ok(document.activeElement === items[0], "Home returns focus to the first action");
await act(async () => {
items[1]?.focus();
press(items[1]!, "ArrowRight");
await flushTimers();
});
const exportMenu = rootElement.querySelector<HTMLElement>('.topicbar__more-export-menu[role="menu"]');
if (!exportMenu) throw new Error("missing export submenu");
const exportItems = directMenuItems(exportMenu);
ok(document.activeElement === exportItems[0], "ArrowRight opens the export submenu and focuses its first action");
await act(async () => {
press(exportItems[0]!, "ArrowDown");
});
ok(document.activeElement === exportItems[1], "submenu ArrowDown stays within the submenu");
await act(async () => {
press(exportItems[1]!, "ArrowLeft");
await flushTimers();
});
ok(rootElement.querySelector(".topicbar__more-export-menu") === null, "ArrowLeft closes the export submenu");
ok(document.activeElement === items[1], "closing the export submenu restores focus to its trigger");
await act(async () => {
press(items[1]!, "Escape");
await flushTimers();
});
ok(rootElement.querySelector(".topicbar__more-menu") === null, "Escape closes the main menu");
ok(document.activeElement === trigger, "closing the main menu restores focus to the More trigger");
await act(async () => {
press(trigger, "ArrowUp");
await flushTimers();
});
await waitFor("keyboard-opened menu", () => rootElement.querySelector(".topicbar__more-menu") !== null);
menu = rootElement.querySelector<HTMLElement>('.topicbar__more-menu[role="menu"]');
if (!menu) throw new Error("missing keyboard-opened menu");
items = directMenuItems(menu);
ok(document.activeElement === items[items.length - 1], "ArrowUp on the trigger opens the menu at its final action");
await act(async () => {
root.unmount();
await flushTimers();
});
console.log(`\ntopicbar more menu: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,287 @@
// Run: tsx src/__tests__/transcript-anchor-compensation-race.test.tsx
//
// W1 scroll-policy races, split out of transcript-recovery-race.test.tsx
// (800-line test-file ceiling): the bottom-hold tail-follow re-entry policy
// (#8709/#9099) and the steady-state manual-mode anchor compensation
// (#8438/#8488/#8897). Same JSDOM + fake rAF/clock harness with a stubbed
// VirtuosoHandle as the recovery race file.
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { VirtuosoHandle } from "react-virtuoso";
import type { TranscriptScrollWriteRecord } from "../lib/transcriptScrollProbe";
import { useTranscriptScrollArbiter } from "../lib/useTranscriptScrollArbiter";
let passed = 0;
let failed = 0;
function check(condition: unknown, label: string) {
if (condition) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
console.log("\ntranscript bottom-hold and anchor compensation races");
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div><div id="scroll"><div class="transcript__row" data-row-key="row-a"></div></div></body></html>', {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Element = dom.window.Element;
globalThis.Node = dom.window.Node;
let nextFrame = 1;
const frames = new Map<number, FrameRequestCallback>();
const requestFrame = (callback: FrameRequestCallback) => {
const id = nextFrame;
nextFrame += 1;
frames.set(id, callback);
return id;
};
const cancelFrame = (id: number) => void frames.delete(id);
globalThis.requestAnimationFrame = requestFrame;
globalThis.cancelAnimationFrame = cancelFrame;
dom.window.requestAnimationFrame = requestFrame;
dom.window.cancelAnimationFrame = cancelFrame;
let clockNow = 10_000;
let nextTimer = 1;
const timers = new Map<number, { dueAt: number; run: () => void }>();
const originalDateNow = Date.now;
const originalSetTimeout = dom.window.setTimeout;
const originalClearTimeout = dom.window.clearTimeout;
Date.now = () => clockNow;
dom.window.setTimeout = ((handler: TimerHandler, timeout = 0, ...args: unknown[]) => {
const id = nextTimer;
nextTimer += 1;
const run = typeof handler === "function"
? () => handler(...args)
: () => { throw new Error("string timer handlers are unsupported in this test"); };
timers.set(id, { dueAt: clockNow + Math.max(0, timeout), run });
return id;
}) as typeof dom.window.setTimeout;
dom.window.clearTimeout = ((id: number | undefined) => {
if (id !== undefined) timers.delete(id);
}) as typeof dom.window.clearTimeout;
async function advanceClock(milliseconds: number) {
await act(async () => {
const target = clockNow + milliseconds;
while (true) {
const next = [...timers.entries()]
.filter(([, timer]) => timer.dueAt <= target)
.sort(([leftID, left], [rightID, right]) => left.dueAt - right.dueAt || leftID - rightID)[0];
if (!next) break;
const [id, timer] = next;
timers.delete(id);
clockNow = timer.dueAt;
timer.run();
}
clockNow = target;
});
}
async function flushFrames() {
const pending = [...frames.entries()];
frames.clear();
await act(async () => pending.forEach(([, callback]) => callback(performance.now())));
}
// Runtime capture of every imperative scroll write (Phase 0 probe).
const scrollWrites: TranscriptScrollWriteRecord[] = [];
dom.window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => { scrollWrites.push(write); };
const rectAt = (top: number) => ({ top, bottom: top + 100, height: 100, left: 0, right: 800, width: 800, x: 0, y: top, toJSON: () => ({}) });
const scrollElement = dom.window.document.getElementById("scroll") as HTMLDivElement;
const rowElement = scrollElement.querySelector<HTMLElement>(".transcript__row")!;
scrollElement.getBoundingClientRect = () => rectAt(0);
rowElement.getBoundingClientRect = () => rectAt(200);
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 100 });
let scrollExtent = 500;
Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, get: () => scrollExtent });
Object.defineProperty(scrollElement, "scrollTop", { configurable: true, writable: true, value: 0 });
Object.defineProperty(scrollElement, "offsetWidth", { configurable: true, value: 800 });
Object.defineProperty(scrollElement, "clientWidth", { configurable: true, value: 780 });
Object.defineProperty(scrollElement, "clientLeft", { configurable: true, value: 0 });
const virtuosoHandle = {
scrollBy: () => {},
scrollToIndex: () => {},
// Browser semantics: an offset write clamps against the current extent.
scrollTo: (options?: { top?: number }) => {
const top = options?.top ?? 0;
scrollElement.scrollTop = Math.max(0, Math.min(scrollExtent - scrollElement.clientHeight, top));
},
getState: () => {},
} as unknown as VirtuosoHandle;
let arbiter: ReturnType<typeof useTranscriptScrollArbiter> | undefined;
function Probe() {
arbiter = useTranscriptScrollArbiter();
return null;
}
const root = createRoot(dom.window.document.getElementById("root")!);
await act(async () => root.render(<Probe />));
await act(async () => {
(arbiter!.virtuosoRef as { current: VirtuosoHandle | null }).current = virtuosoHandle;
arbiter!.scrollerRef(scrollElement);
});
const wheel = async (deltaY: number) => act(async () => {
arbiter?.onWheelIntent({
ctrlKey: false,
deltaX: 0,
deltaY,
target: scrollElement,
} as React.WheelEvent<HTMLElement>);
});
const wheelDown = () => wheel(40);
// ── Bottom-hold re-entry (#8709/#9099): auto re-entry into tail-follow
// requires the bottom to be HELD — two consecutive at-bottom deliveries inside
// one reader-intent window with no upward gesture in between. A single
// touch-down claims the gesture but stays manual.
scrollElement.scrollTop = 400;
await act(async () => arbiter?.releaseTailFollow());
await wheelDown();
check(arbiter?.modeRef.current === "manual", "a single downward touch-down stays manual");
await wheelDown();
check(arbiter?.modeRef.current === "tail-follow", "a held bottom (second delivery) re-enters tail-follow");
// An upward gesture between at-bottom deliveries breaks the hold streak; the
// next downward gesture restarts it from zero.
await act(async () => arbiter?.releaseTailFollow());
await wheelDown();
check(arbiter?.modeRef.current === "manual", "one at-bottom delivery starts the hold without re-entering");
await wheel(-40);
scrollElement.scrollTop = 300;
await act(async () => arbiter?.deliverScroll());
scrollElement.scrollTop = 400;
await wheelDown();
check(arbiter?.modeRef.current === "manual", "an upward gesture between at-bottom deliveries breaks the hold");
await wheelDown();
check(arbiter?.modeRef.current === "tail-follow", "two consecutive held deliveries after the reset re-enter tail-follow");
// The 180ms idle close performs one final native delivery so a large wheel or
// touch gesture that clamps at the physical bottom can complete the hold even
// when the browser emits no second scroll event. The completed transition
// still resets the streak before a fresh reader-intent window begins.
await act(async () => arbiter?.releaseTailFollow());
await wheelDown();
await advanceClock(200);
check(arbiter?.modeRef.current === "tail-follow", "idle close re-samples a held physical bottom before ending intent");
await act(async () => arbiter?.releaseTailFollow());
await wheelDown();
check(arbiter?.modeRef.current === "manual", "a fresh intent window rebuilds the bottom hold from zero");
await wheelDown();
check(arbiter?.modeRef.current === "tail-follow", "the fresh window re-enters tail-follow after its second delivery");
// A thumb gesture that reaches the frozen native bottom claims the tail when
// the drag's own deliveries already held the bottom before release resumes
// real row measurements and changes the extent.
await act(async () => arbiter?.reset());
scrollElement.scrollTop = 0;
await act(async () => arbiter?.onPointerDownIntent({
button: 0,
nativeEvent: { button: 0, clientX: 795 },
} as React.PointerEvent<HTMLElement>));
scrollElement.scrollTop = 400;
await act(async () => arbiter?.deliverScroll());
await act(async () => window.dispatchEvent(new dom.window.Event("pointerup")));
check(arbiter?.modeRef.current === "tail-follow", "native thumb release after a held physical bottom resumes tail-follow");
scrollExtent = 900;
await act(async () => arbiter?.deliverScroll());
await flushFrames();
await flushFrames();
await flushFrames();
check(scrollElement.scrollTop === 800, "post-release remeasurement reconverges the claimed native bottom");
scrollExtent = 500;
// ── Manual-mode viewport anchor compensation (#8438/#8488/#8897): an
// above-viewport height change (fold auto-collapse, history patch) must not
// push the reading position. The drift is measured against the anchor sampled
// on the last delivered scroll and corrected through exactly one arbiter-owned
// offset write; growth below the viewport and ownership changes earn none.
await act(async () => arbiter?.reset());
scrollElement.appendChild(rowElement);
// Real-geometry stub: the row's client position tracks scrollTop like a
// browser's would (document top 150).
rowElement.getBoundingClientRect = () => rectAt(150 - scrollElement.scrollTop);
scrollElement.scrollTop = 100;
await act(async () => arbiter?.setMode("manual", "test-anchor-compensation"));
await act(async () => arbiter?.deliverScroll());
scrollWrites.length = 0;
// A fold above the viewport expands: extent and the row both move +200.
scrollExtent = 700;
rowElement.getBoundingClientRect = () => rectAt(350 - scrollElement.scrollTop);
await act(async () => arbiter?.followGrowingTail());
await flushFrames(); // followGrowingTail frame: LAYOUT_HEIGHT_CHANGED + compensation scheduled
await flushFrames(); // compensation measures drift and writes once
await flushFrames(); // stable frame 1
await flushFrames(); // stable frame 2: done
const compensationWrites = scrollWrites.filter((write) => write.owner === "anchor-compensation");
check(compensationWrites.length === 1, `above-viewport growth in manual mode emits exactly one anchor-compensation write (${compensationWrites.length})`);
check(compensationWrites[0]?.top === 300 && scrollElement.scrollTop === 300, "the compensation restores the anchor row's viewport offset");
check(arbiter?.modeRef.current === "manual", "anchor compensation preserves manual reading ownership");
check(rowElement.getBoundingClientRect().top === 50, "the anchor row is physically back at its pre-change offset");
// Growth below the viewport (streaming tail) leaves the anchor row put:
// zero measured drift, zero writes.
await act(async () => arbiter?.deliverScroll());
scrollWrites.length = 0;
scrollExtent = 900;
await act(async () => arbiter?.followGrowingTail());
for (let i = 0; i < 4; i += 1) await flushFrames();
check(
scrollWrites.filter((write) => write.owner === "anchor-compensation").length === 0,
"below-viewport growth earns no compensation write",
);
check(scrollElement.scrollTop === 300, "below-viewport growth leaves the reading position untouched");
// A collapse above the viewport (CONTENT_SHRINK path) compensates upward.
scrollWrites.length = 0;
scrollExtent = 600;
rowElement.getBoundingClientRect = () => rectAt(150 - scrollElement.scrollTop);
await act(async () => arbiter?.followGrowingTail());
for (let i = 0; i < 4; i += 1) await flushFrames();
const shrinkWrites = scrollWrites.filter((write) => write.owner === "anchor-compensation");
check(shrinkWrites.length === 1 && shrinkWrites[0]?.top === 100, "an above-viewport collapse compensates upward exactly once");
check(scrollElement.scrollTop === 100, "the upward compensation restores the anchor offset");
// A user gesture mid-compensation cancels the loop: the reader owns the
// viewport from there on.
await act(async () => arbiter?.deliverScroll());
scrollWrites.length = 0;
scrollExtent = 800;
rowElement.getBoundingClientRect = () => rectAt(350 - scrollElement.scrollTop);
await act(async () => arbiter?.followGrowingTail());
await flushFrames(); // schedules the compensation
await act(async () => arbiter?.releaseTailFollow());
for (let i = 0; i < 4; i += 1) await flushFrames();
check(
scrollWrites.filter((write) => write.owner === "anchor-compensation").length === 0,
"user scroll intent cancels a pending anchor compensation",
);
await act(async () => root.unmount());
Date.now = originalDateNow;
dom.window.setTimeout = originalSetTimeout;
dom.window.clearTimeout = originalClearTimeout;
dom.window.close();
if (failed > 0) {
console.error(`\n${failed} transcript anchor compensation race test(s) failed; ${passed} passed.`);
process.exit(1);
}
console.log(`\n${passed} transcript anchor compensation race tests passed.`);
@@ -12,8 +12,17 @@ import {
estimateTranscriptTextHeight,
TRANSCRIPT_ESTIMATED_LINE_COLUMNS,
} from "../lib/transcriptRowEstimates";
import { estimateTranscriptRowSize, type TranscriptRow } from "../lib/transcriptRows";
import {
buildTranscriptRows,
buildTurnModels,
EMPTY_FOLDS,
estimateTranscriptRowSize,
transcriptRowMeasurementVersion,
type FoldMap,
type TranscriptRow,
} from "../lib/transcriptRows";
import { createTranscriptMeasuredSizes } from "../lib/transcriptMeasuredSizes";
import { initialState, reducer, type Item } from "../lib/useController";
let passed = 0;
let failed = 0;
@@ -96,15 +105,111 @@ function fakeRow(key: string, kind: TranscriptRow["kind"], text: string): Transc
return { key, kind, item: { id: key, text } } as unknown as TranscriptRow;
}
function estimateFor(store: ReturnType<typeof createTranscriptMeasuredSizes>, row: TranscriptRow, width?: number) {
return store.synthesize([row], width)[0];
}
{
const store = createTranscriptMeasuredSizes();
const row = fakeRow("r1", "answer", "hello");
ok(store.estimateFor(row) === estimateTranscriptRowSize(row), "no data falls back to the static prior");
store.record("r1", "answer", 432);
eq(store.estimateFor(row), 432, "exact rowKey measurement wins");
ok(estimateFor(store, row) === estimateTranscriptRowSize(row), "no data falls back to the static prior");
store.record("r1", "answer", 432, undefined, transcriptRowMeasurementVersion(row));
eq(estimateFor(store, row), 432, "exact rowKey measurement wins");
store.record("r1", "answer", 0);
store.record("r1", "answer", Number.NaN);
eq(store.estimateFor(row), 432, "non-positive and NaN measurements are ignored");
eq(estimateFor(store, row), 432, "non-positive and NaN measurements are ignored");
}
{
const store = createTranscriptMeasuredSizes();
const before = fakeRow("late", "answer", "preview");
const after = fakeRow("late", "answer", "resolved content");
store.record("late", "answer", 777, 960, transcriptRowMeasurementVersion(before));
eq(estimateFor(store, before, 960), 777, "an exact sample matches its row content version");
ok(estimateFor(store, after, 960) !== 777, "the same row key and width reject an old content version");
}
{
const store = createTranscriptMeasuredSizes();
const changedBefore = fakeRow("changed", "answer", "preview");
const changedAfter = fakeRow("changed", "answer", "resolved");
const untouched = fakeRow("untouched", "answer", "stable");
store.record("changed", "answer", 900, 960, transcriptRowMeasurementVersion(changedBefore));
store.record("untouched", "answer", 240, 960, transcriptRowMeasurementVersion(untouched));
const estimates = store.synthesize([changedAfter, untouched], 960);
ok(estimates[0] !== 900, "patching one row invalidates its own exact sample");
eq(estimates[1], 240, "patching one row preserves another row's exact sample");
}
{
const store = createTranscriptMeasuredSizes();
const staleBefore = fakeRow("stale", "answer", "preview");
const stale = fakeRow("stale", "answer", "resolved");
const unseen = fakeRow("unseen", "answer", "fresh");
store.record("stale", "answer", 999, 960, transcriptRowMeasurementVersion(staleBefore));
const estimates = store.synthesize([stale, unseen], 960);
ok(estimates[1] !== 999, "a stale-version sample is excluded from the kind median");
}
{
const user: Item = { kind: "user", id: "user-1", text: "question" };
const firstTool: Item = { kind: "tool", id: "tool-1", name: "read_file", args: "{}", readOnly: true, status: "done" };
const secondTool: Item = { kind: "tool", id: "tool-2", name: "grep", args: "{}", readOnly: true, status: "done" };
const answer: Item = { kind: "assistant", id: "answer-1", text: "done", reasoning: "", streaming: false };
const models = buildTurnModels([user, firstTool, secondTool, answer]);
const folds: FoldMap = new Map([[
"tool-1",
{ open: true, userOverridden: true, running: false },
]]);
const options = {
folds,
foldPreference: "auto" as const,
hasOlderHistory: false,
turnForUser: () => 0,
};
const batch = buildTranscriptRows(models, { ...options, creationMode: false }).find((row) => row.kind === "tool-batch");
const group = buildTranscriptRows(models, { ...options, creationMode: true }).find((row) => row.kind === "tool-group");
const batchVersion = batch && transcriptRowMeasurementVersion(batch);
const groupVersion = group && transcriptRowMeasurementVersion(group);
ok(batchVersion === groupVersion, "batch and group rows combine the same member versions");
const patchedTool = { ...secondTool, output: "late member content" };
const patchedModels = buildTurnModels([user, firstTool, patchedTool, answer]);
const patchedBatch = buildTranscriptRows(patchedModels, { ...options, creationMode: false }).find((row) => row.kind === "tool-batch");
const patchedGroup = buildTranscriptRows(patchedModels, { ...options, creationMode: true }).find((row) => row.kind === "tool-group");
ok(patchedBatch && transcriptRowMeasurementVersion(patchedBatch) !== batchVersion, "a member patch advances its read-only batch version");
ok(patchedGroup && transcriptRowMeasurementVersion(patchedGroup) !== groupVersion, "a member patch advances its creation group version");
}
{
const preview: Item = { kind: "assistant", id: "he:late-answer", text: "preview", reasoning: "", streaming: false };
const before = { ...initialState, items: [preview] };
const beforeRows = buildTranscriptRows(buildTurnModels(before.items), {
folds: EMPTY_FOLDS,
foldPreference: "auto",
hasOlderHistory: false,
creationMode: false,
turnForUser: () => undefined,
});
const beforeRow = beforeRows.find((row) => row.kind === "answer")!;
const store = createTranscriptMeasuredSizes();
const beforeVersion = transcriptRowMeasurementVersion(beforeRow);
store.record(String(beforeRow.key), beforeRow.kind, 88, 960, beforeVersion);
const after = reducer(before, {
type: "history_items_patch",
patches: { [preview.id]: { ...preview, text: "resolved ".repeat(2_000) } },
});
const afterRows = buildTranscriptRows(buildTurnModels(after.items), {
folds: EMPTY_FOLDS,
foldPreference: "auto",
hasOlderHistory: false,
creationMode: false,
turnForUser: () => undefined,
});
const afterRow = afterRows.find((row) => row.kind === "answer")!;
const afterVersion = transcriptRowMeasurementVersion(afterRow);
eq(String(afterRow.key), String(beforeRow.key), "late content preserves the stable React row key");
ok(afterVersion !== beforeVersion, "late content advances only the row measurement version");
ok(estimateFor(store, afterRow, 960) !== 88, "an offscreen late patch cannot reuse the preview height after remount");
}
{
@@ -113,15 +218,39 @@ function fakeRow(key: string, kind: TranscriptRow["kind"], text: string): Transc
store.record("a2", "answer", 300);
store.record("a3", "answer", 200);
const unseen = fakeRow("a4", "answer", "some answer text that is long enough to matter");
eq(store.estimateFor(unseen), 200, "unseen row of a sampled kind uses the kind median");
eq(estimateFor(store, unseen), 200, "unseen row of a sampled kind uses the kind median");
const toolRow = fakeRow("t1", "tool", "");
eq(store.estimateFor(toolRow), estimateTranscriptRowSize(toolRow), "unsampled kind still uses the static prior");
eq(estimateFor(store, toolRow), estimateTranscriptRowSize(toolRow), "unsampled kind still uses the static prior");
}
{
const store = createTranscriptMeasuredSizes();
const first = fakeRow("a1", "answer", "first answer");
const second = fakeRow("a2", "answer", "second answer");
const unseen = fakeRow("a3", "answer", "unseen answer");
store.record("a1", "answer", 291, 960, transcriptRowMeasurementVersion(first));
store.record("a1", "answer", 632, 960, transcriptRowMeasurementVersion(first));
store.record("a2", "answer", 400, 960, transcriptRowMeasurementVersion(second));
eq(estimateFor(store, first, 960), 632, "a later real measurement replaces the stale estimate for the same row");
eq(estimateFor(store, second, 960), 400, "a second row keeps its own latest measurement");
eq(estimateFor(store, unseen, 960), 516, "kind fallback uses one latest sample per row instead of duplicate observations");
}
{
const store = createTranscriptMeasuredSizes();
const row = fakeRow("width-sensitive", "answer", "wrapped answer");
store.record("width-sensitive", "answer", 632, 960, transcriptRowMeasurementVersion(row));
eq(estimateFor(store, row, 960), 632, "an exact measurement is reused at the measured width");
ok(
estimateFor(store, row, 760) === estimateTranscriptRowSize(row),
"a measurement from another content width is not reused after responsive reflow",
);
}
{
const store = createTranscriptMeasuredSizes();
const rows = [fakeRow("r1", "user", "hi"), fakeRow("r2", "answer", "hello there")];
store.record("r1", "user", 64);
store.record("r1", "user", 64, undefined, transcriptRowMeasurementVersion(rows[0]));
const estimates = store.synthesize(rows);
eq(estimates.length, 2, "synthesize stays aligned with the row array");
eq(estimates[0], 64, "synthesize uses the exact measurement where present");
@@ -133,7 +262,7 @@ function fakeRow(key: string, kind: TranscriptRow["kind"], text: string): Transc
store.record("r1", "answer", 500);
store.clear();
const row = fakeRow("r1", "answer", "hello");
ok(store.estimateFor(row) === estimateTranscriptRowSize(row), "clear() drops measurements and medians");
ok(estimateFor(store, row) === estimateTranscriptRowSize(row), "clear() drops measurements and medians");
}
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
@@ -1,11 +1,12 @@
// Run: pnpm exec tsx src/__tests__/transcript-native-scrollbar.test.ts
import { equal } from "node:assert/strict";
import { deepEqual, equal } from "node:assert/strict";
import { JSDOM } from "jsdom";
import {
isNativeVerticalScrollbarPointer,
measureTranscriptVirtuosoItem,
} from "../lib/transcriptNativeScrollbar";
import { noteTranscriptRowMeasurement, setTranscriptScrollDiagnosticSink } from "../lib/transcriptScrollProbe";
let passed = 0;
function check(actual: unknown, expected: unknown, label: string) {
@@ -14,7 +15,7 @@ function check(actual: unknown, expected: unknown, label: string) {
passed += 1;
}
const dom = new JSDOM('<div class="transcript"><div id="row" data-known-size="160"></div></div>');
const dom = new JSDOM('<div class="transcript"><div id="row" data-index="44" data-item-index="1000044" data-logical-index="44" data-row-kind="answer" data-estimated-size="1800" data-known-size="160" data-content-revision="3"><button aria-expanded="false"></button></div></div>');
const transcript = dom.window.document.querySelector<HTMLElement>(".transcript")!;
const row = dom.window.document.querySelector<HTMLElement>("#row")!;
Object.defineProperties(transcript, {
@@ -59,4 +60,45 @@ check(measureTranscriptVirtuosoItem(row, "offsetHeight", false), 640, "ordinary
check(measureTranscriptVirtuosoItem(row, "offsetHeight", true), 160, "native thumb drag keeps the existing Virtuoso size");
check(measureTranscriptVirtuosoItem(row, "offsetHeight", false), 640, "real measurement resumes after thumb release");
const measurementEvents: Array<{ type: string; fields: Record<string, unknown> }> = [];
setTranscriptScrollDiagnosticSink((type, fields) => measurementEvents.push({ type, fields }));
noteTranscriptRowMeasurement(row, "offsetHeight", 640);
deepEqual(measurementEvents, [{
type: "row-measure",
fields: {
rowIndex: 44,
rowKind: "answer",
estimatedSize: 1800,
previousSize: 160,
measuredSize: 640,
sizeDelta: 480,
contentRevision: 3,
foldState: "closed",
disclosureCount: 1,
},
}], "row measurement records only geometry and fixed classifications");
passed += 1;
delete row.dataset.knownSize;
noteTranscriptRowMeasurement(row, "offsetHeight", 420);
deepEqual(measurementEvents[measurementEvents.length - 1], {
type: "row-measure",
fields: {
rowIndex: 44,
rowKind: "answer",
estimatedSize: 1800,
previousSize: undefined,
measuredSize: 420,
sizeDelta: -1380,
contentRevision: 3,
foldState: "closed",
disclosureCount: 1,
},
}, "first real measurement records its estimate delta with the logical row index");
passed += 1;
row.dataset.knownSize = "160";
noteTranscriptRowMeasurement(row, "offsetHeight", 160);
check(measurementEvents.length, 2, "unchanged row size emits no diagnostic event");
noteTranscriptRowMeasurement(row, "offsetWidth", 800);
check(measurementEvents.length, 2, "horizontal measurements emit no row-height diagnostic event");
process.stdout.write(`\n${passed} passed\n`);
@@ -7,6 +7,7 @@
// actually mounted in the viewport, not just that a jump was dispatched.
import { createTranscriptHarness } from "./transcript-dom-harness";
import { QUESTION_JUMP_MAX_MARKERS } from "../components/QuestionJumpBar";
import type { Item } from "../lib/useController";
import { act } from "react";
@@ -32,6 +33,14 @@ function turns(count: number, prefix = ""): Item[] {
return items;
}
function historyTurns(start: number, end: number): Item[] {
const items: Item[] = [];
for (let turn = start; turn <= end; turn += 1) {
items.push({ kind: "user", id: `history-u${turn}`, text: `history question ${turn}`, historyTurn: turn });
}
return items;
}
function dispatchScroll(el: HTMLElement) {
el.dispatchEvent(new Event("scroll"));
}
@@ -41,14 +50,16 @@ function stubRailGeometry(container: HTMLElement, count: number) {
const jumpScroll = container.querySelector<HTMLElement>(".jump-scroll");
if (!jumpBar || !jumpScroll) throw new Error("question jump bar is not mounted");
jumpBar.getBoundingClientRect = () => ({ top: 0, bottom: 240, left: 0, right: 56, height: 240, width: 56 } as DOMRect);
const items = jumpScroll.querySelectorAll<HTMLButtonElement>(".jump-item");
items.forEach((item, index) => {
item.getBoundingClientRect = () => ({ top: index * 20, bottom: index * 20 + 14, left: 0, right: 32, height: 14, width: 32 } as DOMRect);
});
jumpScroll.getBoundingClientRect = () => ({ top: 0, bottom: 240, left: 0, right: 32, height: 240, width: 32 } as DOMRect);
const items = jumpScroll.querySelectorAll<HTMLElement>(".jump-item");
if (items.length !== count) throw new Error(`expected ${count} jump markers, found ${items.length}`);
return jumpScroll;
}
function railClientY(turn: number, total: number): number {
return ((turn + 0.5) / total) * 240;
}
function visibleRows(container: HTMLElement): string[] {
const el = container.querySelector<HTMLElement>(".transcript");
if (!el) return [];
@@ -99,7 +110,7 @@ console.log("\ntranscript question jump landing");
const targetIndices = [0, 31, 3, 36, 12, 28, 39];
for (const targetIndex of targetIndices) {
const clientY = targetIndex * 20 + 7;
const clientY = railClientY(targetIndex, 40);
await act(async () => {
jumpScroll.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY }));
jumpScroll.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, clientY, detail: 1 }));
@@ -148,8 +159,8 @@ console.log("\ntranscript question jump landing");
const targetIndex = 4;
await act(async () => {
jumpScroll.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY: targetIndex * 20 + 7 }));
jumpScroll.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, clientY: targetIndex * 20 + 7, detail: 1 }));
jumpScroll.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY: railClientY(targetIndex, 20) }));
jumpScroll.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, clientY: railClientY(targetIndex, 20), detail: 1 }));
dispatchScroll(el);
});
await harness.waitFor(
@@ -211,8 +222,8 @@ console.log("\ntranscript question jump landing");
const targetIndex = 9; // old-u1 sits at marker 3; pick a post-prepend question
await act(async () => {
jumpScroll.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY: targetIndex * 20 + 7 }));
jumpScroll.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, clientY: targetIndex * 20 + 7, detail: 1 }));
jumpScroll.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY: railClientY(targetIndex, 18) }));
jumpScroll.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0, clientY: railClientY(targetIndex, 18), detail: 1 }));
dispatchScroll(el);
});
await harness.waitFor(
@@ -227,5 +238,144 @@ console.log("\ntranscript question jump landing");
}
}
// ── A one-turn remainder auto-loads without restoring the old fold button ───
{
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
try {
HTMLElement.prototype.scrollIntoView = () => {};
let loads = 0;
await harness.render(historyTurns(2, 61), {
running: false,
questionNavigator: true,
hasOlderHistory: true,
historyStartTurn: 2,
historyTotalTurns: 61,
onLoadOlderHistory: async () => {
loads += 1;
return true;
},
});
await harness.waitFor(() => loads > 0, "the final missing history turn to auto-load");
ok(harness.container.querySelectorAll(".jump-item").length === 61, "the first page renders a marker for every session question");
ok(!harness.container.querySelector(".transcript__older"), "the ordinary show-earlier fold button is absent");
} finally {
await harness.unmount();
await harness.close();
}
}
// ── Extreme sessions keep a bounded rail and exact lazy-load targeting ──────
{
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
try {
HTMLElement.prototype.scrollIntoView = () => {};
const requestedTurns: Array<number | undefined> = [];
await harness.render(historyTurns(9_941, 10_000), {
running: false,
questionNavigator: true,
hasOlderHistory: true,
historyStartTurn: 9_941,
historyTotalTurns: 10_000,
olderHistoryError: "pause automatic loading for this rail-only fixture",
onLoadOlderHistory: async (targetTurn) => {
requestedTurns.push(targetTurn);
return true;
},
});
await harness.settle();
const jumpScroll = stubRailGeometry(harness.container, QUESTION_JUMP_MAX_MARKERS);
ok(harness.container.querySelectorAll(".jump-item").length === QUESTION_JUMP_MAX_MARKERS, "10,000 turns keep a fixed-size question rail");
const targetTurn = 1_234;
await act(async () => {
jumpScroll.dispatchEvent(new MouseEvent("mousedown", {
bubbles: true,
cancelable: true,
button: 0,
clientY: railClientY(targetTurn, 10_000),
}));
});
await harness.waitFor(() => requestedTurns.includes(targetTurn + 1), "the aggregate rail to request the exact absolute turn");
ok(requestedTurns.includes(targetTurn + 1), "the bounded rail preserves exact unloaded-question targeting");
} finally {
await harness.unmount();
await harness.close();
}
}
// ── Clicking an unloaded complete-rail marker loads and lands on that turn ──
{
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
try {
HTMLElement.prototype.scrollIntoView = () => {};
const requestedTurns: Array<number | undefined> = [];
const onLoadOlderHistory = async (targetTurn?: number) => {
requestedTurns.push(targetTurn);
return true;
};
await harness.render(historyTurns(81, 100), {
running: false,
questionNavigator: true,
hasOlderHistory: true,
historyStartTurn: 81,
historyTotalTurns: 100,
onLoadOlderHistory,
});
await harness.settle();
const jumpScroll = stubRailGeometry(harness.container, 100);
await act(async () => {
jumpScroll.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0, clientY: railClientY(10, 100) }));
});
await harness.waitFor(() => requestedTurns.includes(11), "the unloaded marker to request its absolute turn");
await harness.render(historyTurns(11, 100), {
running: false,
questionNavigator: true,
hasOlderHistory: false,
historyStartTurn: 11,
historyTotalTurns: 100,
onLoadOlderHistory,
});
await harness.waitFor(
() => Boolean(harness.container.querySelector("#question-anchor-history-u11")),
"the newly loaded question to land in the viewport",
);
ok(Boolean(harness.container.querySelector("#question-anchor-history-u11")), "an unloaded marker loads and lands on the selected question");
} finally {
await harness.unmount();
await harness.close();
}
}
// ── Failed automatic loading stops and exposes a compact retry state ─────────
{
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 100 });
try {
HTMLElement.prototype.scrollIntoView = () => {};
let retries = 0;
await harness.render(historyTurns(81, 100), {
running: false,
questionNavigator: true,
hasOlderHistory: true,
historyStartTurn: 81,
historyTotalTurns: 100,
olderHistoryError: "backend detail stays out of the UI",
onLoadOlderHistory: async () => {
retries += 1;
return true;
},
});
const retry = Array.from(harness.container.querySelectorAll<HTMLButtonElement>("button"))
.find((button) => button.textContent?.trim() === "Retry");
ok(Boolean(retry), "a failed older-history load exposes a retry action");
ok(harness.container.textContent?.includes("Earlier conversation could not be loaded") ?? false, "the failure uses a path-free user-facing message");
await act(async () => retry?.click());
await harness.waitFor(() => retries === 1, "the older-history retry to run once");
} finally {
await harness.unmount();
await harness.close();
}
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,72 @@
// Run: node --import tsx src/__tests__/transcript-question-nav-integration.test.ts
import { createTranscriptHarness } from "./transcript-dom-harness";
import type { Item } from "../lib/useController";
import { act } from "react";
let passed = 0;
let failed = 0;
function ok(condition: unknown, label: string) {
if (condition) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function turns(count: number): Item[] {
const items: Item[] = [];
for (let i = 0; i < count; i += 1) {
items.push({
kind: "user",
id: `u${i}`,
text: `question ${i}`,
historyTurn: i + 1,
checkpointTurn: 1_000 + i,
});
}
return items;
}
console.log("\ntranscript question navigation integration");
const harness = await createTranscriptHarness({ viewportHeight: 200, rowHeight: 20 });
try {
HTMLElement.prototype.scrollIntoView = () => {};
await harness.render(turns(8), { running: false, questionNavigator: true });
await harness.settle();
const transcript = harness.scrollElement();
transcript.getBoundingClientRect = () => ({ top: 100, bottom: 300, left: 0, right: 800, height: 200, width: 800 } as DOMRect);
const setAnchorPositions = (activeTurn: number) => {
harness.container.querySelectorAll<HTMLElement>("[data-question-anchor]").forEach((anchor, turn) => {
const top = turn <= activeTurn ? -20 - (activeTurn - turn) * 20 : 40 + (turn - activeTurn) * 20;
anchor.getBoundingClientRect = () => ({ top: 100 + top, bottom: 120 + top, left: 0, right: 400, height: 20, width: 400 } as DOMRect);
});
};
const dots = () => Array.from(harness.container.querySelectorAll<HTMLElement>(".jump-dot"));
ok(dots().length === 8, "question navigator renders one marker per question");
setAnchorPositions(5);
await act(async () => {
transcript.dispatchEvent(new Event("scroll"));
await new Promise((resolve) => setTimeout(resolve, 30));
});
ok(dots()[5]?.style.width === "18px", "manual scroll moves the active marker to the visible question");
ok(dots()[7]?.style.width === "12px", "the old tail marker is no longer active after manual scroll");
ok(
harness.container.querySelector<HTMLElement>('.jump-item[data-turn="5"] .jump-dot')?.style.width === "18px",
"scroll sync uses the absolute question index instead of the unrelated checkpoint turn",
);
await harness.settle();
} finally {
await harness.unmount();
await harness.close();
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,48 @@
// Run: node --import tsx src/__tests__/transcript-question-nav.test.ts
import { activeQuestionTurn, type QuestionAnchorPosition } from "../lib/transcriptGrouping";
let passed = 0;
let failed = 0;
function equal(actual: unknown, expected: unknown, label: string) {
if (actual === expected) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`);
failed += 1;
}
}
console.log("\ntranscript question navigation");
equal(
activeQuestionTurn([
{ turn: 0, top: -520 },
{ turn: 1, top: -36 },
{ turn: 2, top: 240 },
] satisfies QuestionAnchorPosition[], 0),
1,
"active question follows the last anchor above the viewport",
);
equal(
activeQuestionTurn([
{ turn: 1, top: 28 },
{ turn: 2, top: 300 },
] satisfies QuestionAnchorPosition[], 0),
1,
"active question falls back to the first mounted anchor at the top boundary",
);
equal(
activeQuestionTurn([{ turn: 2, top: -12 }] satisfies QuestionAnchorPosition[], 0),
2,
"virtualized scrolling can update from the mounted question window",
);
equal(activeQuestionTurn([], 0), undefined, "missing anchors leave the active question unchanged");
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,221 @@
// Run: tsx src/__tests__/transcript-reader-extent-race.test.tsx
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { VirtuosoHandle } from "react-virtuoso";
import type { TranscriptScrollWriteRecord } from "../lib/transcriptScrollProbe";
import { useTranscriptScrollArbiter } from "../lib/useTranscriptScrollArbiter";
let passed = 0;
let failed = 0;
function check(condition: unknown, label: string) {
if (condition) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
console.log("\ntranscript reader extent races");
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div><div id="scroll"><div class="transcript__row" data-row-key="row-a"></div></div></body></html>', {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Element = dom.window.Element;
globalThis.Node = dom.window.Node;
let nextFrame = 1;
const frames = new Map<number, FrameRequestCallback>();
const requestFrame = (callback: FrameRequestCallback) => {
const id = nextFrame;
nextFrame += 1;
frames.set(id, callback);
return id;
};
const cancelFrame = (id: number) => void frames.delete(id);
globalThis.requestAnimationFrame = requestFrame;
globalThis.cancelAnimationFrame = cancelFrame;
dom.window.requestAnimationFrame = requestFrame;
dom.window.cancelAnimationFrame = cancelFrame;
async function flushFrames() {
const pending = [...frames.values()];
frames.clear();
await act(async () => pending.forEach((callback) => callback(performance.now())));
}
const scrollWrites: TranscriptScrollWriteRecord[] = [];
dom.window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => { scrollWrites.push(write); };
const rectAt = (top: number) => ({
top,
bottom: top + 100,
height: 100,
left: 0,
right: 800,
width: 800,
x: 0,
y: top,
toJSON: () => ({}),
});
const scrollElement = dom.window.document.getElementById("scroll") as HTMLDivElement;
const rowElement = scrollElement.querySelector<HTMLElement>(".transcript__row")!;
scrollElement.getBoundingClientRect = () => rectAt(0);
rowElement.getBoundingClientRect = () => rectAt(20);
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 725 });
let scrollExtent = 15_829;
Object.defineProperty(scrollElement, "scrollHeight", { configurable: true, get: () => scrollExtent });
Object.defineProperty(scrollElement, "scrollTop", { configurable: true, writable: true, value: 14_567.47 });
let scrollByCalls = 0;
let lastScrollByTop = 0;
const virtuosoHandle = {
scrollBy: (options?: { top?: number }) => {
scrollByCalls += 1;
lastScrollByTop = options?.top ?? 0;
scrollElement.scrollTop += lastScrollByTop;
},
scrollTo: () => {},
scrollToIndex: () => {},
getState: () => {},
} as unknown as VirtuosoHandle;
let arbiter: ReturnType<typeof useTranscriptScrollArbiter> | undefined;
function Probe() {
arbiter = useTranscriptScrollArbiter();
return null;
}
const root = createRoot(dom.window.document.getElementById("root")!);
await act(async () => root.render(<Probe />));
await act(async () => {
(arbiter!.virtuosoRef as { current: VirtuosoHandle | null }).current = virtuosoHandle;
arbiter!.scrollerRef(scrollElement);
});
// Composer wrap shrinks the in-flow viewport. Tail-follow must pin the native
// tail synchronously so jump-bottom cannot flash before the coalesced frame.
await act(async () => arbiter?.reset());
scrollExtent = 500;
scrollElement.scrollTop = 400;
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 100 });
await act(async () => arbiter?.followGrowingTail());
await flushFrames();
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 80 });
await act(async () => arbiter?.followGrowingTail());
check(scrollElement.scrollTop === 420, "footer-driven viewport shrink pins the native tail before rAF");
await act(async () => arbiter?.deliverScroll());
check(arbiter?.isAtBottom === true, "tail-follow keeps isAtBottom through a composer-wrap gap");
await flushFrames();
Object.defineProperty(scrollElement, "clientHeight", { configurable: true, value: 725 });
scrollExtent = 15_829;
scrollElement.scrollTop = 14_567.47;
// Returned Windows geometry: the native extent collapses after a downward
// wheel and rebounds while scrollTop remains clamped 1,949px too high.
await act(async () => arbiter?.deliverScroll());
await act(async () => arbiter?.releaseTailFollow());
scrollWrites.length = 0;
await act(async () => arbiter?.onWheelIntent({
ctrlKey: false,
deltaMode: 0,
deltaX: 0,
deltaY: 133.33,
target: scrollElement,
} as React.WheelEvent<HTMLElement>));
scrollExtent = 13_344;
scrollElement.scrollTop = 12_618.67;
rowElement.getBoundingClientRect = () => rectAt(1_836);
await act(async () => arbiter?.deliverScroll());
check(arbiter?.modeRef.current === "manual",
"a transient physical-bottom clamp cannot claim tail ownership");
await act(async () => arbiter?.finishProgrammaticScroll());
await act(async () => arbiter?.followGrowingTail());
await flushFrames();
check(scrollByCalls === 0, "the transaction waits while the native extent remains collapsed");
scrollExtent = 15_829;
await act(async () => arbiter?.followGrowingTail());
await flushFrames();
check(scrollByCalls === 1 && lastScrollByTop > 1_900,
`the rebound restores the logical anchor exactly once (${lastScrollByTop}px)`);
check(scrollWrites.length === 1 && scrollWrites[0].owner === "reader-stability",
"the correction is owned by reader stability rather than recovery or tail-follow");
check(arbiter?.modeRef.current === "manual", "the correction preserves manual reader ownership");
// Touch movement is incremental: the second touchmove protects only its own
// segment rather than replaying the distance from the original touchstart.
await act(async () => arbiter?.reset());
scrollExtent = 5_000;
scrollElement.scrollTop = 2_000;
await act(async () => arbiter?.deliverScroll());
await act(async () => arbiter?.releaseTailFollow());
await act(async () => arbiter?.onTouchStartIntent({
touches: [{ clientY: 100 }],
} as unknown as React.TouchEvent<HTMLElement>));
await act(async () => arbiter?.onTouchMoveIntent({
touches: [{ clientY: 90 }],
} as unknown as React.TouchEvent<HTMLElement>));
scrollElement.scrollTop = 2_010;
await act(async () => arbiter?.onTouchMoveIntent({
touches: [{ clientY: 80 }],
} as unknown as React.TouchEvent<HTMLElement>));
scrollExtent = 4_000;
scrollElement.scrollTop = 1_000;
rowElement.remove();
await act(async () => arbiter?.deliverScroll());
scrollExtent = 5_000;
scrollByCalls = 0;
lastScrollByTop = 0;
await flushFrames();
check(scrollByCalls === 1 && lastScrollByTop === 1_020,
`consecutive touch segments use incremental geometry (${lastScrollByTop}px)`);
scrollElement.append(rowElement);
// Ordinary sub-viewport measurement jitter stays browser-owned, and a higher
// priority selection cancels the still-pending transaction.
await act(async () => arbiter?.reset());
scrollExtent = 5_000;
scrollElement.scrollTop = 2_000;
rowElement.getBoundingClientRect = () => rectAt(20);
await act(async () => arbiter?.deliverScroll());
await act(async () => arbiter?.releaseTailFollow());
scrollWrites.length = 0;
scrollByCalls = 0;
await act(async () => arbiter?.onWheelIntent({
ctrlKey: false,
deltaMode: 0,
deltaX: 0,
deltaY: 133.33,
target: scrollElement,
} as React.WheelEvent<HTMLElement>));
scrollElement.scrollTop = 1_960;
rowElement.getBoundingClientRect = () => rectAt(60);
await act(async () => arbiter?.followGrowingTail());
await flushFrames();
check(scrollByCalls === 0 && scrollWrites.length === 0,
"sub-viewport reverse jitter never earns a correction");
await act(async () => arbiter?.setMode("selection", "test-reader-stability-preemption"));
scrollElement.scrollTop = 1_000;
rowElement.getBoundingClientRect = () => rectAt(1_060);
await act(async () => arbiter?.followGrowingTail());
await flushFrames();
check(scrollByCalls === 0 && scrollWrites.length === 0,
"selection ownership cancels a pending reader transaction");
await act(async () => root.unmount());
dom.window.close();
if (failed > 0) {
console.error(`\n${failed} transcript reader extent race test(s) failed; ${passed} passed.`);
process.exit(1);
}
console.log(`\n${passed} transcript reader extent race tests passed.`);
@@ -0,0 +1,155 @@
// Run: tsx src/__tests__/transcript-reader-extent-stability.test.ts
import assert from "node:assert/strict";
import type { TranscriptScrollEvent } from "../lib/transcriptScrollArbiter";
import {
createTranscriptReaderExtentGuard,
observeTranscriptReaderExtent,
resolveTranscriptReaderExtentCorrection,
transcriptScrollEventCancelsReaderExtentGuard,
transcriptKeyboardScrollDelta,
transcriptReaderExtentCanCorrect,
} from "../lib/transcriptReaderExtentStability";
console.log("\ntranscript reader extent stability");
const reported = createTranscriptReaderExtentGuard(
{ scrollTop: 14_567.47, scrollHeight: 15_829, clientHeight: 725 },
{ mode: "manual", rowKey: "visible-row", offset: 20 },
133.33,
)!;
observeTranscriptReaderExtent(
reported,
{ scrollTop: 12_618.67, scrollHeight: 13_344, clientHeight: 725 },
);
assert.equal(
resolveTranscriptReaderExtentCorrection(
reported,
{ scrollTop: 12_618.67, scrollHeight: 13_344, clientHeight: 725 },
1_836,
),
undefined,
"a still-collapsed extent cannot consume the correction budget",
);
const reportedCorrection = resolveTranscriptReaderExtentCorrection(
reported,
{ scrollTop: 12_618.67, scrollHeight: 15_829, clientHeight: 725 },
1_836,
);
assert.ok(reportedCorrection !== undefined && reportedCorrection > 1_900,
`the returned Windows geometry restores its logical anchor (${reportedCorrection})`);
const fallbackCorrection = resolveTranscriptReaderExtentCorrection(
reported,
{ scrollTop: 12_618.67, scrollHeight: 15_829, clientHeight: 725 },
);
assert.equal(Math.round(fallbackCorrection ?? 0), 2_082,
"an unmounted anchor falls back to the expected native wheel landing");
assert.equal(
transcriptReaderExtentCanCorrect(
reported,
{ scrollTop: 14_500, scrollHeight: 15_829, clientHeight: 725 },
),
false,
"sub-viewport reverse jitter remains browser-owned",
);
assert.equal(
transcriptReaderExtentCanCorrect(
reported,
{ scrollTop: 12_618.67, scrollHeight: 13_344, clientHeight: 725 },
),
false,
"a real persistent content shrink is not mistaken for a transient rebound",
);
assert.equal(
transcriptReaderExtentCanCorrect(
reported,
{ scrollTop: 12_618.67, scrollHeight: 15_829, clientHeight: 900 },
),
false,
"viewport resize invalidates the reader geometry transaction",
);
const upward = createTranscriptReaderExtentGuard(
{ scrollTop: 2_000, scrollHeight: 5_000, clientHeight: 800 },
{ mode: "manual", rowKey: "visible-row", offset: 20 },
-120,
)!;
observeTranscriptReaderExtent(
upward,
{ scrollTop: 1_400, scrollHeight: 4_200, clientHeight: 800 },
);
assert.equal(
resolveTranscriptReaderExtentCorrection(
upward,
{ scrollTop: 2_600, scrollHeight: 5_000, clientHeight: 800 },
-580,
),
-720,
"an upward gesture corrects only a catastrophic downward reversal",
);
const prepend = createTranscriptReaderExtentGuard(
{ scrollTop: 2_000, scrollHeight: 5_000, clientHeight: 800 },
{ mode: "manual", rowKey: "visible-row", offset: 20 },
-40,
)!;
observeTranscriptReaderExtent(
prepend,
{ scrollTop: 3_500, scrollHeight: 6_500, clientHeight: 800 },
);
assert.equal(
resolveTranscriptReaderExtentCorrection(
prepend,
{ scrollTop: 3_500, scrollHeight: 6_500, clientHeight: 800 },
20,
),
undefined,
"prepended history growth preserves Virtuoso's logical-anchor compensation",
);
const keyboardSnapshot = { scrollTop: 2_000, scrollHeight: 5_000, clientHeight: 800 };
assert.equal(transcriptKeyboardScrollDelta(" ", true, keyboardSnapshot), -720,
"Shift+Space uses the browser's upward direction");
assert.equal(transcriptKeyboardScrollDelta(" ", false, keyboardSnapshot), 720,
"Space uses the browser's downward direction");
assert.equal(transcriptKeyboardScrollDelta("Home", false, keyboardSnapshot), -2_000,
"Home targets the native top");
assert.equal(transcriptKeyboardScrollDelta("End", false, keyboardSnapshot), 2_200,
"End targets the native bottom");
const cancellingEvents: TranscriptScrollEvent["type"][] = [
"RESET",
"USER_SCROLL_INTENT",
"MANUAL_READING",
"VIEWPORT_RESIZED",
"USER_RESIZE_BEGIN",
"SELECTION_BEGIN",
"PROGRAMMATIC_BEGIN",
"JUMP_TO_BOTTOM",
"JUMP_TO_INDEX",
"SCROLL_TO_OFFSET",
"RECOVERY_BEGIN",
];
for (const event of cancellingEvents) {
assert.equal(transcriptScrollEventCancelsReaderExtentGuard(event), true,
`${event} cancels stale reader geometry`);
}
const observingEvents: TranscriptScrollEvent["type"][] = [
"READER_INTENT_ENDED",
"SCROLL_DELIVERED",
"TAIL_CONTENT_CHANGED",
"CONTENT_SHRANK",
"LAYOUT_HEIGHT_CHANGED",
"USER_RESIZE_END",
"SELECTION_END",
"PROGRAMMATIC_END",
"RECOVERY_END",
];
for (const event of observingEvents) {
assert.equal(transcriptScrollEventCancelsReaderExtentGuard(event), false,
`${event} leaves rebound observation active`);
}
console.log("transcript reader extent stability tests passed");
@@ -99,6 +99,7 @@ dom.window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (write) => { scrollWrites.push
// Terminal-state capture: Transcript wires this into session diagnostics.
const terminals: TranscriptRecoveryTerminal[] = [];
const rowMeasurements: Array<{ rowKey: string; kind: TranscriptRow["kind"]; height: number; width: number }> = [];
const rectAt = (top: number) => ({ top, bottom: top + 100, height: 100, left: 0, right: 800, width: 800, x: 0, y: top, toJSON: () => ({}) });
@@ -140,9 +141,10 @@ const virtuosoHandle = {
let arbiter: ReturnType<typeof useTranscriptScrollArbiter> | undefined;
let integrity: ReturnType<typeof useTranscriptLayoutIntegrity> | undefined;
function Probe({ surfaceKey, rows = baseRows }: { surfaceKey: string; rows?: TranscriptRow[] }) {
function Probe({ surfaceKey, rows = baseRows, layoutWidth = 800 }: { surfaceKey: string; rows?: TranscriptRow[]; layoutWidth?: number }) {
const scroll = useTranscriptScrollArbiter({
onRecoveryTerminal: (terminal) => { terminals.push(terminal); },
onItemMeasured: (rowKey, kind, height, width) => { rowMeasurements.push({ rowKey, kind, height, width }); },
});
const layout = useTranscriptLayoutIntegrity({
surfaceKey,
@@ -156,6 +158,8 @@ function Probe({ surfaceKey, rows = baseRows }: { surfaceKey: string; rows?: Tra
retryRecoveryRequest: scroll.retryRecoveryRequest,
lastGoodAnchorRef: scroll.lastGoodAnchorRef,
captureStateSnapshot: scroll.captureStateSnapshot,
layoutTransientRef: scroll.layoutTransientRef,
layoutWidth,
});
arbiter = scroll;
integrity = layout;
@@ -187,33 +191,44 @@ const root = createRoot(dom.window.document.getElementById("root")!);
await act(async () => root.render(<Probe surfaceKey="surface-a" />));
await act(async () => {
(arbiter!.virtuosoRef as { current: VirtuosoHandle | null }).current = virtuosoHandle;
});
// A first-mount bottom request may race the Virtuoso scroller ref. It must not
// strand the blank watchdog in a permanent layout-transient state.
await act(async () => arbiter?.scrollToBottom());
check(
arbiter?.layoutTransientRef.current === false,
"a pre-scroller tail request cannot strand layout-transient suppression",
);
await act(async () => {
arbiter!.scrollerRef(scrollElement);
});
// itemSize is the measurement source of truth. data-known-size may still hold
// the estimate Virtuoso started from, so the cache callback must receive the
// returned DOM height instead.
rowElement.dataset.rowKind = "answer";
rowElement.dataset.knownSize = "291";
rowElement.getBoundingClientRect = () => ({ ...rectAt(200), height: 632, bottom: 832, width: 960, right: 960 });
rowMeasurements.length = 0;
arbiter?.itemSize(rowElement, "offsetHeight");
check(
rowMeasurements.length === 1
&& rowMeasurements[0].rowKey === "row-a"
&& rowMeasurements[0].kind === "answer"
&& rowMeasurements[0].height === 632
&& rowMeasurements[0].width === 960,
"itemSize publishes the real DOM height instead of data-known-size",
);
rowElement.getBoundingClientRect = () => rectAt(200);
// The native extent is authoritative even when Virtuoso reports a stale
// logical atBottom value after delayed row measurement.
scrollElement.scrollTop = 400;
await act(async () => arbiter?.atBottomStateChange(false));
check(arbiter?.isAtBottom === true, "physical bottom overrides a stale Virtuoso atBottom=false report");
// A thumb gesture that reaches the frozen native bottom must claim the tail
// before release resumes real row measurements and changes the extent.
scrollElement.scrollTop = 0;
await act(async () => arbiter?.onPointerDownIntent({
button: 0,
nativeEvent: { button: 0, clientX: 795 },
} as React.PointerEvent<HTMLElement>));
scrollElement.scrollTop = 400;
await act(async () => window.dispatchEvent(new dom.window.Event("pointerup")));
check(arbiter?.modeRef.current === "tail-follow", "native thumb release at the physical bottom resumes tail-follow");
scrollExtent = 900;
await act(async () => arbiter?.deliverScroll());
await flushFrames();
await flushFrames();
await flushFrames();
check(scrollElement.scrollTop === 800, "post-release remeasurement reconverges the claimed native bottom");
scrollExtent = 500;
// A nested code/tool scrollport owns the wheel until it reaches its edge.
// Capturing the event on Transcript must not release tail-follow early.
const nestedScroller = dom.window.document.createElement("div");
@@ -245,21 +260,6 @@ await act(async () => {
check(nestedWheelAccepted && arbiter?.modeRef.current === "manual", "a nested edge hands wheel ownership to the transcript");
nestedScroller.remove();
// If measurement/clamping reaches the physical bottom between scroll events,
// a fresh downward gesture must still claim tail-follow even though the
// browser has no remaining pixels to deliver.
scrollElement.scrollTop = 400;
let bottomWheelAccepted = false;
await act(async () => {
bottomWheelAccepted = arbiter?.onWheelIntent({
ctrlKey: false,
deltaX: 0,
deltaY: 40,
target: scrollElement,
} as React.WheelEvent<HTMLElement>) ?? false;
});
check(bottomWheelAccepted && arbiter?.modeRef.current === "tail-follow", "a downward gesture claims an already-clamped physical bottom");
// A queued confirmation belongs to the surface that requested it. Resetting
// before its frame runs must prevent the old request from writing the new one.
scrollToCalls = 0;
@@ -270,6 +270,15 @@ await act(async () => arbiter?.reset());
await flushFrames();
check(scrollToCalls === 1, "a reset invalidates the previous surface's queued tail confirmation");
// A jump-bottom transaction suppresses the blank watchdog while WebView2 and
// Virtuoso are still exchanging scroll/measurement frames. The diagnostic
// packages showed the old watchdog rebuilding inside this exact window.
const keyBeforeJumpBlank = integrity?.resetKey;
await act(async () => arbiter?.scrollToBottom());
await triggerWatchdogRebuild();
check(integrity?.resetKey === keyBeforeJumpBlank, "jump-bottom transients cannot trigger a blank size-tree rebuild");
await advanceClock(350);
// Tail-follow is a persistent mode, not a six-frame retry window. As long as
// growth keeps arriving (streaming), each height notification re-arms another
// coalesced convergence and the view keeps landing on the current bottom.
@@ -277,6 +286,7 @@ scrollToCalls = 0;
await act(async () => arbiter?.scrollToBottom());
for (let i = 0; i < 14; i += 1) {
scrollExtent += 200;
await advanceClock(40);
await act(async () => arbiter?.followGrowingTail());
await flushFrames();
}
@@ -309,6 +319,28 @@ check(
);
check(scrollToCalls >= 1 && scrollToCalls <= 2, `post-churn convergence costs at most two writes (${scrollToCalls})`);
// Replay the returned Windows trace: several different extents become visible
// while one explicit jump owns the viewport. The writer may respond immediately
// and perform one final correction, but must not write once per intermediate
// height.
scrollToCalls = 0;
scrollExtent = 5_154;
scrollElement.scrollTop = 0;
await act(async () => arbiter?.scrollToBottom());
for (const extent of [3_467, 6_785, 7_728, 5_525, 4_869]) {
scrollExtent = extent;
scrollElement.scrollTop = Math.min(scrollElement.scrollTop, scrollExtent - scrollElement.clientHeight);
await act(async () => arbiter?.followGrowingTail());
await flushFrames();
}
await advanceClock(240);
for (let i = 0; i < 6; i += 1) await flushFrames();
check(scrollToCalls <= 2, `one jump-bottom transaction emits at most two effective writes (${scrollToCalls})`);
check(
scrollElement.scrollTop === scrollExtent - scrollElement.clientHeight,
`the bounded jump-bottom transaction still converges on the final native bottom (${scrollElement.scrollTop}/${scrollExtent - scrollElement.clientHeight})`,
);
scrollExtent = 500;
scrollElement.scrollTop = 400;
await act(async () => arbiter?.deliverScroll());
@@ -346,11 +378,26 @@ check(scrollByCalls === 0, "invalidated anchor stops the restore correction loop
check(scrollToIndexCalls === 0, "invalidated anchor never re-aims at the stale row");
check(scrollToBottomCalls === 1, "a reset without an anchor settles at the bottom");
// ── Blank-recovery cooldown: immediate re-blank is blocked, a persistent
// blank past the cooldown earns another rebuild
// ── Blank-recovery generation: the same geometry may hard-reset only once.
// A real row-set or width change opens one new bounded recovery opportunity.
await advanceClock(2_100);
await triggerWatchdogRebuild();
check(integrity?.resetKey === "surface-c:3", "a later blank rebuilds the size tree again");
check(integrity?.resetKey === "surface-c:2", "the same broken layout generation cannot enter a reset loop");
check(integrity?.safeMode === true, "a repeatedly blank generation enters one bounded measurement probe instead of another remount");
const safeModeResetKey = integrity?.resetKey;
rowElement.getBoundingClientRect = () => rectAt(0);
await flushBlankCheck();
check(integrity?.safeMode === false && integrity?.resetKey === safeModeResetKey,
"a healthy measured viewport exits the bounded probe without remounting");
rowElement.getBoundingClientRect = () => rectAt(200);
await triggerWatchdogRebuild();
check(integrity?.safeMode === false && integrity?.resetKey === safeModeResetKey,
"an exhausted generation cannot re-enter its measurement probe");
let recoveryRows = [...baseRows, { kind: "answer", key: "generation-1", item: { ...item, id: "generation-1" } } satisfies TranscriptRow];
await act(async () => root.render(<Probe surfaceKey="surface-c" rows={recoveryRows} />));
check(integrity?.safeMode === false, "a real layout generation change exits the automatic measurement fallback");
await triggerWatchdogRebuild();
check(integrity?.resetKey === "surface-c:3", "a changed row generation earns one new rebuild");
await act(async () => integrity?.handleItemsRendered(1));
// Let the in-flight restore converge: place the anchor row at its target
// offset so the correction loop settles within two stable frames (real DOMs
@@ -367,7 +414,11 @@ check(integrity?.resetKey === "surface-c:3", "blank recovery within the cooldown
check(scrollByCalls === 0, "cooldown-blocked blank check performs no correction");
await advanceClock(2_100);
await triggerWatchdogRebuild();
check(integrity?.resetKey === "surface-c:4", "a blank that persists past the cooldown earns another rebuild");
check(integrity?.resetKey === "surface-c:3", "the revised generation also refuses a second hard reset");
recoveryRows = [...recoveryRows, { kind: "answer", key: "generation-2", item: { ...item, id: "generation-2" } } satisfies TranscriptRow];
await act(async () => root.render(<Probe surfaceKey="surface-c" rows={recoveryRows} />));
await triggerWatchdogRebuild();
check(integrity?.resetKey === "surface-c:4", "the next real layout generation can recover once");
await act(async () => integrity?.handleItemsRendered(1));
rowElement.getBoundingClientRect = () => rectAt(0);
for (let i = 0; i < 10; i += 1) await flushFrames();
@@ -508,6 +559,7 @@ strayRow.className = "transcript__row";
strayRow.dataset.rowKey = "row-stray";
strayRow.getBoundingClientRect = () => rectAt(600);
scrollElement.appendChild(strayRow);
await act(async () => root.render(<Probe surfaceKey="surface-f" layoutWidth={960} />));
await triggerWatchdogRebuild();
const watchdogLocation = integrity?.restoreLocation;
check(
@@ -671,6 +723,37 @@ await flushFrames();
check(scrollToBottomCalls === scrollToBottomBeforeSnapshot + 2, "a disjoint snapshot-less first mount settles at the bottom");
stubSnapshot = null;
// A 10,000-row generation gets only one keyed reset and one bounded probe.
const longRows: TranscriptRow[] = Array.from({ length: 10_000 }, (_, index) => ({
kind: "answer", key: `long-${index}`,
item: { ...item, id: `long-${index}` },
}));
await switchSurface("surface-long", longRows);
await act(async () => arbiter?.releaseTailFollow());
await advanceClock(2_100);
const longResetBefore = integrity?.resetKey;
await triggerWatchdogRebuild();
const longResetAfter = integrity?.resetKey;
check(longResetAfter !== longResetBefore, "a 10,000-row generation spends its single hard-reset budget");
await act(async () => integrity?.invalidateAnchors());
await act(async () => integrity?.handleItemsRendered(1));
await triggerWatchdogRebuild();
check(integrity?.safeMode === true, "long-history recovery keeps the probe independent of total row count");
for (let frame = 0; frame < 3; frame += 1) await flushFrames();
check(integrity?.safeMode === false,
"an unsuccessful long-history probe exits without another range or scroll event");
for (let cycle = 0; cycle < 3; cycle += 1) await triggerWatchdogRebuild();
check(integrity?.resetKey === longResetAfter && integrity?.safeMode === false,
"repeated blank cycles cannot remount or re-enter the probe after the generation budget is exhausted");
const nextLongRows = [...longRows.slice(0, -1), { kind: "answer" as const, key: "long-next-generation",
item: { ...item, id: "long-next-generation" } }];
await act(async () => root.render(<Probe surfaceKey="surface-long" rows={nextLongRows} />));
check(integrity?.safeMode === false, "a changed 10,000-row generation resets the probe state");
await advanceClock(2_100);
const nextGenerationResetBefore = integrity?.resetKey;
await triggerWatchdogRebuild();
check(integrity?.resetKey !== nextGenerationResetBefore, "a changed 10,000-row generation receives a fresh hard-reset budget");
await act(async () => root.unmount());
Date.now = originalDateNow;
dom.window.setTimeout = originalSetTimeout;
@@ -0,0 +1,204 @@
// Run: node --import tsx src/__tests__/transcript-scroll-diagnostics.test.ts
import assert from "node:assert/strict";
import {
createTranscriptScrollDiagnostics,
isTranscriptScrollDiagnosticsBuild,
} from "../lib/transcriptScrollDiagnostics";
let now = 1_000;
const diagnostics = createTranscriptScrollDiagnostics({
maxEvents: 4,
now: () => now,
randomID: () => "0123456789abcdef0123456789abcdef",
environment: () => ({
buildCommit: "test-commit",
buildChannel: "test",
platform: "windows",
userAgent: "Reasonix diagnostic test",
devicePixelRatio: 1.25,
viewportWidth: 1440,
viewportHeight: 900,
reducedMotion: false,
transcriptWidth: 1180,
contentWidth: 960,
fontSize: 14,
lineHeight: 23.52,
processFoldPreference: "auto",
reasoningDisplayMode: "summary",
}),
});
diagnostics.record("scroll", { scrollTop: 10 });
assert.equal(diagnostics.getSnapshot().eventCount, 0, "idle recorder ignores events");
diagnostics.start();
now += 10;
diagnostics.record("wheel", { deltaY: -120 });
now += 10;
diagnostics.record("scroll", {
scrollTop: 120,
scrollHeight: 2_400,
clientHeight: 800,
bottomDistance: 1_480,
transcriptText: "PRIVATE_TRANSCRIPT_CANARY",
rowKey: "raw-session-row-key",
path: "C:\\Users\\private-user\\secret",
token: "TOKEN_CANARY",
} as never);
now += 10;
diagnostics.mark();
now += 10;
diagnostics.record("items-rendered", { mountedRows: 12, totalRows: 100, firstVisibleIndex: 44 });
now += 10;
diagnostics.record("scroll-write", { owner: "jump", writeKind: "scrollToIndex", targetIndex: 45 });
const active = diagnostics.getSnapshot();
assert.equal(active.status, "recording");
assert.equal(active.eventCount, 4, "ring buffer remains bounded");
assert.equal(active.droppedEventCount, 2, "ring buffer reports dropped events");
assert.equal(active.markerCount, 1, "issue marker is retained in summary");
const payload = diagnostics.stop();
assert.equal(payload.schemaVersion, 2);
assert.equal(payload.summary.eventCount, 4);
assert.equal(payload.summary.droppedEventCount, 3);
assert.equal(payload.summary.markerCount, 1);
assert.equal(payload.manifest.platform, "windows");
assert.equal(payload.events[payload.events.length - 1]?.type, "stop");
const serialized = JSON.stringify(payload);
for (const forbidden of [
"PRIVATE_TRANSCRIPT_CANARY",
"raw-session-row-key",
"private-user",
"TOKEN_CANARY",
"transcriptText",
"rowKey",
"path",
"token",
]) {
assert.equal(serialized.includes(forbidden), false, `payload excludes ${forbidden}`);
}
const frozenCount = diagnostics.getSnapshot().eventCount;
now += 10;
diagnostics.record("scroll", { scrollTop: 999 });
assert.equal(diagnostics.getSnapshot().eventCount, frozenCount, "stopped recorder remains frozen");
diagnostics.start();
assert.equal(diagnostics.getSnapshot().eventCount, 1, "new recording clears the old trace");
diagnostics.reset();
assert.equal(diagnostics.getSnapshot().status, "idle");
assert.equal(diagnostics.getSnapshot().eventCount, 0);
const markerOverflow = createTranscriptScrollDiagnostics({
maxEvents: 4,
now: () => now,
randomID: () => "fedcba9876543210fedcba9876543210",
environment: () => ({
buildCommit: "test-commit",
buildChannel: "test",
platform: "windows",
userAgent: "Reasonix diagnostic test",
devicePixelRatio: 1,
viewportWidth: 1280,
viewportHeight: 720,
reducedMotion: false,
transcriptWidth: 1040,
contentWidth: 960,
fontSize: 14,
lineHeight: 23.52,
processFoldPreference: "expanded",
reasoningDisplayMode: "expanded",
}),
});
markerOverflow.start();
markerOverflow.mark();
for (let i = 0; i < 4; i += 1) markerOverflow.record("scroll", { scrollTop: i });
const overflowPayload = markerOverflow.stop();
assert.equal(overflowPayload.summary.markerCount, 0, "summary only counts markers retained in the bounded trace");
assert.equal(overflowPayload.events.some((event) => event.type === "mark"), false, "evicted markers do not survive serialization");
const detailDiagnostics = createTranscriptScrollDiagnostics({
now: () => now,
randomID: () => "abcdef0123456789abcdef0123456789",
environment: () => ({
buildCommit: "detail-test",
buildChannel: "test",
platform: "windows",
userAgent: "Reasonix detail diagnostic test",
devicePixelRatio: 1.38,
viewportWidth: 1920,
viewportHeight: 1080,
reducedMotion: true,
transcriptWidth: 1600,
contentWidth: 960,
fontSize: 15,
lineHeight: 25.2,
processFoldPreference: "auto",
reasoningDisplayMode: "legacy-collapsed",
}),
});
detailDiagnostics.start();
detailDiagnostics.record("row-measure", {
rowIndex: 44,
rowKind: "answer",
estimatedSize: 1_800,
previousSize: 1_800,
measuredSize: 420,
sizeDelta: -1_380,
contentRevision: 3,
foldState: "closed",
disclosureCount: 1,
rowKey: "PRIVATE_ROW_KEY",
transcriptText: "PRIVATE_TRANSCRIPT_CANARY",
});
detailDiagnostics.record("scroll-state", {
source: "jump-bottom",
previousMode: "manual",
mode: "tail-follow",
atBottom: true,
scrollable: true,
readerIntent: false,
canClaimTail: false,
tailCommand: true,
scrollTop: 4_000,
scrollHeight: 5_000,
clientHeight: 800,
bottomDistance: 200,
});
detailDiagnostics.record("scroll-write", {
owner: "tail-follow",
writeKind: "scrollTo",
source: "jump-bottom",
phase: "settle",
targetTop: 5_000,
settleFrame: 2,
offBottomFrames: 2,
stagnantFrames: 0,
});
const detailPayload = detailDiagnostics.stop();
const rowMeasurement = detailPayload.events.find((event) => event.type === "row-measure");
assert.deepEqual(rowMeasurement, {
t: 0,
type: "row-measure",
rowIndex: 44,
estimatedSize: 1_800,
previousSize: 1_800,
measuredSize: 420,
sizeDelta: -1_380,
contentRevision: 3,
disclosureCount: 1,
rowKind: "answer",
foldState: "closed",
});
const detailSerialized = JSON.stringify(detailPayload);
assert.equal(detailSerialized.includes("PRIVATE_ROW_KEY"), false, "row measurements exclude stable row keys");
assert.equal(detailSerialized.includes("PRIVATE_TRANSCRIPT_CANARY"), false, "row measurements exclude transcript text");
assert.equal(isTranscriptScrollDiagnosticsBuild("test", false), true, "test builds expose diagnostics");
assert.equal(isTranscriptScrollDiagnosticsBuild("stable", true), true, "development server exposes diagnostics");
assert.equal(isTranscriptScrollDiagnosticsBuild("stable", false), false, "stable production hides diagnostics");
console.log("transcript scroll diagnostics tests passed");
@@ -8,6 +8,7 @@ import {
type TranscriptScrollEvent,
type TranscriptScrollState,
} from "../lib/transcriptScrollArbiter";
import { pinTranscriptScrollerToNativeTail, pinTranscriptTailAfterViewportShrink } from "../lib/transcriptScrollGeometry";
let passed = 0;
let failed = 0;
@@ -71,9 +72,56 @@ const returned = run([
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
{ type: "USER_SCROLL_INTENT", canClaimTail: true },
{ type: "SCROLL_DELIVERED", atBottom: false, scrollable: true },
// The bottom must be held: two consecutive at-bottom deliveries inside the
// same reader-intent window re-engage tail-follow (#8709/#9099).
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
]);
check(returned.state.mode === "tail-follow", "reaching the real bottom re-engages tail-follow");
check(returned.state.mode === "tail-follow", "holding the real bottom across two deliveries re-engages tail-follow");
const touchDownOnce = run([
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
{ type: "USER_SCROLL_INTENT", canClaimTail: true },
{ type: "SCROLL_DELIVERED", atBottom: false, scrollable: true },
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
]);
check(touchDownOnce.state.mode === "manual", "a single touch-down at the bottom stays manual");
const holdBrokenByUpwardGesture = run([
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
{ type: "USER_SCROLL_INTENT", canClaimTail: true },
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
// An upward gesture inside the streak resets the hold; the next downward
// gesture starts again from zero.
{ type: "USER_SCROLL_INTENT", canClaimTail: false },
{ type: "USER_SCROLL_INTENT", canClaimTail: true },
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
]);
check(holdBrokenByUpwardGesture.state.mode === "manual", "an upward gesture breaks the bottom-hold streak");
const holdEndsWithIntentWindow = run([
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
{ type: "USER_SCROLL_INTENT", canClaimTail: true },
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
{ type: "READER_INTENT_ENDED" },
{ type: "USER_SCROLL_INTENT", canClaimTail: true },
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
]);
check(holdEndsWithIntentWindow.state.mode === "manual", "a closed intent window ends the bottom-hold streak");
const steadyStateOffsetKeepsManual = run([
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
{ type: "USER_SCROLL_INTENT", canClaimTail: false },
{ type: "SCROLL_DELIVERED", atBottom: false, scrollable: true },
{ type: "READER_INTENT_ENDED" },
{ type: "SCROLL_TO_OFFSET", owner: "anchor-compensation", top: 640 },
{ type: "SCROLL_TO_OFFSET", owner: "block-window-prepend", top: 680 },
]);
check(steadyStateOffsetKeepsManual.state.mode === "manual", "steady-state offset corrections keep manual ownership");
check(
steadyStateOffsetKeepsManual.commands.join(",") === "SCROLL_TO_OFFSET,SCROLL_TO_OFFSET",
"steady-state offset corrections emit only their own commands",
);
const browserClamp = run([
{ type: "SCROLL_DELIVERED", atBottom: true, scrollable: true },
@@ -207,5 +255,21 @@ check(
"substantial displacements keep reconverging after a misread shrink",
);
const wrapScroller = { scrollHeight: 500, scrollTop: 400, clientHeight: 80 };
check(pinTranscriptScrollerToNativeTail(wrapScroller) === true, "a composer-wrap viewport shrink is off-bottom and gets pinned");
check(wrapScroller.scrollTop === 420, "pin writes the native tail top");
check(pinTranscriptScrollerToNativeTail(wrapScroller) === false, "an already-pinned scroller is left alone");
const foldScroller = { scrollHeight: 500, scrollTop: 400, clientHeight: 80 };
check(
pinTranscriptTailAfterViewportShrink(foldScroller, { contentExtent: 540, viewportExtent: 100 }, true) === null,
"content collapse suppresses a coincident viewport-shrink pin",
);
check(foldScroller.scrollTop === 400, "content collapse leaves the browser-owned offset unchanged");
check(
pinTranscriptTailAfterViewportShrink(foldScroller, { contentExtent: 500, viewportExtent: 100 }, false) === null,
"manual reading suppresses viewport-shrink pinning",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -1,7 +1,7 @@
// Run: tsx src/__tests__/transcript-state-snapshot.test.ts
import type { StateSnapshot } from "react-virtuoso";
import { resolveTranscriptStateSnapshot } from "../lib/transcriptStateSnapshot";
import type { StateSnapshot, VirtuosoHandle } from "react-virtuoso";
import { captureTranscriptVirtuosoState, resolveTranscriptStateSnapshot } from "../lib/transcriptStateSnapshot";
let passed = 0;
let failed = 0;
@@ -18,6 +18,12 @@ function check(condition: unknown, label: string) {
console.log("\ntranscript state snapshot");
const liveSnapshot = { ranges: [{ startIndex: 0, endIndex: 1, size: 100 }], scrollTop: 42 } as StateSnapshot;
const fakeHandle = { getState: (callback: (snapshot: StateSnapshot) => void) => callback(liveSnapshot) } as VirtuosoHandle;
check(captureTranscriptVirtuosoState(null) === null, "a missing Virtuoso handle has no snapshot");
check(captureTranscriptVirtuosoState(fakeHandle) === liveSnapshot,
"snapshot capture returns Virtuoso's synchronous measured state");
const base: StateSnapshot = {
scrollTop: 420,
ranges: [
@@ -6,6 +6,7 @@
// refs, and the markdown cache budget.
import { TranscriptStore } from "../lib/transcriptStore";
import { historyPageRequestBudget } from "../lib/historyPaging";
import { historyMessagesToItems, type Item } from "../lib/useController";
import type {
HistoryContentChunk,
@@ -104,7 +105,8 @@ class FakeBackend {
};
}
// Turn-budgeted windowing, mirroring the Go slice semantics.
// Turn- and entry-budgeted windowing, mirroring the Go slice semantics for
// the compact stress fixtures used below.
async HistorySliceForTab(_tabID: string, req: HistorySliceRequest): Promise<HistorySlice> {
this.sliceCalls.push(req);
if (this.sliceGate) {
@@ -135,6 +137,8 @@ class FakeBackend {
if (turnsOf[i] >= oldestTurn) { lo = i; break; }
}
}
const entries = Math.max(1, Math.floor(req.entries || 120));
lo = Math.max(lo, before - entries);
return this.slice(lo, before);
}
@@ -181,6 +185,34 @@ function bigTranscript(turns: number): HistoryMessage[] {
return messages;
}
function longArchivedTranscript(turns: number): HistoryMessage[] {
const messages: HistoryMessage[] = [];
for (let turn = 1; turn <= turns; turn += 1) {
const callId = `archived-${turn}`;
messages.push({ role: "user", content: `prompt ${turn}` });
messages.push({
role: "assistant",
content: `answer ${turn}`,
toolCalls: [{
id: callId,
name: "bash",
arguments: "",
argumentsArchived: true,
subject: `command ${turn}`,
summary: "1 line",
}],
});
messages.push({
role: "tool",
toolCallId: callId,
toolName: "bash",
content: "",
toolResultArchived: true,
});
}
return messages;
}
// Canonical shape for cross-scheme equality (ids are scheme-dependent and
// verified separately).
function canon(items: Item[]): unknown[] {
@@ -219,6 +251,11 @@ console.log("\ntranscript store");
const first = await store.loadLatest("tab-1", "/s/one.jsonl", { turns: 12 });
ok(!!first && first.items.length > 0, "latest page projects items");
eq(first?.hasOlder, true, "latest page reports older history");
const projectedTurns = (first?.items ?? [])
.filter((item): item is Extract<Item, { kind: "user" }> => item.kind === "user")
.map((item) => item.historyTurn);
eq(projectedTurns[projectedTurns.length - 1], 46, "history user items retain their absolute turn for complete-session navigation");
ok(projectedTurns.every((turn) => Number.isInteger(turn) && (turn ?? 0) > 0), "every paged user item carries an absolute history turn");
const firstIds = (first?.items ?? []).map((item) => item.id);
await drainOlder(store, "tab-1", "/s/one.jsonl", 12);
const full = store.peek("tab-1", "/s/one.jsonl");
@@ -230,6 +267,36 @@ console.log("\ntranscript store");
eq(unique.size, fullIds.length, "item ids are unique across the full projection");
}
// ── 10,000-turn targeted paging stays bounded ──────────────────────────────
{
const messages = longArchivedTranscript(10_000);
const backend = new FakeBackend(messages, new Map(), "stress");
const store = new TranscriptStore(backend);
const startedAt = performance.now();
let projection = await store.loadLatest("tab-stress", "/s/stress.jsonl", { turns: 60 });
let pages = 1;
while (projection?.hasOlder && pages <= 40) {
const budget = historyPageRequestBudget(projection.startTurn, projection.totalTurns, 1);
const older = await store.loadOlder("tab-stress", "/s/stress.jsonl", budget);
if (!older) break;
projection = older;
pages += 1;
}
const elapsedMs = performance.now() - startedAt;
const users = (projection?.items ?? []).filter((item): item is Extract<Item, { kind: "user" }> => item.kind === "user");
eq(projection?.hasOlder, false, "10,000-turn target paging reaches the first page");
eq(pages, 32, "10,000-turn target paging respects both turn and production entry bounds");
eq(backend.sliceCalls.length, 32, "10,000-turn target paging performs the expected bounded backend calls");
ok(backend.sliceCalls.slice(1).every((request) => request.entries === 1000), "targeted pages use the backend's bounded 1000-entry capacity");
eq(users.length, 10_000, "10,000-turn target paging preserves every question");
eq(users[0]?.historyTurn, 1, "10,000-turn target paging lands on absolute turn one");
eq(users[users.length - 1]?.historyTurn, 10_000, "10,000-turn target paging keeps the tail coordinate");
ok(new Set(projection?.items.map((item) => item.id)).size === projection?.items.length, "10,000-turn target paging keeps item ids unique");
const stats = store.stats();
ok(stats.bodyBytes <= stats.bodyBudgetBytes, "10,000-turn transcript stays within the production history body budget");
ok(elapsedMs < 10_000, `10,000-turn targeted paging completes within 10s (${elapsedMs.toFixed(1)}ms)`);
}
// ── cross-page tool call/result merge ───────────────────────────────────────
{
const messages: HistoryMessage[] = [
@@ -1,6 +1,7 @@
// Run: tsx src/__tests__/use-controller-meta.test.ts
import { currentTurnWaitMs, effortSwitchNoticeText, foregroundRunningFromRuntimeMeta, historyMessagesToItems, initialState, localizedBackendNoticeText, localizedNoticeText, metaFromTab, modelSwitchNoticeText, reducer, sameMeta, type Item } from "../lib/useController";
import { historyPageRequestBudget, historyTurnsToLoad } from "../lib/historyPaging";
import { shouldReconcileStaleTurn } from "../lib/useStaleTurnWatchdog";
import { parseTodos } from "../lib/tools";
import { resolveTodoPanelTodos } from "../lib/todoVisibility";
@@ -273,6 +274,12 @@ console.log("\nuse controller meta");
}
{
eq(historyTurnsToLoad(941, 1_000, 1), 500, "a distant question jump uses the bounded 500-turn history window");
eq(historyTurnsToLoad(441, 1_000, 1), 440, "the follow-up jump page reaches the requested turn without overfetching");
eq(historyTurnsToLoad(2, 61), 60, "ordinary automatic history loading keeps the standard page size");
eq(JSON.stringify(historyPageRequestBudget(941, 1_000, 1)), JSON.stringify({ turns: 500, entries: 1000 }), "a distant jump uses the backend's bounded entry capacity");
eq(JSON.stringify(historyPageRequestBudget(2, 61)), JSON.stringify({ turns: 60 }), "ordinary history loading keeps the default entry and byte budgets");
let s = reducer(initialState, {
type: "event",
e: { kind: "notice", level: "warn", code: "session_recovery_depth_cap", text: "reworded recovery maintenance" },
@@ -720,12 +727,17 @@ eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "pl
},
});
eq(s.items.some((item) => item.kind === "user" && item.text === "recent prompt"), true, "history page replace renders the latest window");
eq(s.historyStartTurn, 60, "history page stores the older cursor");
eq(s.historyStartTurn, 61, "legacy history page converts its zero-based cursor to the first absolute turn");
eq(s.historyHasOlder, true, "history page records older availability");
const recentUser = s.items.find((item) => item.kind === "user" && item.text === "recent prompt");
eq(recentUser?.kind === "user" && recentUser.checkpointTurn, 1060, "paged history hydrates its authoritative checkpoint turn");
eq(recentUser?.kind === "user" && recentUser.historyTurn, 61, "legacy history page preserves the absolute question turn");
s = reducer(s, { type: "history_older_start" });
eq(s.historyOlderLoading, true, "older history request marks loading");
s = reducer(s, { type: "history_older_error", error: "read failed" });
eq(s.historyOlderError, "read failed", "older history failures remain available to the retry UI");
s = reducer(s, { type: "history_older_start" });
eq(s.historyOlderError, undefined, "retrying older history clears the previous failure");
s = reducer(s, {
type: "history_page",
mode: "prepend",
@@ -743,6 +755,8 @@ eq(sameMeta(meta({ collaborationMode: "normal" }), meta({ collaborationMode: "pl
const users = s.items.filter((item) => item.kind === "user");
eq(users[0]?.kind === "user" && users[0].text, "older prompt", "older history prepends before the current window");
eq(users[1]?.kind === "user" && users[1].text, "recent prompt", "older history keeps the current window");
eq(users[0]?.kind === "user" && users[0].historyTurn, 1, "legacy prepend starts at absolute turn one");
eq(users[1]?.kind === "user" && users[1].historyTurn, 61, "legacy prepend keeps the recent page's absolute turn");
eq(s.historyHasOlder, false, "older history clears hasOlder when all pages are loaded");
eq(s.historyOlderLoading, false, "older history clears loading");
}
@@ -52,9 +52,14 @@ function ev(s: typeof initialState, e: WireEvent) {
review: "passed",
gap_kinds: [],
constraint_degraded: false,
floor: "standard",
attention: false,
},
});
eq(complete.items, before.items, "ordinary completion summary stays off the transcript");
eq(complete.items.length, before.items.length + 1, "workspace mutations add a neutral change notice");
const changeNotice = complete.items[complete.items.length - 1];
eq(changeNotice?.kind === "notice" ? changeNotice.level : "", "info", "workspace mutations use an info notice, not a warning");
eq(changeNotice?.kind === "notice" ? changeNotice.completionSummary : undefined, complete.completionSummary, "neutral change notice retains its own normalized summary");
eq(complete.completionSummary?.preset, "balanced", "ordinary completion summary remains available to the change panel");
const after = ev(complete, {
@@ -69,15 +74,48 @@ function ev(s: typeof initialState, e: WireEvent) {
review: "passed",
gap_kinds: ["stale_check"],
constraint_degraded: true,
floor: "delivery",
attention: true,
},
});
eq(after.items.length, before.items.length + 1, "actionable completion summary adds one compact transcript notice");
eq(after.items.length, complete.items.length + 1, "actionable completion summary adds one compact transcript notice");
const notice = after.items[after.items.length - 1];
eq(notice?.kind === "notice" ? notice.variant : "", "completion", "quality gap uses the completion notice variant");
eq(notice?.kind === "notice" ? notice.action : "", "open_changes", "quality gap links to the change panel");
eq(notice?.kind === "notice" ? notice.completionSummary : undefined, after.completionSummary, "completion notice retains its own normalized summary");
eq(notice?.kind === "notice" ? notice.text.includes("balanced") : true, false, "compact notice does not expose internal preset values");
eq(after.completionSummary?.checks_failed, 1, "actionable completion summary is retained for details");
const switchedFloor = ev({
...complete,
meta: { label: "test", ready: true, eventChannel: "", cwd: "/repo", qualityFloor: "delivery" },
}, {
kind: "completion_summary",
completion: {
...complete.completionSummary!,
verdict: "partial",
gap_kinds: ["unverified_change"],
floor: "standard",
attention: false,
},
});
const switchedNotice = switchedFloor.items[switchedFloor.items.length - 1];
eq(switchedNotice?.kind === "notice" ? switchedNotice.level : "", "info", "turn-time standard summary stays neutral after switching to delivery");
const suppressed = ev(complete, {
kind: "completion_summary",
completion: {
...complete.completionSummary!,
mutations: 0,
checks_suppressed: 1,
gap_kinds: ["suppressed_requirement"],
floor: "delivery",
attention: true,
},
});
const suppressedNotice = suppressed.items[suppressed.items.length - 1];
eq(suppressedNotice?.kind === "notice" ? suppressedNotice.title : "", "This turn still needs attention", "required suppression uses a generic attention notice");
const restarted = ev(after, { kind: "turn_started" });
eq(restarted.completionSummary, undefined, "a new turn clears the previous turn's quality details");
}
@@ -1,28 +1,9 @@
// Run: tsx src/__tests__/workspace-changes-errors.test.tsx
import { JSDOM } from "jsdom";
import { registerHooks } from "node:module";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { workspaceFileIcon } from "../components/WorkspaceFileIcon";
import { WorkspacePanel } from "../components/WorkspacePanel";
import { LocaleProvider } from "../lib/i18n";
import { resetWorkspaceTreeMemoryForTests } from "../lib/workspaceTreeMemory";
import type { AppBindings } from "../lib/bridge";
import type { DirEntry, GitCommitView, WireCompletionSummary, WorkspaceChangeDetailView, WorkspaceChangesView } from "../lib/types";
// Markdown previews lazy-load MarkdownRenderer, whose KaTeX stylesheet belongs
// to the same production chunk. Node's tsx loader has no CSS module support,
// so map stylesheet imports to the existing empty asset stub in this DOM test.
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.endsWith(".css")) {
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
}
return nextResolve(specifier, context);
},
});
import type { DirEntry, FilePreview, WorkspaceChangeDetailView, WorkspaceChangesView } from "../lib/types";
import { flushPromises, renderFilesWorkspace, renderWorkspace, waitFor } from "./workspace-panel-test-harness";
let passed = 0;
let failed = 0;
@@ -37,169 +18,6 @@ function ok(value: boolean, label: string) {
}
}
function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 20; attempt += 1) {
await act(async () => {
await flushPromises();
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function installDom() {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
globalThis.MutationObserver = dom.window.MutationObserver;
globalThis.ResizeObserver = TestResizeObserver;
dom.window.ResizeObserver = TestResizeObserver;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
(dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void }).attachEvent = () => {};
(dom.window.HTMLElement.prototype as unknown as { detachEvent: () => void }).detachEvent = () => {};
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", {
configurable: true,
get: () => 320,
});
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", {
configurable: true,
get: function offsetHeight(this: HTMLElement) {
return this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
},
});
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
configurable: true,
value: function getBoundingClientRect(this: HTMLElement) {
const width = 320;
const height = this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
return {
x: 0,
y: 0,
top: 0,
left: 0,
right: width,
bottom: height,
width,
height,
toJSON: () => ({}),
} as DOMRect;
},
});
return dom;
}
async function renderWorkspace(
changes: WorkspaceChangesView,
options: { creationMode?: boolean; history?: GitCommitView[]; detail?: WorkspaceChangeDetailView; completionSummary?: WireCompletionSummary } = {},
) {
resetWorkspaceTreeMemoryForTests();
const dom = installDom();
window.go = {
main: {
App: {
ListDirForTab: async () => [],
WorkspaceGitHistory: async () => options.history ?? [],
WorkspaceChanges: async () => changes,
WorkspaceChangeDetail: async () => options.detail ?? {},
ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
} as Partial<AppBindings> as AppBindings,
},
};
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(
<LocaleProvider>
<WorkspacePanel
open
tabId="tab-a"
cwd="/repo"
maximized={false}
initialViewMode="changed"
creationMode={options.creationMode}
completionSummary={options.completionSummary}
onClose={() => {}}
onToggleMaximized={() => {}}
/>
</LocaleProvider>,
);
await flushPromises();
});
await waitFor("workspace changes", () => Boolean(document.querySelector(".workspace-preview__body")));
return { dom, root };
}
async function renderFilesWorkspace(methods: Partial<AppBindings>, props: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) {
resetWorkspaceTreeMemoryForTests();
const dom = installDom();
window.go = {
main: {
App: {
ListDirForTab: async () => [],
SearchFileRefsForTab: async () => [],
WorkspaceGitHistory: async () => [],
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
WorkspaceChangeDetail: async () => ({}),
ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
...methods,
} as Partial<AppBindings> as AppBindings,
},
};
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
let currentProps: Parameters<typeof WorkspacePanel>[0] = {
open: true,
tabId: "tab-a",
cwd: "/repo",
maximized: false,
initialViewMode: "files",
onClose: () => {},
onToggleMaximized: () => {},
...props,
};
const rerender = async (nextProps: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) => {
currentProps = { ...currentProps, ...nextProps };
await act(async () => {
root.render(
<LocaleProvider>
<WorkspacePanel {...currentProps} />
</LocaleProvider>,
);
await flushPromises();
});
};
await rerender();
return { dom, root, rerender };
}
console.log("\nworkspace changes git errors");
{
@@ -833,6 +651,8 @@ console.log("\nworkspace changes git errors");
}
{
let resolveCodePreview!: (preview: FilePreview) => void;
const codePreview = new Promise<FilePreview>((resolve) => { resolveCodePreview = resolve; });
const { dom, root } = await renderFilesWorkspace({
ListDirForTab: async (_tabId, dir) => dir === ""
? [
@@ -840,13 +660,9 @@ console.log("\nworkspace changes git errors");
{ name: "README.md", isDir: false },
]
: [],
ReadFileForTab: async (_tabId, path) => ({
path,
body: path === "README.md" ? "# Documentation" : "const value = 42;",
size: 17,
truncated: false,
binary: false,
}),
ReadFileForTab: async (_tabId, path) => path === "README.md"
? { path, body: "# Documentation", size: 15, truncated: false, binary: false }
: codePreview,
});
await waitFor("searchable code file", () => document.querySelector('[data-workspace-path="code.ts"]') != null);
@@ -856,11 +672,15 @@ console.log("\nworkspace changes git errors");
?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
await flushPromises();
});
await waitFor("pending code preview layout", () => document.querySelector(".workspace-preview__body--code") != null);
ok(document.querySelector(".workspace-preview__body--code") != null, "code preview uses its final layout while loading");
await act(async () => {
resolveCodePreview({ path: "code.ts", body: "const value = 42;", size: 17, truncated: false, binary: false });
await flushPromises();
});
await waitFor("code preview search action", () => document.querySelector('button[aria-label="Find"]') != null);
ok(
document.querySelector('button[aria-label="Find"]') != null,
"searchable code previews expose a visible search action",
);
ok(document.querySelector(".workspace-preview__body--code") != null, "resolved code preview keeps the loading layout");
ok(document.querySelector('button[aria-label="Find"]') != null, "searchable code previews expose a visible search action");
const filterInput = document.querySelector<HTMLInputElement>('input[placeholder="Filter files…"]');
await act(async () => {
@@ -874,10 +694,7 @@ console.log("\nworkspace changes git errors");
await flushPromises();
});
await waitFor("panel-scoped code search", () => document.querySelector(".code-search__input") != null);
ok(
document.activeElement === document.querySelector(".code-search__input"),
"workspace find shortcut opens and focuses code search from the file filter",
);
ok(document.activeElement === document.querySelector(".code-search__input"), "workspace find shortcut opens and focuses code search from the file filter");
await act(async () => {
document
@@ -893,10 +710,7 @@ console.log("\nworkspace changes git errors");
await flushPromises();
});
await waitFor("button-opened code search", () => document.querySelector(".code-search__input") != null);
ok(
document.activeElement === document.querySelector(".code-search__input"),
"visible search action opens and focuses the same search UI",
);
ok(document.activeElement === document.querySelector(".code-search__input"), "visible search action opens and focuses the same search UI");
await act(async () => {
document
@@ -905,10 +719,7 @@ console.log("\nworkspace changes git errors");
await flushPromises();
});
await waitFor("markdown preview", () => document.body.textContent?.includes("Documentation") === true);
ok(
document.querySelector('button[aria-label="Find"]') == null,
"Markdown previews do not expose the code-search action",
);
ok(document.querySelector('button[aria-label="Find"]') == null, "Markdown previews do not expose the code-search action");
const markdownFindEvent = new window.KeyboardEvent("keydown", {
key: "f",
ctrlKey: true,
@@ -19,6 +19,10 @@ let failed = 0;
const testDir = dirname(fileURLToPath(import.meta.url));
const appSource = readFileSync(resolve(testDir, "../App.tsx"), "utf8");
const stylesSource = readFileSync(resolve(testDir, "../styles.css"), "utf8");
const moreMenuSource = [
readFileSync(resolve(testDir, "../components/TopicbarMoreMenu.tsx"), "utf8"),
readFileSync(resolve(testDir, "../components/TopicbarMoreMenuContent.tsx"), "utf8"),
].join("\n");
const terminalPanelSource = readFileSync(resolve(testDir, "../components/TerminalPanel.tsx"), "utf8");
const terminalViewSource = readFileSync(resolve(testDir, "../components/TerminalView.tsx"), "utf8");
const terminalRailSource = readFileSync(resolve(testDir, "../components/TerminalSessionRail.tsx"), "utf8");
@@ -160,7 +164,7 @@ eq(terminalMaxHeight(180), 120, "terminal maximum never falls below the accessib
eq(clampTerminalHeight(680, 480), 240, "restored terminal height clamps after the window shrinks");
eq(clampTerminalHeight(80, 720), 120, "terminal height clamps to its minimum");
eq(
/const closeWorkspacePanel = useCallback\(\(\) => \{[\s\S]*?setLiveWorkspacePanelRenderWidth\(null\);[\s\S]*?setWorkspacePanelOpen\(false\);[\s\S]*?saveWorkspacePanelOpen\(false\);/.test(appSource),
/const closeWorkspacePanel = useCallback\(\(\) => \{[\s\S]*?setLiveWorkspacePanelRenderWidth\(null\);[\s\S]*?setWorkspacePanelOpen\(false\);[\s\S]*?saveWorkspacePanelOpen\(false, activeWorkspaceRoot\);/.test(appSource),
true,
"closing the dock clears the transient render width, hides the panel, and persists the collapsed preference",
);
@@ -177,7 +181,7 @@ eq(
"workspace resize has one guarded finish path for capture loss, blur, cancellation, and unmount",
);
eq(
/setWorkspacePanelOpen\(true\);[\s\S]*?saveWorkspacePanelOpen\(true\);/.test(appSource),
/setWorkspacePanelOpen\(true\);[\s\S]*?saveWorkspacePanelOpen\(true, activeWorkspaceRoot\);/.test(appSource),
true,
"opening the dock persists the expanded preference for the next launch",
);
@@ -253,7 +257,8 @@ const workspaceDockTabsSource = appSource.match(/<div className="workbench-dock_
eq(
workspaceDockTabsSource.length > 0
&& !/rightDock\.terminal|terminalPanelOpen|toggleTerminalPanel/.test(workspaceDockTabsSource)
&& /className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"[\s\S]*?aria-label=\{t\("rightDock\.terminal"\)\}[\s\S]*?onClick=\{toggleTerminalPanel\}/.test(appSource),
&& /<TopicbarMoreMenu[\s\S]*?toggleTerminal=\{toggleTerminalPanel\}/.test(appSource)
&& /className="topicbar__menu-item"[\s\S]*?closeAndRun\(toggleTerminal\)[\s\S]*?t\("rightDock\.terminal"\)/.test(moreMenuSource),
true,
"workspace dock omits the terminal view while the topic bar keeps the terminal drawer action",
);
@@ -324,11 +329,11 @@ eq(
"terminal and xterm remain in a lazy chunk",
);
eq(
/onPointerEnter=\{prefetchTerminalPanel\}/.test(appSource)
&& /onFocus=\{prefetchTerminalPanel\}/.test(appSource)
/onPointerEnter=\{prefetchTerminal\}/.test(moreMenuSource)
&& /onFocus=\{prefetchTerminal\}/.test(moreMenuSource)
&& /void import\("\.\.\/components\/TerminalPanel"\)/.test(terminalLifecycleSource),
true,
"pointer and keyboard intent prefetch the terminal chunk before opening",
"pointer and keyboard intent prefetch the terminal chunk before opening (More menu)",
);
eq(
/useWarmTerminalPanel\(terminalPanelOpen, terminalResizing\)/.test(appSource)
@@ -370,5 +375,45 @@ eq(
"closing a warm terminal removes portaled selection controls",
);
// C1: the chat pane keeps its 400px floor no matter how wide the dock is
// dragged — the dock's available width is viewport minus sidebar minus the
// 400px chat minimum minus the resizer, so chat can never be squeezed below it.
const chatFloorDock = availableWorkspacePanelWidth({
viewportWidth: 1000,
sidebarCollapsed: false,
sidebarWidth: SIDEBAR_WIDTH,
chatMinWidth: CHAT_MIN_WIDTH,
resizerWidth: RESIZER_WIDTH,
});
eq(
chatFloorDock + SIDEBAR_WIDTH + CHAT_MIN_WIDTH + RESIZER_WIDTH <= 1000,
true,
"C1: dock width never consumes the chat 400px floor (chat stays readable)",
);
// Sanity: with a wide viewport the dock gets more room, but the chat floor is
// still reserved — chat is never the thing that shrinks.
const wideDock = availableWorkspacePanelWidth({
viewportWidth: 1600,
sidebarCollapsed: false,
sidebarWidth: SIDEBAR_WIDTH,
chatMinWidth: CHAT_MIN_WIDTH,
resizerWidth: RESIZER_WIDTH,
});
eq(wideDock > chatFloorDock, true, "C1: wider viewport gives the dock more room, chat floor untouched");
// C3: switching dock tabs (context/files/changed) must never resize the dock —
// the preferred width is a single source (rightDockTreeWidth), not a
// detail-dependent ternary that would jump the sidebar per tab.
eq(
/const preferredWorkspacePanelWidth = rightDockTreeWidth;/.test(appSource),
true,
"C3: preferredWorkspacePanelWidth is the single tree width (no detail ternary)",
);
eq(
/const preferredWorkspacePanelWidth = rightDockDetailActive \? rightDockPreviewWidth : rightDockTreeWidth;/.test(appSource),
false,
"C3: no preview-width dual system that would resize the sidebar on tab switch",
);
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,65 @@
// Run: tsx src/__tests__/workspace-panel-open-migration.test.ts
// zk-ge C14 兼容性:项目级 dock 开合 key 缺失时回退旧全局 key,升级不翻转用户选择
import { JSDOM } from "jsdom";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function installLocalStorage(seed: Record<string, string>) {
const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "http://localhost/" });
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.localStorage = dom.window.localStorage;
for (const [key, value] of Object.entries(seed)) {
dom.window.localStorage.setItem(key, value);
}
return dom;
}
// 动态 import(依赖 window/localStorage 已就绪)
async function loadModule() {
return await import("../store/layout");
}
console.log("\nworkspace dock-open legacy key migration (CLAIM.PERSIST.014)");
{
// 场景 1:项目 key 缺失 + 旧全局 key = 关闭("0") → 首次打开项目应保持关闭
installLocalStorage({ "reasonix.workspacePanel.open": "0" });
const { loadWorkspacePanelOpen } = await loadModule();
ok(loadWorkspacePanelOpen("project-a") === false, "project key missing + legacy global closed => stays closed");
}
{
// 场景 2:项目 key 缺失 + 旧全局 key = 打开("1") → 首次打开项目应打开
installLocalStorage({ "reasonix.workspacePanel.open": "1" });
const { loadWorkspacePanelOpen } = await loadModule();
ok(loadWorkspacePanelOpen("project-b") === true, "project key missing + legacy global open => stays open");
}
{
// 场景 3:项目 key 存在(优先于旧全局 key)
installLocalStorage({ "reasonix.workspacePanel.open": "0", "reasonix.workspacePanel.open.project-c": "1" });
const { loadWorkspacePanelOpen } = await loadModule();
ok(loadWorkspacePanelOpen("project-c") === true, "project key present wins over legacy global");
}
{
// 场景 4:项目 key 和旧全局 key 都缺失 → 默认打开
installLocalStorage({});
const { loadWorkspacePanelOpen } = await loadModule();
ok(loadWorkspacePanelOpen("project-d") === true, "neither key present => default open");
}
console.log(`\nworkspace dock-open migration: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,181 @@
import { JSDOM } from "jsdom";
import { registerHooks } from "node:module";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { WorkspacePanel } from "../components/WorkspacePanel";
import type { AppBindings } from "../lib/bridge";
import { LocaleProvider } from "../lib/i18n";
import type { GitCommitView, WireCompletionSummary, WorkspaceChangeDetailView, WorkspaceChangesView } from "../lib/types";
import { resetWorkspaceTreeMemoryForTests } from "../lib/workspaceTreeMemory";
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.endsWith(".css")) {
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
}
return nextResolve(specifier, context);
},
});
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
export function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
export async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 20; attempt += 1) {
await act(async () => {
await flushPromises();
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
function installDom() {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
globalThis.MutationObserver = dom.window.MutationObserver;
globalThis.ResizeObserver = TestResizeObserver;
dom.window.ResizeObserver = TestResizeObserver;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
(dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void }).attachEvent = () => {};
(dom.window.HTMLElement.prototype as unknown as { detachEvent: () => void }).detachEvent = () => {};
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", {
configurable: true,
get: () => 320,
});
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", {
configurable: true,
get: function offsetHeight(this: HTMLElement) {
return this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
},
});
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
configurable: true,
value: function getBoundingClientRect(this: HTMLElement) {
const width = 320;
const height = this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
return {
x: 0,
y: 0,
top: 0,
left: 0,
right: width,
bottom: height,
width,
height,
toJSON: () => ({}),
} as DOMRect;
},
});
return dom;
}
export async function renderWorkspace(
changes: WorkspaceChangesView,
options: { creationMode?: boolean; history?: GitCommitView[]; detail?: WorkspaceChangeDetailView; completionSummary?: WireCompletionSummary } = {},
) {
resetWorkspaceTreeMemoryForTests();
const dom = installDom();
window.go = {
main: {
App: {
ListDirForTab: async () => [],
WorkspaceGitHistory: async () => options.history ?? [],
WorkspaceChanges: async () => changes,
WorkspaceChangeDetail: async () => options.detail ?? {},
ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
} as Partial<AppBindings> as AppBindings,
},
};
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(
<LocaleProvider>
<WorkspacePanel
open
tabId="tab-a"
cwd="/repo"
maximized={false}
initialViewMode="changed"
creationMode={options.creationMode}
completionSummary={options.completionSummary}
onClose={() => {}}
onToggleMaximized={() => {}}
/>
</LocaleProvider>,
);
await flushPromises();
});
await waitFor("workspace changes", () => Boolean(document.querySelector(".workspace-preview__body")));
return { dom, root };
}
export async function renderFilesWorkspace(methods: Partial<AppBindings>, props: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) {
resetWorkspaceTreeMemoryForTests();
const dom = installDom();
window.go = {
main: {
App: {
ListDirForTab: async () => [],
SearchFileRefsForTab: async () => [],
WorkspaceGitHistory: async () => [],
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
WorkspaceChangeDetail: async () => ({}),
ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
...methods,
} as Partial<AppBindings> as AppBindings,
},
};
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
let currentProps: Parameters<typeof WorkspacePanel>[0] = {
open: true,
tabId: "tab-a",
cwd: "/repo",
maximized: false,
initialViewMode: "files",
onClose: () => {},
onToggleMaximized: () => {},
...props,
};
const rerender = async (nextProps: Partial<Parameters<typeof WorkspacePanel>[0]> = {}) => {
currentProps = { ...currentProps, ...nextProps };
await act(async () => {
root.render(
<LocaleProvider>
<WorkspacePanel {...currentProps} />
</LocaleProvider>,
);
await flushPromises();
});
};
await rerender();
return { dom, root, rerender };
}
@@ -96,7 +96,17 @@ eq(finalDeclaration(".code-search__actions", "display"), "flex", "search actions
eq(finalDeclaration(".code-search__actions", "margin-left"), "auto", "wrapped search actions stay aligned to the toolbar edge");
eq(finalDeclaration(".workspace-preview__body--code .code", "overflow"), "auto", "code viewport owns horizontal and vertical scrolling");
eq(finalDeclaration(".workspace-preview__body--code .code", "min-height"), "0", "code viewport can shrink inside the preview pane");
eq(finalDeclaration(".workspace-preview__body--code .code", "max-height"), "none", "workspace code clears the shared scroll-y height cap");
eq(finalDeclaration(".workspace-preview__body--code .code", "margin"), "0", "code viewport scrollbar sits at the visible pane bottom");
eq(
computedDeclaration(
`<html><head></head><body><div class="workspace-preview__body workspace-preview__body--code"><pre class="code code--scroll-y"></pre></div></body></html>`,
".code--scroll-y",
"max-height",
),
"none",
"workspace selector wins over the shared virtual-scroll cap",
);
eq(finalDeclaration(".code-search__input", "min-width"), "60px", "search input reserves a minimum readable width");
eq(
computedDeclaration(
@@ -107,24 +117,25 @@ eq(
"0px",
"themed workspace code keeps the line-number gutter flush",
);
const splitDom = `<html><head></head><body><aside class="workspace-panel"><section class="workspace-files"></section><section class="workspace-preview"></section><button class="workspace-tree-resizer"></button></aside></body></html>`;
eq(
finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden)", "grid-template-columns"),
"var(--workspace-tree-rail-width) var(--workspace-tree-width) minmax(var(--workspace-preview-min-width), 1fr)",
"split mode keeps a narrow tree toggle rail beside the file tree",
computedDeclaration(splitDom, ".workspace-panel", "grid-template-columns"),
"minmax(var(--workspace-preview-min-width), 1fr) var(--workspace-tree-width)",
"split mode is a two-column layout: preview on the left, file tree on the right (the tree toggle rail is gone)",
);
eq(finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden) .workspace-files", "grid-column"), "2", "file tree sits beside the rail");
eq(finalDeclaration(".workspace-panel--with-tree-rail:not(.workspace-panel--tree-hidden) .workspace-preview", "grid-column"), "3", "preview sits after rail and tree");
eq(computedDeclaration(splitDom, ".workspace-files", "grid-column"), "2", "file tree sits at the far right after the preview");
eq(computedDeclaration(splitDom, ".workspace-preview", "grid-column"), "1", "preview sits to the left of the tree");
eq(
finalDeclaration(".workspace-panel--with-tree-rail .workspace-tree-resizer", "left"),
"calc(var(--workspace-tree-rail-width) + var(--workspace-tree-width) - 4px)",
"tree resizer accounts for the persistent rail",
computedDeclaration(splitDom, ".workspace-tree-resizer", "left"),
"calc(100% - var(--workspace-tree-width) - 4px)",
"tree resizer sits at the tree's left edge (tree is on the right)",
);
eq(
finalDeclaration(".workspace-panel--tree-hidden", "grid-template-columns"),
"var(--workspace-tree-rail-width) minmax(0, 1fr)",
"preview-only mode keeps a narrow tree toggle rail",
"minmax(0, 1fr)",
"preview-only mode drops the file tree",
);
eq(finalDeclaration(".workspace-panel--tree-hidden .workspace-preview", "grid-column"), "2", "preview sits beside the rail");
eq(finalDeclaration(".workspace-panel--tree-hidden .workspace-preview", "grid-column"), "1", "preview fills the panel when the tree is hidden");
eq(
/const filePreviewLimit = 2 \* 1024 \* 1024/.test(appGo),
true,
@@ -0,0 +1,165 @@
// Run: tsx src/__tests__/workspace-resize-interaction.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { WorkspacePanel } from "../components/WorkspacePanel";
import type { AppBindings } from "../lib/bridge";
import { LocaleProvider } from "../lib/i18n";
import { resetWorkspaceTreeMemoryForTests } from "../lib/workspaceTreeMemory";
let passed = 0;
let failed = 0;
function eq<T>(actual: T, expected: T, label: string) {
if (Object.is(actual, expected)) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}: expected ${String(expected)}, got ${String(actual)}\n`);
failed += 1;
}
}
function flushTimers(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 30; attempt += 1) {
await act(async () => {
await flushTimers();
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: "en-US" });
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
globalThis.MutationObserver = dom.window.MutationObserver;
globalThis.ResizeObserver = TestResizeObserver;
dom.window.ResizeObserver = TestResizeObserver;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { configurable: true, get: () => 800 });
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", {
configurable: true,
get: function offsetHeight(this: HTMLElement) {
return this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
},
});
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
configurable: true,
value: function getBoundingClientRect(this: HTMLElement) {
const width = 800;
const height = this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
return { x: 100, y: 0, top: 0, left: 100, right: 900, bottom: height, width, height, toJSON: () => ({}) } as DOMRect;
},
});
console.log("\nworkspace right-side tree resize interaction");
resetWorkspaceTreeMemoryForTests();
window.go = {
main: {
App: {
ListDirForTab: async (_tabId, dir) => dir === "" ? [{ name: "app.ts", isDir: false }] : [],
SearchFileRefsForTab: async () => [],
WorkspaceGitHistory: async () => [],
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
WorkspaceChangeDetail: async () => ({}),
ReadFileForTab: async (_tabId, path) => ({ path, body: "const value = 1;", size: 16, truncated: false, binary: false }),
} as Partial<AppBindings> as AppBindings,
},
};
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("missing root");
const root = createRoot(rootElement);
await act(async () => {
root.render(
<LocaleProvider>
<WorkspacePanel
open
tabId="resize-tab"
cwd="/repo"
maximized={false}
panelWidth={800}
initialViewMode="files"
onClose={() => {}}
onToggleMaximized={() => {}}
/>
</LocaleProvider>,
);
await flushTimers();
});
await waitFor("workspace file", () => document.querySelector('[data-workspace-path="app.ts"]') !== null);
await act(async () => {
document.querySelector<HTMLButtonElement>('[data-workspace-path="app.ts"]')?.click();
await flushTimers();
});
await waitFor("tree separator", () => document.querySelector(".workspace-tree-resizer") !== null);
const separator = document.querySelector<HTMLButtonElement>(".workspace-tree-resizer");
if (!separator) throw new Error("missing tree separator");
const initialWidth = Number(separator.getAttribute("aria-valuenow"));
await act(async () => {
separator.dispatchEvent(new window.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowRight" }));
await flushTimers();
});
eq(Number(separator.getAttribute("aria-valuenow")), initialWidth - 16, "ArrowRight moves the separator right and narrows the right-side tree");
await act(async () => {
separator.dispatchEvent(new window.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "ArrowLeft" }));
await flushTimers();
});
eq(Number(separator.getAttribute("aria-valuenow")), initialWidth, "ArrowLeft moves the separator left and widens the right-side tree");
await act(async () => {
separator.dispatchEvent(new window.MouseEvent("pointerdown", { bubbles: true, cancelable: true, clientX: 600 }));
window.dispatchEvent(new window.MouseEvent("pointermove", { bubbles: true, clientX: 650 }));
window.dispatchEvent(new window.MouseEvent("pointerup", { bubbles: true, clientX: 650 }));
await flushTimers();
});
eq(Number(separator.getAttribute("aria-valuenow")), 250, "pointer position measures tree width from the panel's right edge");
await act(async () => {
separator.dispatchEvent(new window.KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "End" }));
await flushTimers();
});
eq(Number(separator.getAttribute("aria-valuenow")), 660, "End widens the tree to keep only the preview minimum");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,91 @@
// Run: tsx src/__tests__/workspace-selection-isolation.test.tsx
import { act } from "react";
import { flushPromises, renderFilesWorkspace, waitFor } from "./workspace-panel-test-harness";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
console.log("\nworkspace selection isolation");
const changeDetailPaths: string[] = [];
const { dom, root, rerender } = await renderFilesWorkspace({
ListDirForTab: async (_tabId, dir) => dir === "" ? [{ name: "notes.txt", isDir: false }] : [],
WorkspaceChanges: async () => ({
files: [{ path: "src/changed.ts", sources: ["git"], gitStatus: "M" }],
gitAvailable: true,
}),
WorkspaceChangeDetail: async (_tabId, path) => {
changeDetailPaths.push(path);
return { source: "git", diff: "changed diff" };
},
ReadFileForTab: async (_tabId, path) => ({ path, body: "file preview", size: 12, truncated: false, binary: false }),
});
await waitFor("files view entry", () => document.body.textContent?.includes("notes.txt") === true);
await act(async () => {
document.querySelector<HTMLButtonElement>('[data-workspace-path="notes.txt"]')
?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
await flushPromises();
});
await waitFor("selected file preview", () => document.body.textContent?.includes("file preview") === true);
await rerender({ initialViewMode: "changed" });
await waitFor("changes overview", () => document.body.textContent?.includes("changed.ts") === true);
ok(changeDetailPaths.length === 0, "entering Changes does not request diff detail for the Files selection");
ok(document.querySelector(".workspace-panel--changed-overview") !== null, "entering Changes opens its overview instead of leaking the Files selection");
await act(async () => {
document.querySelector<HTMLButtonElement>(".workspace-change")
?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
await flushPromises();
});
await waitFor("selected change detail", () => changeDetailPaths.includes("src/changed.ts"));
await rerender({ initialViewMode: "files" });
await waitFor("restored file preview", () => document.body.textContent?.includes("file preview") === true);
ok(changeDetailPaths.every((path) => path === "src/changed.ts"), "Changes requests only its own selected path");
ok(document.body.textContent?.includes("notes.txt") === true, "returning to Files restores the prior file selection after viewing a change");
await rerender({
initialViewMode: "changed",
changeListRequest: {
id: 1,
changes: [{ key: "scoped-change", path: "src/changed.ts", meta: "M", time: "", detail: "changed" }],
},
});
await waitFor("scoped Changes list", () => document.body.textContent?.includes("Session changes") === true);
await rerender({ initialViewMode: "files", changeListRequest: null });
await waitFor("file preview after scoped Changes", () => document.body.textContent?.includes("file preview") === true);
ok(document.body.textContent?.includes("notes.txt") === true, "scoped Changes requests preserve the Files preview state");
await rerender({ initialViewMode: "changed" });
await waitFor("change before scope switch", () => document.body.textContent?.includes("changed.ts") === true);
await act(async () => {
document.querySelector<HTMLButtonElement>(".workspace-change")
?.dispatchEvent(new window.MouseEvent("click", { bubbles: true }));
await flushPromises();
});
await waitFor("change selected before scope switch", () => changeDetailPaths.includes("src/changed.ts"));
const callsBeforeScopeSwitch = changeDetailPaths.length;
await rerender({ workspaceScopeKey: "other-session" });
await waitFor("new scope Changes overview", () => document.querySelector(".workspace-panel--changed-overview") !== null);
ok(changeDetailPaths.length === callsBeforeScopeSwitch, "workspace scope changes do not request the previous scope's selected change");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -28,30 +28,27 @@ function eq(a: unknown, b: unknown, label: string) {
console.log("\nworkspace file split");
const TREE_MIN_WIDTH = 140;
const TREE_RAIL_WIDTH = 44;
const PREVIEW_MIN_WIDTH = 140;
eq(
initialWorkspaceSplitTreeWidth({
panelWidth: 660,
railWidth: TREE_RAIL_WIDTH,
savedTreeWidth: null,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
}),
308,
"first split divides the file area evenly after reserving the tree rail",
330,
"first split divides the file area evenly (no tree rail to reserve)",
);
eq(
initialWorkspaceSplitTreeWidth({
panelWidth: 660,
railWidth: TREE_RAIL_WIDTH,
savedTreeWidth: 620,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
}),
476,
520,
"tree width is clamped so the preview keeps its minimum width",
);
@@ -60,12 +57,11 @@ eq(
clientX: 400,
panelLeft: 100,
panelWidth: 660,
railWidth: TREE_RAIL_WIDTH,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
}),
256,
"tree resize pointer coordinates start after the tree rail",
300,
"tree resize pointer coordinates start at the panel's left edge",
);
eq(
@@ -73,12 +69,37 @@ eq(
clientX: 700,
panelLeft: 100,
panelWidth: 660,
railWidth: TREE_RAIL_WIDTH,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
}),
476,
"tree resize pointer clamps against the preview minimum after reserving the rail",
520,
"tree resize pointer clamps against the preview minimum",
);
eq(
workspaceSplitTreeWidthFromPointer({
clientX: 650,
panelLeft: 100,
panelWidth: 800,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
treeOnRight: true,
}),
250,
"right-side tree width is measured from the pointer to the panel's right edge",
);
eq(
workspaceSplitTreeWidthFromPointer({
clientX: 100,
panelLeft: 100,
panelWidth: 800,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
treeOnRight: true,
}),
660,
"right-side tree maximum keeps space for the preview",
);
eq(
@@ -86,11 +107,10 @@ eq(
mode: "even",
currentTreeWidth: 140,
panelWidth: 660,
railWidth: TREE_RAIL_WIDTH,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
}),
308,
330,
"default split recomputes evenly after the parent preview width applies",
);
@@ -99,7 +119,6 @@ eq(
mode: "manual",
currentTreeWidth: 256,
panelWidth: 660,
railWidth: TREE_RAIL_WIDTH,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
}),
@@ -121,19 +140,17 @@ eq(
eq(
workspaceSplitCanFit({
panelWidth: 323,
railWidth: TREE_RAIL_WIDTH,
panelWidth: 279,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
}),
false,
"file tree collapses before rail tree and preview would overflow a narrow panel",
"file tree collapses before tree and preview would overflow a narrow panel",
);
eq(
workspaceSplitCanFit({
panelWidth: 324,
railWidth: TREE_RAIL_WIDTH,
panelWidth: 280,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
}),
@@ -144,7 +161,6 @@ eq(
eq(
workspaceSplitCanFit({
panelWidth: undefined,
railWidth: TREE_RAIL_WIDTH,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
}),
@@ -178,7 +194,6 @@ const effectiveEvenTreeWidth = resolveWorkspaceSplitTreeWidth({
mode: "even",
currentTreeWidth: TREE_MIN_WIDTH,
panelWidth: 660,
railWidth: TREE_RAIL_WIDTH,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
});
@@ -188,7 +203,6 @@ eq(
mode: "manual",
currentTreeWidth: effectiveEvenTreeWidth,
panelWidth: 660,
railWidth: TREE_RAIL_WIDTH,
treeMinWidth: TREE_MIN_WIDTH,
previewMinWidth: PREVIEW_MIN_WIDTH,
}),
@@ -0,0 +1,250 @@
// Run: tsx src/__tests__/workspace-tree-scroll-restore.test.tsx
// zk-ge CLAIM.TREE.008: 切换 dock 标签(组件 unmount/remount)后文件树滚动位置恢复
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import type { Root } from "react-dom/client";
import { WorkspacePanel } from "../components/WorkspacePanel";
import type { AppBindings } from "../lib/bridge";
import { LocaleProvider } from "../lib/i18n";
import { resetWorkspaceTreeMemoryForTests } from "../lib/workspaceTreeMemory";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
function flushTimers(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 30; attempt += 1) {
await act(async () => {
await flushTimers();
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function installDom() {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: "en-US" });
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
globalThis.MutationObserver = dom.window.MutationObserver;
globalThis.ResizeObserver = TestResizeObserver;
dom.window.ResizeObserver = TestResizeObserver;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { configurable: true, get: () => 320 });
Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", {
configurable: true,
get: function clientHeight(this: HTMLElement) {
return this.classList.contains("workspace-tree") ? 300 : 0;
},
});
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollHeight", {
configurable: true,
get: function scrollHeight(this: HTMLElement) {
return this.classList.contains("workspace-tree") ? 40 * 24 : 0;
},
});
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", {
configurable: true,
get: function offsetHeight(this: HTMLElement) {
return this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
},
});
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
configurable: true,
value: function getBoundingClientRect(this: HTMLElement) {
const width = 320;
const height = this.classList.contains("workspace-tree") ? 300 : this.dataset.index ? 24 : 0;
return { x: 0, y: 0, top: 0, left: 0, right: width, bottom: height, width, height, toJSON: () => ({}) } as DOMRect;
},
});
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollTo", {
configurable: true,
value: function scrollTo(this: HTMLElement, options?: ScrollToOptions | number) {
const requested = typeof options === "number" ? options : options?.top ?? 0;
const maximum = Math.max(0, this.scrollHeight - this.clientHeight);
this.scrollTop = Math.max(0, Math.min(maximum, requested));
},
});
return dom;
}
const MEMORY_KEY = "scroll-test\u0000/repo";
function renderPanel(root: Root) {
return act(async () => {
root.render(
<LocaleProvider>
<WorkspacePanel
open
tabId="scroll-tab"
cwd="/repo"
workspaceMemoryKey={MEMORY_KEY}
maximized={false}
initialViewMode="files"
onClose={() => {}}
onToggleMaximized={() => {}}
onOpenInTerminal={() => {}}
/>
</LocaleProvider>,
);
await flushTimers();
});
}
console.log("\nworkspace tree scroll restore (CLAIM.TREE.008)");
resetWorkspaceTreeMemoryForTests();
const dom = installDom();
dom.window.localStorage.clear();
const RESTORED_SCROLL_TOP = 200;
dom.window.localStorage.setItem("reasonix.workspaceState.v2", JSON.stringify({
version: 2,
projects: [{
key: MEMORY_KEY,
state: {
openDirs: [""],
scrollTop: RESTORED_SCROLL_TOP,
updatedAt: Date.now(),
},
}],
}));
window.go = {
main: {
App: {
ListDirForTab: async (_tabId, dir) => {
if (dir === "") {
return Array.from({ length: 40 }, (_, i) => ({ name: `file-${i}.ts`, isDir: false }));
}
return [];
},
SearchFileRefsForTab: async () => [],
WorkspaceGitHistory: async () => [],
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
WorkspaceChangeDetail: async () => ({}),
ReadFileForTab: async (_tabId, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
ResolveWorkspacePathForTab: async (_tabId, path) => `/repo/${path}`,
RevealWorkspacePathForTab: async () => {},
OpenWorkspacePathForTab: async () => {},
} as Partial<AppBindings> as AppBindings,
},
};
const rootElement = document.getElementById("root");
if (!rootElement) throw new Error("missing root");
const root = createRoot(rootElement);
// Phase 1: mount from a persisted deep offset and verify the virtualizer writes
// that offset into a genuinely scrollable DOM fixture.
await renderPanel(root);
await waitFor("workspace rows render", () => document.querySelectorAll(".workspace-tree__row").length > 0);
const treeEl = document.querySelector<HTMLElement>(".workspace-tree");
if (!treeEl) throw new Error("missing workspace-tree element");
ok(treeEl.scrollHeight > treeEl.clientHeight, "fixture has a real scrollable extent");
await waitFor("persisted tree offset restore", () => treeEl.scrollTop === RESTORED_SCROLL_TOP);
ok(treeEl.scrollTop === RESTORED_SCROLL_TOP, "initial mount restores the persisted DOM scroll offset");
// Scroll position should have been persisted to the upstream per-project
// workspace state envelope (reasonix.workspaceState.v2).
function persistedScrollTop(): number | null {
const raw = dom.window.localStorage.getItem("reasonix.workspaceState.v2");
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as { projects?: Array<{ key: string; state: { scrollTop?: number } }> };
const project = parsed.projects?.find((entry) => entry.key === MEMORY_KEY);
return typeof project?.state?.scrollTop === "number" ? project.state.scrollTop : null;
} catch {
return null;
}
}
ok(persistedScrollTop() === RESTORED_SCROLL_TOP, "restoration preserves the persisted localStorage offset");
// Phase 2: unmount (simulates switching from 文件 to 概览 dock tab)
await act(async () => {
root.render(<></>);
await flushTimers();
});
// Phase 3: remount (simulates switching back to 文件)
await renderPanel(root);
await waitFor("workspace rows re-render", () => document.querySelectorAll(".workspace-tree__row").length > 0);
const treeEl2 = document.querySelector<HTMLElement>(".workspace-tree");
if (!treeEl2) throw new Error("missing workspace-tree element after remount");
// The persisted value must survive the remount — verify the workspace memory
// still holds it (this isolates persistence loss from virtualizer restore).
ok(persistedScrollTop() === RESTORED_SCROLL_TOP, "persisted scroll offset survives remount in localStorage");
// Remount must re-render the tree rows (otherwise the restore effect's
// treeRows.length guard never passes).
const rowsAfterRemount = document.querySelectorAll(".workspace-tree__row").length;
ok(rowsAfterRemount > 0, `tree rows re-render after remount (got ${rowsAfterRemount})`);
await waitFor("remounted tree DOM restore", () => treeEl2.scrollTop === RESTORED_SCROLL_TOP);
ok(treeEl2.scrollTop === RESTORED_SCROLL_TOP, "dock-tab remount restores the actual DOM scroll offset");
// Phase 4: switch to the "changed" view via the tab button, then back to
// "files". The pending scroll restore must be reset on viewMode change — the
// saved offset belongs to the previous mode's tree and re-applying it to a
// shorter list (files -> changed) would scroll past the end and leave a blank
// band at the top. We assert the panel still renders rows after the round
// trip; the reset itself is what makes that render coherent.
const tabs = document.querySelectorAll(".workspace-files__tab");
ok(tabs.length >= 2, "files and changed view tabs render in the fixture");
if (tabs.length >= 2) {
await act(async () => {
(tabs[1] as HTMLElement).click();
await flushTimers();
});
await act(async () => {
(tabs[0] as HTMLElement).click();
await flushTimers();
});
await waitFor("files rows re-render after view-mode round trip", () => document.querySelectorAll(".workspace-tree__row").length > 0);
ok(document.querySelectorAll(".workspace-tree__row").length > 0, "tree rows re-render after files<->changed view switch");
}
console.log(`\nworkspace tree scroll restore: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,218 @@
// Run: tsx src/__tests__/workspace-turn-verification.test.tsx
import { JSDOM } from "jsdom";
import { registerHooks } from "node:module";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { WORKSPACE_TURN_VERIFICATION_ID, WorkspacePanel } from "../components/WorkspacePanel";
import { LocaleProvider } from "../lib/i18n";
import type { AppBindings } from "../lib/bridge";
import type { WireCompletionSummary } from "../lib/types";
registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier.endsWith(".css")) {
return nextResolve("./asset-stub-for-tests.ts", { ...context, parentURL: import.meta.url });
}
return nextResolve(specifier, context);
},
});
let passed = 0;
let failed = 0;
function ok(value: unknown, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
async function waitFor(label: string, predicate: () => boolean) {
for (let attempt = 0; attempt < 30; attempt += 1) {
await act(async () => {
await flushPromises();
});
if (predicate()) return;
}
throw new Error(`timed out waiting for ${label}`);
}
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
function installDom() {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
globalThis.MutationObserver = dom.window.MutationObserver;
globalThis.ResizeObserver = TestResizeObserver;
dom.window.ResizeObserver = TestResizeObserver;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
(dom.window.HTMLElement.prototype as unknown as { attachEvent: () => void }).attachEvent = () => {};
(dom.window.HTMLElement.prototype as unknown as { detachEvent: () => void }).detachEvent = () => {};
Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetWidth", { configurable: true, get: () => 320 });
Object.defineProperty(dom.window.HTMLElement.prototype, "offsetHeight", { configurable: true, get: () => 300 });
Object.defineProperty(dom.window.HTMLElement.prototype, "getBoundingClientRect", {
configurable: true,
value: () => ({ x: 0, y: 0, top: 0, left: 0, right: 320, bottom: 300, width: 320, height: 300, toJSON: () => ({}) }) as DOMRect,
});
return dom;
}
type WorkspaceProps = Parameters<typeof WorkspacePanel>[0];
async function createHarness(props: Partial<WorkspaceProps>) {
const dom = installDom();
window.go = {
main: {
App: {
ListDirForTab: async () => [],
SearchFileRefsForTab: async () => [],
WorkspaceGitHistory: async () => [],
WorkspaceChanges: async () => ({ files: [], gitAvailable: true }),
WorkspaceChangeDetail: async () => ({}),
ReadFileForTab: async (_tabID, path) => ({ path, body: "", size: 0, truncated: false, binary: false }),
} as Partial<AppBindings> as AppBindings,
},
};
const root = createRoot(document.getElementById("root")!);
let currentProps: WorkspaceProps = {
open: true,
tabId: "tab-a",
cwd: "/repo",
maximized: false,
onClose: () => {},
onToggleMaximized: () => {},
...props,
};
const rerender = async (next: Partial<WorkspaceProps> = {}) => {
currentProps = { ...currentProps, ...next };
await act(async () => {
root.render(<LocaleProvider><WorkspacePanel {...currentProps} /></LocaleProvider>);
await flushPromises();
});
};
await rerender();
return { dom, root, rerender };
}
async function closeHarness(dom: JSDOM, root: Root) {
await act(async () => root.unmount());
dom.window.close();
}
function summary(mutations: number, checksFailed = 1): WireCompletionSummary {
return {
preset: "balanced",
verdict: "partial",
mutations,
checks_passed: 2,
checks_failed: checksFailed,
checks_suppressed: 1,
review: "passed",
gap_kinds: ["stale_check", "future_internal_value"],
constraint_degraded: true,
};
}
console.log("\nworkspace turn verification");
{
const current = summary(3);
const { dom, root } = await createHarness({ initialViewMode: "changed", completionSummary: current });
await waitFor("turn verification summary", () => document.getElementById(WORKSPACE_TURN_VERIFICATION_ID) !== null);
const text = document.querySelector(".workspace-completion-summary")?.textContent ?? "";
ok(text.includes("Partially complete") && text.includes("3 changes"), "summary renders localized verdict and metrics");
ok(text.includes("stale checks") && text.includes("Other"), "summary safely labels known and unknown gaps");
ok(text.includes("Turn verification limited"), "summary explains constrained verification");
ok(!text.includes("balanced") && !text.includes("partial") && !text.includes("stale_check"), "summary exposes no raw enum values");
const title = document.getElementById(`${WORKSPACE_TURN_VERIFICATION_ID}-title`);
ok(title?.tagName === "H3", "turn verification title is a heading, not a button");
ok(document.querySelector(`#${WORKSPACE_TURN_VERIFICATION_ID} button`) === null, "summary does not expose a clickable control");
await closeHarness(dom, root);
}
{
const legacyDeliverySummary: WireCompletionSummary = {
preset: "balanced",
verdict: "partial",
mutations: 1,
checks_passed: 0,
checks_failed: 0,
checks_suppressed: 0,
review: "passed",
gap_kinds: ["unverified_change"],
constraint_degraded: false,
};
const { dom, root, rerender } = await createHarness({
initialViewMode: "changed",
completionSummary: legacyDeliverySummary,
qualityFloor: "delivery",
});
await waitFor("delivery attention styling", () => document.querySelector(".workspace-completion-summary") !== null);
ok(document.querySelector(".workspace-completion-summary")?.classList.contains("workspace-completion-summary--attention"), "legacy delivery summary uses delivery-floor attention styling");
await rerender({ qualityFloor: "standard" });
ok(!document.querySelector(".workspace-completion-summary")?.classList.contains("workspace-completion-summary--attention"), "legacy standard summary remains neutral");
await closeHarness(dom, root);
}
{
const current = summary(2);
const historical = summary(7);
const { dom, root, rerender } = await createHarness({ initialViewMode: "changed", completionSummary: current });
await waitFor("current summary", () => document.body.textContent?.includes("2 changes") === true);
let scrolled = 0;
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => { scrolled += 1; } });
const request = { id: 1, summary: historical, tabId: "tab-a", turnStartAt: 100, currentSummary: current };
await rerender({ verificationRevealRequest: request, turnStartAt: 100 } as Partial<WorkspaceProps>);
await waitFor("same-view scroll", () => scrolled > 0);
ok(document.body.textContent?.includes("7 changes"), "same-view reveal displays the requested historical summary");
ok(scrolled === 1, "same-view reveal scrolls exactly once");
await rerender({ completionSummary: undefined, turnStartAt: 200 } as Partial<WorkspaceProps>);
await waitFor("stale summary cleared", () => document.getElementById(WORKSPACE_TURN_VERIFICATION_ID) === null);
ok(!document.body.textContent?.includes("7 changes"), "a new turn clears the historical reveal");
await closeHarness(dom, root);
}
{
const current = summary(4);
const historical = summary(9);
const { dom, root, rerender } = await createHarness({ initialViewMode: "files", completionSummary: current });
let scrolled = 0;
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => { scrolled += 1; } });
const request = { id: 2, summary: historical, tabId: "tab-a", turnStartAt: 300, currentSummary: current };
await rerender({ initialViewMode: "changed", verificationRevealRequest: request, turnStartAt: 300 } as Partial<WorkspaceProps>);
await waitFor("navigation reveal", () => document.body.textContent?.includes("9 changes") === true && scrolled > 0);
ok(document.getElementById(WORKSPACE_TURN_VERIFICATION_ID) !== null, "reveal navigates from Files to the change overview");
ok(scrolled === 1, "navigation reveal scrolls after the overview mounts");
await closeHarness(dom, root);
}
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -19,7 +19,6 @@ interface AppChromeProps {
sidebarToggleTitle: string;
workspacePanelMaximized: boolean;
workspacePanelRenderable: boolean;
workspaceTogglePressed: boolean;
workspacePanelLabel: string;
onToggleSidebar: () => void;
onToggleWorkspacePanel: () => void;
@@ -45,7 +44,6 @@ export function AppChrome({
sidebarToggleTitle,
workspacePanelMaximized,
workspacePanelRenderable,
workspaceTogglePressed,
workspacePanelLabel,
onToggleSidebar,
onToggleWorkspacePanel,
@@ -129,7 +127,6 @@ export function AppChrome({
className={[
"app-chrome__tools",
"app-chrome__tools--fixed",
workspaceTogglePressed ? "app-chrome__tools--workspace-pressed" : "",
].filter(Boolean).join(" ")}
aria-label={t("tabBar.commandSearch")}
>
@@ -156,7 +153,6 @@ export function AppChrome({
<div
className={[
"app-chrome__tools",
workspaceTogglePressed ? "app-chrome__tools--workspace-pressed" : "",
].filter(Boolean).join(" ")}
aria-label={t("tabBar.commandSearch")}
>
@@ -183,7 +179,6 @@ export function AppChrome({
"app-chrome__panel-toggle",
"app-chrome__panel-toggle--right",
workspacePanelRenderable ? "app-chrome__panel-toggle--active" : "",
workspaceTogglePressed ? "app-chrome__panel-toggle--pressed" : "",
].filter(Boolean).join(" ")}
type="button"
onClick={onToggleWorkspacePanel}
+60 -36
View File
@@ -9,6 +9,7 @@ import { enqueueInboxGuidance } from "../lib/inboxSubmit";
import { formatInboxError, isInboxItemMissing } from "../lib/inboxError";
import { inboxScopeKey } from "../lib/composerInboxQueue";
import { useComposerInboxRefresh } from "../lib/useComposerInboxRefresh";
import { useComposerImeGuard } from "../lib/useComposerImeGuard";
import { guidanceIsInFlight, guidanceNeedsRetry, guidanceTextMatches, kickIdleGuidance, markGuidanceQueued } from "../lib/composerGuidance";
import { canUsePromptHistory, composerEnterAction, composerEscapeAction, composerMenuKeyAction, insertComposerNewline, isFnKeyEvent, isImeKeyEvent, promptHistoryDirectionFromEvent } from "../lib/composerKeyboard";
import { cacheGeneration, loadOlder } from "../lib/composerHistory";
@@ -33,6 +34,7 @@ import type { ControllerLiveStore } from "../lib/useController";
import { clearLayoutSize, loadOptionalLayoutSize, saveLayoutSize } from "../lib/layoutPreferences";
import { createRafResizeUpdater } from "../lib/resizeDrag";
import { observeComposerMenuViewport } from "../lib/composerMenuViewport";
import { resolveComposerContentSizing } from "../lib/composerSizing";
import { useToast } from "../lib/toast";
import { type CollaborationMode, type CommandInfo, type ComposerInsertRequest, type ContextInfo, type DirEntry, type EffortInfo, type GoalRuntime, type HistoryMessage, type Mode, type PromptHistoryEntry, type QualityFloor, type SessionMeta, type SessionReference, type SlashArgItem, type SlashArgsResult, type ToolApprovalMode, type BalanceInfo } from "../lib/types";
import {
@@ -382,6 +384,14 @@ function composerMaxHeight(): number {
return Math.max(COMPOSER_MIN_HEIGHT, Math.min(COMPOSER_MAX_HEIGHT, Math.floor(window.innerHeight * COMPOSER_MAX_VIEWPORT_RATIO)));
}
// Hero (creation) input cap: the old 96px hard cap clipped longer drafts
// before the card autosize took over; give the hero min(30vh, 160px) so a
// visible scrollbar takes over instead (#8494/#8742/#9019).
function composerHeroInputMaxHeight(): number {
if (typeof window === "undefined") return 160;
return Math.min(Math.floor(window.innerHeight * 0.3), 160);
}
// The rendered card includes the run strip while a turn runs; subtract it to
// recover the user's logical height when measuring from the DOM.
function composerLogicalHeight(card: HTMLElement): number {
@@ -394,10 +404,6 @@ function clampComposerHeight(height: number): number {
return Math.min(Math.max(Math.round(height), COMPOSER_MIN_HEIGHT), composerMaxHeight());
}
function composerAutoInputMaxHeight(extraReservedHeight = 0): number {
return Math.max(32, composerMaxHeight() - COMPOSER_AUTO_RESERVED_HEIGHT - extraReservedHeight);
}
function loadComposerHeight(): number | null {
return loadOptionalLayoutSize("composerHeight", clampComposerHeight);
}
@@ -733,6 +739,8 @@ export function Composer({
const [active, setActive] = useState(0);
const [dismissed, setDismissed] = useState(false);
const [dragOver, setDragOver] = useState(false);
// A saved manual height is a floor, not a hard cap: longer drafts may grow
// above it and return to it when their content shrinks.
const [composerHeight, setComposerHeight] = useState<number | null>(loadComposerHeight);
const [composerResizing, setComposerResizing] = useState(false);
const [textareaAutoHeight, setTextareaAutoHeight] = useState<number | null>(null);
@@ -789,8 +797,6 @@ export function Composer({
const intentHoverTimerRef = useRef<number | null>(null);
const creationChrome = showContextWindowRing;
const wasRunningByDraftRef = useRef<Record<string, boolean>>({ [draftKey]: running });
const composingRef = useRef(false);
const lastCompositionEndAt = useRef(0);
const pastChatSearchComposingRef = useRef(false);
const pastChatSearchLastCompositionEndAt = useRef(0);
const lastSelectionRef = useRef({ start: 0, end: 0 });
@@ -822,6 +828,18 @@ export function Composer({
const openPastedLabelsRef = useRef(openPastedLabels);
const sessionRefsRef = useRef(sessionRefs);
const selectedTextRefsRef = useRef(selectedTextRefs);
// Plain-textarea IME freeze: while a composition is active the textarea
// renders uncontrolled so no re-render can cancel it (#8593/#8409); the
// hook owns the composition lifecycle, resync, and force-sync semantics.
const { composingRef, lastCompositionEndAt, trackImeInputChange } = useComposerImeGuard({
taRef,
text,
invocationCount: invocations.length,
textRef,
lastSelectionRef,
setText,
setPlainSelection,
});
textRef.current = text;
invocationsRef.current = invocations;
attachmentsRef.current = attachments;
@@ -2927,11 +2945,6 @@ export function Composer({
}, []);
const measureTextareaAutoHeight = useCallback(() => {
if (composerHeight !== null) {
setTextareaAutoHeight(null);
setTextareaAutoOverflow(false);
return;
}
// Creation empty hero starts single-line but must grow so multi-line drafts
// stay readable before send (review: fixed 20px + overflow:hidden clipped).
if (heroMode) {
@@ -2944,7 +2957,7 @@ export function Composer({
const previousHeight = node.style.height;
node.style.height = "auto";
const scrollHeight = node.scrollHeight || 20;
const maxHeight = 96;
const maxHeight = composerHeroInputMaxHeight();
const nextHeight = Math.min(Math.max(scrollHeight, 20), maxHeight);
const nextOverflow = scrollHeight > maxHeight + 1;
node.style.height = previousHeight;
@@ -2958,12 +2971,15 @@ export function Composer({
const previousHeight = node?.style.height;
if (node) node.style.height = "auto";
const scrollHeight = richHeight || node?.scrollHeight || 0;
const maxHeight = composerAutoInputMaxHeight();
const nextHeight = Math.min(scrollHeight, maxHeight);
const nextOverflow = scrollHeight > maxHeight + 1;
const sizing = resolveComposerContentSizing({
contentHeight: scrollHeight,
manualLogicalHeight: composerHeight,
maxLogicalHeight: composerMaxHeight(),
reservedHeight: COMPOSER_AUTO_RESERVED_HEIGHT,
});
if (node && previousHeight !== undefined) node.style.height = previousHeight;
setTextareaAutoHeight((current) => (current === nextHeight ? current : nextHeight));
setTextareaAutoOverflow((current) => (current === nextOverflow ? current : nextOverflow));
setTextareaAutoHeight((current) => (current === sizing.inputHeight ? current : sizing.inputHeight));
setTextareaAutoOverflow((current) => (current === sizing.overflow ? current : sizing.overflow));
}, [composerHeight, heroMode, invocations.length]);
useLayoutEffect(() => {
@@ -2971,7 +2987,6 @@ export function Composer({
}, [text, measureTextareaAutoHeight]);
useEffect(() => {
if (composerHeight !== null) return;
let frame = 0;
const update = () => {
if (frame) window.cancelAnimationFrame(frame);
@@ -3009,7 +3024,7 @@ export function Composer({
e.preventDefault();
const startY = e.clientY;
const startHeight = composerHeight ?? composerLogicalHeight(card);
const startHeight = Math.max(composerHeight ?? COMPOSER_MIN_HEIGHT, composerLogicalHeight(card));
let nextHeight = clampComposerHeight(startHeight);
let moved = false;
card.style.setProperty("--composer-height", `${nextHeight}px`);
@@ -3047,7 +3062,10 @@ export function Composer({
const onComposerResizeKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
const card = composerCardRef.current;
const current = composerHeight ?? (card ? composerLogicalHeight(card) : COMPOSER_MIN_HEIGHT);
const current = Math.max(
composerHeight ?? COMPOSER_MIN_HEIGHT,
card ? composerLogicalHeight(card) : COMPOSER_MIN_HEIGHT,
);
const step = e.shiftKey ? 32 : 16;
let next: number | null = null;
if (e.key === "ArrowUp" || e.key === "PageUp") next = current + step;
@@ -3597,21 +3615,33 @@ export function Composer({
// When the run strip is visible inside a user-resized card, the card grows
// by the strip's reserved height so the meta row stays fully visible.
// --composer-height carries only the user's logical height; the reservation
// is a separate variable consumed by the CSS calc, so the live resize drag
// (which writes raw logical heights) stays consistent with this render path.
// --composer-height stays in logical card-height space. It may be the saved
// manual floor or a larger content-derived height; the run-strip reservation
// remains separate so the live resize writer uses the same coordinate space.
const showRunStrip = Boolean(retry || running);
const composerCardStyle = composerHeight === null
const effectiveComposerHeight = composerHeight === null
? null
: resolveComposerContentSizing({
contentHeight: textareaAutoHeight ?? 0,
manualLogicalHeight: composerHeight,
maxLogicalHeight: composerMaxHeight(),
reservedHeight: COMPOSER_AUTO_RESERVED_HEIGHT,
}).logicalHeight;
const composerCardStyle = effectiveComposerHeight === null
? undefined
: ({
"--composer-height": `${composerHeight}px`,
"--composer-height": `${effectiveComposerHeight}px`,
"--composer-run-strip-reserved": `${showRunStrip ? COMPOSER_RUN_STRIP_RESERVED : 0}px`,
} as CSSProperties);
const textareaStyle = composerHeight === null && textareaAutoHeight !== null
const textareaStyle = !composerResizing && textareaAutoHeight !== null
? ({ height: `${textareaAutoHeight}px`, overflowY: textareaAutoOverflow ? "auto" : "hidden" } as CSSProperties)
: undefined;
const composerAutoExpanded = composerHeight === null && textareaAutoHeight !== null && textareaAutoHeight > 40;
const composerResizeValue = composerHeight ?? clampComposerHeight((textareaAutoHeight ?? 0) + COMPOSER_AUTO_RESERVED_HEIGHT);
// Autosize mode flips overflow-y to auto once content exceeds the max
// height; the card modifier restores a thin scrollbar for exactly that
// state so long drafts expose their scrollability (#8494/#8742/#9019).
const composerAutoOverflow = composerHeight === null && textareaAutoOverflow;
const composerResizeValue = effectiveComposerHeight ?? clampComposerHeight((textareaAutoHeight ?? 0) + COMPOSER_AUTO_RESERVED_HEIGHT);
void onSetMode;
const chooseApprovalMode = (nextMode: ToolApprovalMode) => {
onSetToolApprovalMode(nextMode);
@@ -4399,7 +4429,7 @@ export function Composer({
</div>
)}
<div
className={`composer-card${composerHeight !== null || composerResizing ? " composer-card--resized" : ""}${composerAutoExpanded ? " composer-card--autosized" : ""}${composerResizing ? " composer-card--resizing" : ""}${running ? (waitingPrompt ? " composer-card--waiting" : " composer-card--running") : ""}`}
className={`composer-card${composerHeight !== null || composerResizing ? " composer-card--resized" : ""}${composerAutoExpanded ? " composer-card--autosized" : ""}${composerAutoOverflow ? " composer-card--auto-overflow" : ""}${composerResizing ? " composer-card--resizing" : ""}${running ? (waitingPrompt ? " composer-card--waiting" : " composer-card--running") : ""}`}
ref={composerCardRef}
style={composerCardStyle}
>
@@ -4511,7 +4541,7 @@ export function Composer({
ref={taRef}
className="composer__input"
aria-label={t("composer.placeholder")} spellCheck={false} autoCorrect="off" autoCapitalize="off"
value={text}
value={composingRef.current ? undefined : text}
onInputCapture={(e) => {
pendingNativeInputTypeRef.current = (e.nativeEvent as InputEvent).inputType;
}}
@@ -4520,6 +4550,7 @@ export function Composer({
const inputType = (e.nativeEvent as InputEvent).inputType
|| pendingNativeInputTypeRef.current;
pendingNativeInputTypeRef.current = undefined;
trackImeInputChange(e.nativeEvent as InputEvent, inputType, e.target.value);
resetPromptHistoryNavigation();
textRef.current = e.target.value;
setText(e.target.value);
@@ -4539,13 +4570,6 @@ export function Composer({
onContextMenu={openInputMenu}
onPaste={onPaste}
onKeyDown={onKeyDown}
onCompositionStart={() => {
composingRef.current = true;
}}
onCompositionEnd={() => {
composingRef.current = false;
lastCompositionEndAt.current = Date.now();
}}
style={textareaStyle}
placeholder={composerPlaceholder}
rows={1}
+72 -6
View File
@@ -145,9 +145,13 @@ function isStreamingListItemLine(line: string): boolean {
// Live parse prefix: last completed block. A later list marker commits prior
// items only — the new item stays in the tail so indented continuations can join.
// An open code fence commits only up to the fence line: the tail renders the
// growing code with code styling (splitStreamingTailFence), so streaming a
// large block no longer re-parses the whole document on every commit.
export function streamingCommitTarget(text: string): string {
let lineStart = 0;
let fence: { marker: string; length: number } | null = null;
let fenceStart = 0;
let displayMath = false;
let boundary = 0;
while (lineStart < text.length) {
@@ -167,8 +171,10 @@ export function streamingCommitTarget(text: string): string {
}
} else {
const fenceMatch = /^ {0,3}(`{3,}|~{3,})/.exec(line);
if (fenceMatch) fence = { marker: fenceMatch[1][0], length: fenceMatch[1].length };
else if (line.trim() === "$$") displayMath = true;
if (fenceMatch) {
fence = { marker: fenceMatch[1][0], length: fenceMatch[1].length };
fenceStart = lineStart;
} else if (line.trim() === "$$") displayMath = true;
else if (terminated && line.trim() === "") boundary = lineEnd;
// A heading interrupts a paragraph, so a partial heading line already
// completes everything before it; a terminated one is itself complete.
@@ -177,7 +183,41 @@ export function streamingCommitTarget(text: string): string {
}
lineStart = lineEnd;
}
return fence || displayMath ? text : text.slice(0, boundary);
return fence ? text.slice(0, fenceStart) : displayMath ? text : text.slice(0, boundary);
}
type StreamingTailFence = { head: string; lang: string; code: string };
// Split a streaming tail around an unclosed code fence so the fence body can
// render with code styling before the closing fence arrives. Bail out cheaply
// when no fence marker exists; otherwise mirror the fence state machine from
// streamingCommitTarget in one forward pass over the tail.
export function splitStreamingTailFence(text: string): StreamingTailFence | null {
if (!text.includes("```") && !text.includes("~~~")) return null;
let lineStart = 0;
let fence: { marker: string; length: number } | null = null;
let fenceStart = 0;
let fenceBodyStart = 0;
let lang = "";
while (lineStart < text.length) {
const newline = text.indexOf("\n", lineStart);
const lineEnd = newline === -1 ? text.length : newline + 1;
const line = text.slice(lineStart, newline === -1 ? text.length : newline).replace(/\r$/, "");
if (fence) {
if (new RegExp(`^ {0,3}${fence.marker}{${fence.length},}[ \\t]*$`).test(line)) fence = null;
} else {
const fenceMatch = /^ {0,3}(`{3,}|~{3,})([^\n]*)$/.exec(line);
if (fenceMatch) {
fence = { marker: fenceMatch[1][0], length: fenceMatch[1].length };
fenceStart = lineStart;
fenceBodyStart = lineEnd;
lang = fenceMatch[2].trim();
}
}
lineStart = lineEnd;
}
if (!fence) return null;
return { head: text.slice(0, fenceStart), lang, code: text.slice(fenceBodyStart) };
}
export function useRenderedMarkdownText(text: string, streaming: boolean, holdIdleFinalization = false): string {
@@ -302,9 +342,35 @@ const StreamingMarkdownTail = memo(function StreamingMarkdownTail({ text }: { te
const element = elementRef.current;
if (!element) return;
const previousText = previousTextRef.current;
const textNode = element.firstChild;
if (text.startsWith(previousText) && textNode?.nodeType === Node.TEXT_NODE) {
(textNode as Text).appendData(text.slice(previousText.length));
const previous = splitStreamingTailFence(previousText);
const next = splitStreamingTailFence(text);
// Append-only fast paths keep per-frame updates to one text-node append.
if (next && previous && next.head === previous.head && next.lang === previous.lang && next.code.startsWith(previous.code)) {
const textNode = element.querySelector("code")?.firstChild;
if (textNode?.nodeType === Node.TEXT_NODE) {
(textNode as Text).appendData(next.code.slice(previous.code.length));
previousTextRef.current = text;
return;
}
}
if (!next && !previous && text.startsWith(previousText)) {
const textNode = element.firstChild;
if (element.childNodes.length === 1 && textNode?.nodeType === Node.TEXT_NODE) {
(textNode as Text).appendData(text.slice(previousText.length));
previousTextRef.current = text;
return;
}
}
element.textContent = "";
if (next) {
if (next.head) element.appendChild(document.createTextNode(next.head));
const pre = document.createElement("pre");
pre.className = "code md--stream-tail-code";
if (next.lang) pre.setAttribute("data-lang", next.lang);
const code = document.createElement("code");
code.textContent = next.code;
pre.appendChild(code);
element.appendChild(pre);
} else {
element.textContent = text;
}
@@ -21,6 +21,7 @@ import { getMarkdownWorkerClient } from "../lib/markdownWorkerClient";
import { getTranscriptStore } from "../lib/transcriptStore";
import { createComponents } from "./markdownComponents";
import { VirtualMarkdownSourceTable } from "./MarkdownTable";
import { useTranscriptScrollOffsetWrite } from "./TranscriptLayoutIntentContext";
// A history surface opens at the newest transcript content. Keep the same
// ownership inside a giant Markdown row: mount a small tail, then move a
@@ -180,6 +181,7 @@ export const MarkdownHistory = memo(function MarkdownHistory({
const [blockWindow, moveBlockWindow] = useProgressiveBlockWindow(totalBlocks, blocks);
const rootRef = useRef<HTMLDivElement>(null);
const pendingAnchorRef = useRef<PendingScrollAnchor | null>(null);
const writeTranscriptOffset = useTranscriptScrollOffsetWrite();
const moveFromBoundary = useCallback((direction: "older" | "newer", sentinel: HTMLSpanElement) => {
if (!blocks) return;
if (pendingAnchorRef.current?.identity === blocks) return;
@@ -212,8 +214,11 @@ export const MarkdownHistory = memo(function MarkdownHistory({
const anchor = rootRef.current?.querySelector<HTMLElement>(`[data-markdown-scroll-anchor="${pending.index}"]`);
pendingAnchorRef.current = null;
if (!anchor || !pending.scroller?.isConnected) return;
pending.scroller.scrollTop += anchor.getBoundingClientRect().top - pending.top;
}, [blockWindow.end, blockWindow.start, blocks]);
const delta = anchor.getBoundingClientRect().top - pending.top;
if (delta !== 0 && writeTranscriptOffset) {
writeTranscriptOffset("block-window-prepend", pending.scroller.scrollTop + delta);
}
}, [blockWindow.end, blockWindow.start, blocks, writeTranscriptOffset]);
// JSX per block depends only on the block and the components map; build it
// lazily so viewport-window growth never re-converts settled blocks.
@@ -2,6 +2,7 @@ import { memo, type RefObject, useCallback, useEffect, useId, useLayoutEffect, u
import { createPortal } from "react-dom";
import { AlertCircle, Code2, Maximize2, Minimize2, Play, RotateCcw, ZoomIn, ZoomOut } from "lucide-react";
import { CopyButton } from "./CopyButton";
import { createMermaidPanZoom, type MermaidPanZoomInstance, type MermaidPanZoomOptions } from "./mermaidPanZoom";
import { openExternal } from "../lib/bridge";
import { markdownImageSource } from "../lib/markdownImage";
@@ -25,17 +26,9 @@ type MermaidRenderAdapter = (
signal: AbortSignal,
) => Promise<string>;
type PanZoomInstance = {
destroy(): void;
resize(): unknown;
fit(): unknown;
center(): unknown;
zoomIn(): unknown;
zoomOut(): unknown;
reset(): unknown;
};
type PanZoomInstance = MermaidPanZoomInstance;
type PanZoomFactory = (svg: SVGSVGElement, options?: Record<string, unknown>) => PanZoomInstance;
type PanZoomFactory = (svg: SVGSVGElement, options?: MermaidPanZoomOptions) => PanZoomInstance;
const MAX_TEXT_SIZE = 100000;
const MIN_ZOOM = 0.3;
@@ -46,8 +39,6 @@ const XLINK_NS = "http://www.w3.org/1999/xlink";
let mermaidApi: MermaidApi | null = null;
let initPromise: Promise<MermaidApi> | null = null;
let renderQueue: Promise<void> = Promise.resolve();
let panZoomFactory: PanZoomFactory | null = null;
let panZoomPromise: Promise<PanZoomFactory | null> | null = null;
let renderAdapterForTest: MermaidRenderAdapter | null = null;
let panZoomFactoryForTest: PanZoomFactory | null | undefined;
@@ -210,15 +201,7 @@ async function renderMermaid(
async function ensurePanZoomFactory(): Promise<PanZoomFactory | null> {
if (panZoomFactoryForTest !== undefined) return panZoomFactoryForTest;
if (panZoomFactory) return panZoomFactory;
if (!panZoomPromise) {
panZoomPromise = import("svg-pan-zoom").then((mod) => {
const factory = (("default" in mod ? mod.default : mod) as unknown) as PanZoomFactory;
panZoomFactory = factory;
return factory;
}).catch(() => null);
}
return panZoomPromise;
return createMermaidPanZoom;
}
export function isSafeMermaidHref(href: string | null | undefined): boolean {
@@ -317,7 +300,7 @@ function destroyPanZoom(instance: PanZoomInstance | null): void {
try {
instance.destroy();
} catch {
/* svg-pan-zoom cleanup is best-effort across browser and test DOMs. */
/* pan/zoom listener cleanup is best-effort across browser and test DOMs. */
}
}
@@ -332,9 +315,8 @@ export function safelyRunPanZoom(instance: PanZoomInstance | null, action: () =>
action();
return true;
} catch {
// svg-pan-zoom asks SVGMatrix.inverse() to invert the current transform.
// Hidden/zero-sized layouts can produce a singular matrix in WebKit and
// Chromium; keep that library detail out of the global crash surface.
// Pan/zoom math reads live layout (getBoundingClientRect); a detached or
// zero-sized surface must never reach the global crash surface (#8068).
return false;
}
}
@@ -429,14 +411,6 @@ const MermaidDiagram = memo(function MermaidDiagram({ definition }: MermaidDiagr
destroyPanZoom(panZoomRef.current);
try {
instance = factory(svg, {
zoomEnabled: true,
panEnabled: true,
controlIconsEnabled: false,
dblClickZoomEnabled: true,
// Fitting inside the constructor can run before the portal/layout has
// dimensions, which is the matrix-inverse crash seen in diagnostics.
fit: false,
center: false,
minZoom: MIN_ZOOM,
maxZoom: MAX_ZOOM,
zoomScaleSensitivity: 0.3,
@@ -10,7 +10,7 @@ import { asArray } from "../lib/array";
import { useToast } from "../lib/toast";
import { app } from "../lib/bridge";
import { onProjectTreeChangedV2 } from "../lib/sessionCatalogBridge";
import { isRuntimeSessionNode, isTopicNode, loadWorkbenchOrganizeMode, loadWorkbenchSortMode, mergeIncompleteProjectTopicPage, mergeProjectTopicPage, projectTreeDedupedExactTime, projectTreeEventAffectsFolder, projectTreeFolderDisclosure, projectTreeReadActivityKey, projectTreeRevisionIsFresh, projectTreeShellChildren, projectTreeShellSignature, projectTreeShouldApplyShellSnapshot, projectTreeShouldRenderTopicActions, projectTreeShouldSuppressOpenForRename, projectTreeTopicArchiveBlocked, projectTreeTopicHasUnreadActivity, projectTreeTopicHoverCardModel, projectTreeTopicMenuOffersPin, projectTreeTopicMetaLine, projectTreeTopicOpenRequest, projectTreeTopicPageIsFresh, projectTreeTopicPageSignature, projectTreeWithoutTopic, topicActivityAt, topicActivityDateLabel, topicActivityLabel, topicIsActive, topicStatus, topicStatusLabel, topicUnknownTimeLabel, WORKBENCH_ORGANIZE_KEY, WORKBENCH_SORT_KEY, type ProjectTreePendingTopicOpen, type ProjectTreeReadActivity, type ProjectTreeTopicHoverCard, type ProjectTreeVariant, type WorkbenchOrganizeMode, type WorkbenchSortMode } from "../lib/projectTreeTopic";
import { isRuntimeSessionNode, isTopicNode, loadWorkbenchOrganizeMode, loadWorkbenchSortMode, mergeIncompleteProjectTopicPage, mergeProjectTopicPage, projectTreeDedupedExactTime, projectTreeEventAffectsFolder, projectTreeFolderDisclosure, projectTreeReadActivityKey, projectTreeRevisionIsFresh, projectTreeShellChildren, projectTreeShellSignature, projectTreeShouldApplyShellSnapshot, projectTreeShouldRenderTopicActions, projectTreeShouldSuppressOpenForRename, projectTreeTopicArchiveBlocked, projectTreeTopicHasUnreadActivity, projectTreeTopicHoverCardModel, projectTreeTopicMenuOffersPin, projectTreeTopicMetaLine, projectTreeTopicOpenRequest, projectTreeTopicPageIsFresh, projectTreeTopicPageSignature, projectTreeTopicRecoveryCopyCount, projectTreeWithoutTopic, projectTreeWithTopicTitle, topicActivityAt, topicActivityDateLabel, topicActivityLabel, topicIsActive, topicStatus, topicStatusLabel, topicUnknownTimeLabel, WORKBENCH_ORGANIZE_KEY, WORKBENCH_SORT_KEY, type ProjectTreePendingTopicOpen, type ProjectTreeReadActivity, type ProjectTreeTopicHoverCard, type ProjectTreeVariant, type WorkbenchOrganizeMode, type WorkbenchSortMode } from "../lib/projectTreeTopic";
export * from "../lib/projectTreeTopic";
import { arrangeClassicProjectTree, arrangeWorkbenchTree, classicTopicWindow, CLASSIC_TOPIC_PREVIEW_LIMIT, splitPinnedProjectTree, type PinnedTreeSections } from "../lib/projectTreePresentation";
export * from "../lib/projectTreePresentation";
@@ -257,6 +257,7 @@ export function ProjectTree({
const [catalogStatus, setCatalogStatus] = useState<SessionCatalogStatus>({
state: "opening", revision: 0, indexed: 0, total: 0, repairPending: 0,
});
const rebuildingCatalogRef = useRef(false);
const [topicPageState, setTopicPageState] = useState<Record<string, { nextCursor?: string; loading: boolean }>>({});
const topicPageStateRef = useRef(topicPageState);
const updateTopicPageState = useCallback((key: string, next: { nextCursor?: string; loading: boolean }) => {
@@ -459,12 +460,27 @@ export function ProjectTree({
}
}, [applyRuntimeProjection]);
refreshRef.current = refresh;
const { addingProject, handleAddProject, openBlankProjectFlow, blankProjectFlow } = useProjectCreation({
onAddProject,
onRefresh: refresh,
showToast,
});
const rebuildSessionCatalog = useCallback(async () => {
if (rebuildingCatalogRef.current || catalogStatus.canRebuild === false) return;
rebuildingCatalogRef.current = true;
try {
await app.RebuildSessionCatalog();
showToast(t("projectTree.rebuildCatalog"), "info");
void refresh();
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error", { durationMs: 6000 });
} finally {
rebuildingCatalogRef.current = false;
}
}, [catalogStatus.canRebuild, refresh, showToast, t]);
useEffect(() => {
treeRef.current = tree;
}, [tree]);
@@ -485,7 +501,12 @@ export function ProjectTree({
}, [refresh, refreshSignal]);
useEffect(() => onProjectTreeChangedV2((event) => {
if (!projectTreeRevisionIsFresh(latestRevisionRef.current, event.revision)) return;
// A stale or missed revision means the tree may have drifted from the
// catalog; refetch the full snapshot instead of dropping the event.
if (!projectTreeRevisionIsFresh(latestRevisionRef.current, event.revision)) {
void refresh();
return;
}
latestRevisionRef.current = Math.max(latestRevisionRef.current, event.revision);
if (event.reason === "metadata") setOrganizationRevision((current) => Math.max(current, event.revision));
void app.GetSessionCatalogStatus().then(setCatalogStatus).catch(() => {});
@@ -769,6 +790,8 @@ export function ProjectTree({
try {
if (onRenameTopic) await onRenameTopic(topicId, title);
else await app.RenameTopic(topicId, title);
// Paint the new label immediately; the catalog event round-trip can lag.
setTree((current) => applyRuntimeProjection(projectTreeWithTopicTitle(current, topicId, title)));
await refresh();
if (!onRenameTopic) await onTopicsChanged?.();
} catch (err) {
@@ -1050,7 +1073,9 @@ export function ProjectTree({
const accentStyle = projectAccentStyle(node.projectColor, scope === "global" ? "var(--project-tree-global-accent)" : undefined);
const active = topicIsActive(node, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath);
const label = (node.label || node.topicId || "Untitled").replace(/^●\s*/, "");
// Ordinary list never advertises recovery copies; History owns that view.
// Recovery copies stay folded behind the canonical row; the row only
// advertises how many converged, and History owns the full list (#8525).
const recoveryCopyCount = projectTreeTopicRecoveryCopyCount(node);
const activityAt = node.lastActivityAt || node.createdAt || 0;
// Every variant is a single-line row with the activity time on the right;
// classic moved there too so turns and the exact date live in the hover
@@ -1076,7 +1101,12 @@ export function ProjectTree({
const imSourceLabel = imSource?.label || "";
const imSourceTitle = imSourceLabel ? t("msg.fromIm", { source: imSourceLabel }) : "";
const imSourcePlatform = (imSource?.platform || "im").replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "im";
const title = [node.preview || "", label, imSourceTitle, statusLabel, metaFull, projectTreeDedupedExactTime(metaFull, exactTimeLabel)].filter(Boolean).join(" · ");
const recoveryLabel = node.recoveryState === "recovery_only"
? t("projectTree.recoveryOnly")
: node.recovered
? t("projectTree.recovered")
: "";
const title = [node.preview || "", label, recoveryLabel, imSourceTitle, statusLabel, metaFull, projectTreeDedupedExactTime(metaFull, exactTimeLabel)].filter(Boolean).join(" · ");
const topicMenuOpen = !isSessionNode && menuTopic === topicId;
const pinned = Boolean(node.pinned);
const pinLabel = t(pinned ? "projectTree.unpinTopic" : "projectTree.pinTopic");
@@ -1211,6 +1241,7 @@ export function ProjectTree({
<span className="project-tree__topic-copy">
<span className="project-tree__topic-heading">
<span className="project-tree__topic-label">{label}</span>
{recoveryLabel && <span className="project-tree__topic-recovery" title={recoveryLabel}>{recoveryLabel}</span>}
{imSource && (
<span
className={`project-tree__topic-im project-tree__topic-im--${imSourcePlatform}`}
@@ -1224,6 +1255,14 @@ export function ProjectTree({
{!compactTopics && statusLabel && (!classicTopics || status === "paused" || status === "awaiting_delivery" || status === "error") && (
<span className={`project-tree__topic-status project-tree__topic-status--${status}`}>{statusLabel}</span>
)}
{recoveryCopyCount > 0 && (
<span
className="project-tree__topic-recovery-copies"
title={t("projectTree.recoveryCopies", { count: recoveryCopyCount })}
>
{t("projectTree.recoveryCopies", { count: recoveryCopyCount })}
</span>
)}
</span>
</span>
{sideTimeVisible && (
@@ -1551,6 +1590,23 @@ export function ProjectTree({
const backendPage = topicPageState[key];
const renderFolderChildren = () => {
if (!hasChildren) {
// While the first topic page is still loading (cold start, catalog
// reconcile in flight), show a skeleton instead of a blank folder or
// a premature "no topics" placeholder.
if (backendPage?.loading) {
return (
<div className={`project-tree__children${isExpanded ? " project-tree__children--expanded" : ""}`}>
<div className="project-tree__children-inner">
<div className="project-tree__skeleton" style={{ paddingLeft: 14 + (depth + 1) * 16 }} aria-hidden="true">
<span className="project-tree__skeleton-bar" />
<span className="project-tree__skeleton-bar project-tree__skeleton-bar--short" />
<span className="project-tree__skeleton-bar" />
<span className="project-tree__skeleton-bar project-tree__skeleton-bar--short" />
</div>
</div>
</div>
);
}
if (!classicTopics) return null;
return (
<div className={`project-tree__children${isExpanded ? " project-tree__children--expanded" : ""}`}>
@@ -2092,9 +2148,16 @@ export function ProjectTree({
/>
</label>
)}
{(catalogStatus.state === "opening" || catalogStatus.state === "rebuilding" || catalogStatus.repairPending > 0) && (
{(catalogStatus.state === "opening" || catalogStatus.state === "rebuilding" || catalogStatus.repairPending > 0 || (catalogStatus.unindexedTargetCount ?? 0) > 0 || Boolean(catalogStatus.lastError)) && (
<div className="project-tree__catalog-progress" role="status">
{t("projectTree.indexingProgress", { done: catalogStatus.indexed, total: catalogStatus.total || "?" })}
<span>{catalogStatus.lastError
? t("projectTree.rebuildCatalog")
: t("projectTree.indexingProgress", { done: catalogStatus.indexed, total: catalogStatus.total || "?" })}</span>
{catalogStatus.canRebuild !== false && catalogStatus.state !== "rebuilding" && (
<button type="button" className="project-tree__catalog-rebuild" onClick={() => void rebuildSessionCatalog()}>
{t("projectTree.rebuildCatalog")}
</button>
)}
</div>
)}
{compactTopics ? (
@@ -2107,7 +2170,10 @@ export function ProjectTree({
<>
{pinnedTreeSections.pinned.length > 0 && (
<div className="project-tree__section project-tree__section--pinned">
<div className="project-tree__section-title">{t("projectTree.pinnedTitle")}</div>
<div className="project-tree__section-title project-tree__section-title--pinned">
<Pin size={14} className="project-tree__section-title-icon" aria-hidden="true" />
<span>{t("projectTree.pinnedTitle")}</span>
</div>
{pinnedTreeSections.pinned.map((node) => renderNode(node, 0, "pinned"))}
</div>
)}
@@ -20,6 +20,9 @@ export function PromptShelf({
role = "dialog",
decision = false,
actionsRole = "listbox",
cardCollapsible = false,
collapsed = false,
onToggleCollapse,
}: {
className?: string;
cardClassName?: string;
@@ -45,6 +48,11 @@ export function PromptShelf({
// Select-then-confirm surfaces use listbox. Immediate actions are a group of
// buttons so assistive technology does not announce them as pending choices.
actionsRole?: "listbox" | "group";
// Whole-card click-to-collapse (used by the todo shelf): clicking anywhere
// on the card toggles collapsed; interactive children stop propagation.
cardCollapsible?: boolean;
collapsed?: boolean;
onToggleCollapse?: () => void;
}) {
return (
<div
@@ -59,22 +67,40 @@ export function PromptShelf({
>
<div
ref={barRef}
className={["prompt-shelf__card", cardClassName ?? ""].filter(Boolean).join(" ")}
className={[
"prompt-shelf__card",
cardCollapsible ? "prompt-shelf__card--collapsible" : "",
cardCollapsible && collapsed ? "prompt-shelf__card--collapsed" : "",
cardClassName ?? "",
]
.filter(Boolean)
.join(" ")}
role={role}
aria-modal={role === "dialog" ? "false" : undefined}
aria-labelledby={titleId}
tabIndex={-1}
tabIndex={cardCollapsible ? 0 : -1}
onClick={cardCollapsible ? onToggleCollapse : undefined}
onKeyDown={cardCollapsible && onToggleCollapse ? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onToggleCollapse();
}
} : undefined}
>
<div className="prompt-shelf__content">
<div className="prompt-shelf__header">
<div className="prompt-shelf__copy">
<div id={titleId} className="prompt-shelf__title">
<span className="prompt-shelf__heading">{title}</span>
{meta && <span className="prompt-shelf__meta">{meta}</span>}
{badges && <span className="prompt-shelf__badges">{badges}</span>}
</div>
{meta && <div className="prompt-shelf__meta">{meta}</div>}
</div>
{headerActions && <div className="prompt-shelf__header-actions">{headerActions}</div>}
{headerActions && (
<div className="prompt-shelf__header-actions" onClick={(event) => event.stopPropagation()}>
{headerActions}
</div>
)}
</div>
{crumbs}
{children && <div className="prompt-shelf__body">{children}</div>}
@@ -1,86 +1,146 @@
// QuestionJumpBar: the question navigator rail along the transcript edge.
import { useEffect, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from "react";
import { useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from "react";
import { useT } from "../lib/i18n";
import type { QuestionAnchor } from "../lib/transcriptGrouping";
export function QuestionJumpBar({ questions, onJump }: { questions: QuestionAnchor[]; onJump: (question: QuestionAnchor) => void }) {
export const QUESTION_JUMP_MAX_MARKERS = 120;
export function sampledQuestionTurns(totalQuestions: number, activeTurn: number | null, limit = QUESTION_JUMP_MAX_MARKERS): number[] {
const total = Math.max(0, Math.floor(totalQuestions));
const maxMarkers = Math.max(2, Math.floor(limit));
if (total <= maxMarkers) return Array.from({ length: total }, (_, turn) => turn);
const turns = Array.from({ length: maxMarkers }, (_, index) => (
Math.round(index * (total - 1) / (maxMarkers - 1))
));
if (maxMarkers <= 2 || activeTurn == null || activeTurn <= 0 || activeTurn >= total - 1 || turns.includes(activeTurn)) return turns;
let replaceIndex = 1;
let closestDistance = Number.POSITIVE_INFINITY;
for (let index = 1; index < turns.length - 1; index += 1) {
const distance = Math.abs(turns[index] - activeTurn);
if (distance < closestDistance) {
closestDistance = distance;
replaceIndex = index;
}
}
turns[replaceIndex] = activeTurn;
turns.sort((left, right) => left - right);
return turns;
}
export function questionTurnFromRailY(clientY: number, top: number, height: number, totalQuestions: number): number | null {
const total = Math.max(0, Math.floor(totalQuestions));
if (total === 0 || !Number.isFinite(height) || height <= 0) return null;
const ratio = Math.max(0, Math.min(1, (clientY - top) / height));
return Math.min(total - 1, Math.floor(ratio * total));
}
export function QuestionJumpBar({
loadedQuestions,
totalQuestions,
activeTurn,
onJump,
}: {
loadedQuestions: QuestionAnchor[];
totalQuestions: number;
activeTurn: number | null;
onJump: (question: QuestionAnchor) => void;
}) {
const t = useT();
const total = Math.max(0, Math.floor(totalQuestions));
const [hovered, setHovered] = useState<number | null>(null);
const [active, setActive] = useState<number | null>(null);
const barRef = useRef<HTMLDivElement>(null);
const barRef = useRef<HTMLElement>(null);
const railRef = useRef<HTMLDivElement>(null);
const previewTop = useRef(0);
const [showPreview, setShowPreview] = useState(false);
useEffect(() => {
if (questions.length === 0) return;
setActive(questions[questions.length - 1]?.turn ?? null);
}, [questions]);
const loadedByTurn = useMemo(() => {
const loaded = new Map<number, QuestionAnchor>();
for (const question of loadedQuestions) loaded.set(question.turn, question);
return loaded;
}, [loadedQuestions]);
const active = activeTurn != null && activeTurn >= 0 && activeTurn < total
? activeTurn
: total > 0 ? total - 1 : null;
useEffect(() => {
if (active === null) return;
const el = barRef.current?.querySelector(`[data-turn="${active}"]`);
el?.scrollIntoView({ block: "nearest" });
}, [active]);
const markerTurns = useMemo(() => sampledQuestionTurns(total, active), [active, total]);
const hoverIdx = hovered === null
? -1
: markerTurns.reduce((closest, turn, index) => (
closest < 0 || Math.abs(turn - hovered) < Math.abs(markerTurns[closest] - hovered) ? index : closest
), -1);
const hoverIdx = hovered !== null ? questions.findIndex((question) => question.turn === hovered) : -1;
const hoveredQuestion = hovered !== null ? questions.find((question) => question.turn === hovered) : undefined;
const closestQuestionFromY = (clientY: number): { question: QuestionAnchor; previewY: number } | null => {
const el = barRef.current;
if (!el) return null;
const markers = el.querySelectorAll<HTMLElement>(".jump-item");
const barRect = el.getBoundingClientRect();
let closest = -1;
let closestDist = Infinity;
let closestY = 0;
markers.forEach((item, index) => {
const rect = item.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const dist = Math.abs(clientY - midY);
if (dist < closestDist) {
closestDist = dist;
closest = index;
closestY = midY - barRect.top;
}
});
const question = questions[closest];
if (!question) return null;
return { question, previewY: closestY };
const questionAt = (turn: number): QuestionAnchor => loadedByTurn.get(turn) ?? {
id: `history-question-${turn + 1}`,
text: t("questionNav.notLoaded", { n: turn + 1 }),
turn,
loaded: false,
};
const hoveredQuestion = hovered === null ? undefined : questionAt(hovered);
const onMove = (e: ReactMouseEvent<HTMLDivElement>) => {
const closest = closestQuestionFromY(e.clientY);
if (!closest) return;
previewTop.current = closest.previewY;
setHovered(closest.question.turn);
setShowPreview(true);
const questionFromY = (clientY: number): { question: QuestionAnchor; previewY: number } | null => {
const rail = railRef.current;
const bar = barRef.current;
if (!rail || !bar) return null;
const railRect = rail.getBoundingClientRect();
const turn = questionTurnFromRailY(clientY, railRect.top, railRect.height, total);
if (turn === null) return null;
const barRect = bar.getBoundingClientRect();
const turnCenter = railRect.top - barRect.top + ((turn + 0.5) / total) * railRect.height;
return {
question: questionAt(turn),
previewY: Math.max(0, Math.min(barRect.height, turnCenter)),
};
};
const scrollTo = (question: QuestionAnchor) => {
setActive(question.turn);
onJump(question);
};
const onRailMouseDown = (e: ReactMouseEvent<HTMLDivElement>) => {
const closest = closestQuestionFromY(e.clientY);
if (!closest) return;
e.preventDefault();
previewTop.current = closest.previewY;
setHovered(closest.question.turn);
const onMove = (event: ReactMouseEvent<HTMLDivElement>) => {
const target = questionFromY(event.clientY);
if (!target) return;
previewTop.current = target.previewY;
setHovered(target.question.turn);
setShowPreview(true);
scrollTo(closest.question);
};
const onItemMouseDown = (e: ReactMouseEvent<HTMLButtonElement>, question: QuestionAnchor) => {
e.preventDefault();
scrollTo(question);
const onRailMouseDown = (event: ReactMouseEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
const target = questionFromY(event.clientY);
if (!target) return;
event.preventDefault();
previewTop.current = target.previewY;
setHovered(target.question.turn);
setShowPreview(true);
scrollTo(target.question);
};
const dotProps = (
idx: number,
turn: number,
): { style: CSSProperties; "data-d"?: string } => {
const onRailKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (total === 0) return;
const current = active ?? Math.max(0, total - 1);
const page = Math.max(1, Math.round(total / 10));
let next: number | null = null;
switch (event.key) {
case "ArrowUp":
case "ArrowLeft": next = current - 1; break;
case "ArrowDown":
case "ArrowRight": next = current + 1; break;
case "PageUp": next = current - page; break;
case "PageDown": next = current + page; break;
case "Home": next = 0; break;
case "End": next = total - 1; break;
case "Enter":
case " ": next = current; break;
default: return;
}
event.preventDefault();
scrollTo(questionAt(Math.max(0, Math.min(total - 1, next))));
};
const dotProps = (idx: number, turn: number): { style: CSSProperties; "data-d"?: string } => {
const isActive = active === turn;
if (hoverIdx < 0) {
return { style: { width: isActive ? 18 : 12, background: isActive ? "var(--accent)" : undefined } };
@@ -89,40 +149,51 @@ export function QuestionJumpBar({ questions, onJump }: { questions: QuestionAnch
const width = d === 0 ? 32 : d === 1 ? 20 : d === 2 ? 14 : isActive ? 18 : 12;
const background = d <= 2 ? undefined : isActive ? "var(--accent)" : undefined;
return {
style: { width, transitionDelay: `${d * 20}ms`, background },
style: { width, transitionDelay: `${Math.min(d, 3) * 20}ms`, background },
"data-d": d <= 2 ? String(d) : undefined,
};
};
const density = markerTurns.length > 80 ? "packed" : markerTurns.length > 40 ? "compact" : "normal";
const activeValue = active ?? Math.max(0, total - 1);
return (
<nav
className="jump-bar"
ref={barRef}
aria-label={t("questionNav.label")}
onMouseMove={onMove}
onMouseLeave={() => {
setHovered(null);
setShowPreview(false);
}}
>
{/* Pointer activation is owned by mousedown. Handling the following click
as well can issue a second jump after the active marker has moved. */}
<div className="jump-scroll" onMouseDown={onRailMouseDown}>
{questions.map((question, index) => (
<button
<div
className="jump-scroll"
ref={railRef}
role="slider"
tabIndex={0}
aria-label={t("questionNav.label")}
aria-orientation="vertical"
aria-valuemin={1}
aria-valuemax={Math.max(1, total)}
aria-valuenow={activeValue + 1}
aria-valuetext={t("questionNav.progress", { current: activeValue + 1, total })}
data-density={density}
onMouseMove={onMove}
onMouseDown={onRailMouseDown}
onKeyDown={onRailKeyDown}
>
{markerTurns.map((turn, index) => (
<span
className="jump-item"
key={question.id}
type="button"
data-turn={question.turn}
aria-label={t("questionNav.jump", { n: question.turn + 1 })}
onMouseDown={(e) => onItemMouseDown(e, question)}
onClick={(e) => {
e.stopPropagation();
if (e.detail === 0) scrollTo(question);
}}
key={turn}
data-turn={turn}
data-loaded={loadedByTurn.has(turn) ? "true" : "false"}
aria-hidden="true"
style={{ top: `${((turn + 0.5) / total) * 100}%` }}
>
<span className="jump-dot" {...dotProps(index, question.turn)} />
</button>
<span className="jump-dot" {...dotProps(index, turn)} />
</span>
))}
</div>
{showPreview && hoveredQuestion && (
@@ -133,3 +204,5 @@ export function QuestionJumpBar({ questions, onJump }: { questions: QuestionAnch
</nav>
);
}
export default QuestionJumpBar;
@@ -0,0 +1,108 @@
.scroll-diagnostics {
--wails-draggable: no-drag;
position: absolute;
left: 14px;
bottom: 14px;
z-index: var(--z-inline-sticky);
display: flex;
align-items: center;
gap: 5px;
min-height: 32px;
padding: 4px;
border: 1px solid var(--border);
border-radius: 7px;
background: color-mix(in srgb, var(--bg-elev) 94%, transparent);
box-shadow: var(--shadow-2);
color: var(--fg-dim);
font-size: var(--font-caption);
pointer-events: auto;
backdrop-filter: blur(10px);
}
.scroll-diagnostics button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-height: 26px;
padding: 0 8px;
border: 0;
border-radius: 5px;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.scroll-diagnostics button:hover {
background: var(--bg-hover);
color: var(--fg);
}
.scroll-diagnostics button:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.scroll-diagnostics button:disabled {
opacity: 0.55;
cursor: wait;
}
.scroll-diagnostics__start,
.scroll-diagnostics__export {
white-space: nowrap;
}
.scroll-diagnostics__export {
background: var(--accent-soft) !important;
color: var(--accent) !important;
}
.scroll-diagnostics__icon-button {
width: 28px;
padding: 0 !important;
}
.scroll-diagnostics__status {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 200px;
padding: 0 6px;
white-space: nowrap;
}
.scroll-diagnostics__dot {
width: 7px;
height: 7px;
flex: 0 0 7px;
border-radius: 50%;
background: var(--danger);
}
.scroll-diagnostics--stopped .scroll-diagnostics__dot {
background: var(--success);
}
@media (max-width: 620px) {
.scroll-diagnostics {
left: 8px;
bottom: 8px;
}
.scroll-diagnostics__status {
max-width: 132px;
overflow: hidden;
text-overflow: ellipsis;
}
.scroll-diagnostics__export span {
display: none;
}
.scroll-diagnostics__export {
width: 28px;
padding: 0 !important;
}
}
@@ -0,0 +1,212 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Activity, CircleStop, Download, Flag, X } from "lucide-react";
import { app } from "../lib/bridge";
import { useI18n, type Locale } from "../lib/i18n";
import { useToast } from "../lib/toast";
import "./ScrollDiagnosticPanel.css";
import {
isTranscriptScrollDiagnosticsBuild,
transcriptScrollDiagnostics,
type TranscriptScrollDiagnosticSnapshot,
} from "../lib/transcriptScrollDiagnostics";
const diagnosticsAvailable = isTranscriptScrollDiagnosticsBuild(
typeof __BUILD_CHANNEL__ === "string" ? __BUILD_CHANNEL__ : "stable",
import.meta.env.DEV,
);
type DiagnosticCopy = {
start: string;
startHint: string;
recording: (seconds: number, events: number) => string;
ready: (events: number) => string;
mark: string;
markHint: string;
stopExport: string;
exportLabel: string;
exported: string;
exportFailed: (error: string) => string;
};
// Keep diagnostic-only copy in this lazy chunk so stable builds do not carry
// strings for a panel that is compiled out of the product surface.
const COPY: Record<Locale, DiagnosticCopy> = {
en: {
start: "Scroll diagnostics",
startHint: "Start a local 90-second scroll trace",
recording: (seconds, events) => `Recording - ${seconds}s - ${events} events`,
ready: (events) => `Trace ready - ${events} events`,
mark: "Mark issue",
markHint: "Mark the moment the issue appears",
stopExport: "Stop & export",
exportLabel: "Export",
exported: "Scroll diagnostics exported",
exportFailed: (error) => `Could not export diagnostics: ${error}`,
},
zh: {
start: "滚动诊断",
startHint: "开始一段仅保存在本地的 90 秒滚动记录",
recording: (seconds, events) => `记录中 - ${seconds} 秒 - ${events} 条事件`,
ready: (events) => `记录已停止 - ${events} 条事件`,
mark: "标记问题",
markHint: "问题出现时点击标记",
stopExport: "停止并导出",
exportLabel: "导出",
exported: "滚动诊断文件已导出",
exportFailed: (error) => `无法导出诊断文件:${error}`,
},
"zh-TW": {
start: "捲動診斷",
startHint: "開始一段僅儲存在本機的 90 秒捲動記錄",
recording: (seconds, events) => `記錄中 - ${seconds} 秒 - ${events} 筆事件`,
ready: (events) => `記錄已停止 - ${events} 筆事件`,
mark: "標記問題",
markHint: "問題出現時按一下標記",
stopExport: "停止並匯出",
exportLabel: "匯出",
exported: "捲動診斷檔案已匯出",
exportFailed: (error) => `無法匯出診斷檔案:${error}`,
},
};
function diagnosticSample(element: HTMLElement | null, totalRows: number): Record<string, unknown> | null {
if (!element) return null;
const viewport = element.getBoundingClientRect();
let firstVisibleIndex: number | undefined;
let firstVisibleTop: number | undefined;
const mountedRows = element.querySelectorAll<HTMLElement>(".transcript__row[data-logical-index]");
for (const row of mountedRows) {
const rect = row.getBoundingClientRect();
if (rect.bottom <= viewport.top || rect.top >= viewport.bottom) continue;
const index = Number(row.dataset.logicalIndex);
if (Number.isFinite(index)) firstVisibleIndex = index;
firstVisibleTop = rect.top - viewport.top;
break;
}
const bottomDistance = element.scrollHeight - element.scrollTop - element.clientHeight;
return {
scrollTop: element.scrollTop,
scrollHeight: element.scrollHeight,
clientHeight: element.clientHeight,
bottomDistance,
mountedRows: mountedRows.length,
totalRows,
firstVisibleIndex,
firstVisibleTop,
mode: element.dataset.scrollMode,
atBottom: bottomDistance <= 4,
scrollable: element.scrollHeight - element.clientHeight > 4,
};
}
export default function ScrollDiagnosticPanel({
scrollElement,
totalRows,
}: {
scrollElement: HTMLElement | null;
totalRows: number;
}) {
const { locale, t } = useI18n();
const copy = COPY[locale];
const { showToast } = useToast();
const scrollElementRef = useRef(scrollElement);
const totalRowsRef = useRef(totalRows);
scrollElementRef.current = scrollElement;
totalRowsRef.current = totalRows;
const [snapshot, setSnapshot] = useState<TranscriptScrollDiagnosticSnapshot>(() => transcriptScrollDiagnostics.getSnapshot());
const [exporting, setExporting] = useState(false);
const refresh = useCallback(() => setSnapshot(transcriptScrollDiagnostics.getSnapshot()), []);
useEffect(() => transcriptScrollDiagnostics.subscribe(refresh), [refresh]);
useEffect(() => {
if (snapshot.status !== "recording") return;
const timer = window.setInterval(refresh, 500);
return () => window.clearInterval(timer);
}, [refresh, snapshot.status]);
const start = useCallback(() => {
transcriptScrollDiagnostics.start(() => diagnosticSample(scrollElementRef.current, totalRowsRef.current));
}, []);
const exportTrace = useCallback(async () => {
setExporting(true);
try {
const payload = transcriptScrollDiagnostics.stop();
const serialized = JSON.stringify(payload);
const path = app.ExportScrollDiagnostics
? await app.ExportScrollDiagnostics(serialized)
: await app.SaveExportFile("reasonix-scroll-diagnostics-browser.json", serialized, false)
.then(() => "reasonix-scroll-diagnostics-browser.json");
if (path) {
showToast(copy.exported, "info");
transcriptScrollDiagnostics.reset();
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
showToast(copy.exportFailed(detail), "error");
} finally {
setExporting(false);
}
}, [copy, showToast]);
if (!diagnosticsAvailable) return null;
if (snapshot.status === "idle") {
return (
<div className="scroll-diagnostics" data-testid="scroll-diagnostics">
<button
type="button"
className="scroll-diagnostics__start"
onClick={start}
title={copy.startHint}
>
<Activity size={15} aria-hidden="true" />
<span>{copy.start}</span>
</button>
</div>
);
}
const elapsedSeconds = Math.min(90, Math.floor(snapshot.durationMs / 1_000));
const stopped = snapshot.status === "stopped";
return (
<div className={`scroll-diagnostics scroll-diagnostics--${snapshot.status}`} data-testid="scroll-diagnostics">
<span className="scroll-diagnostics__status" role="status">
<span className="scroll-diagnostics__dot" aria-hidden="true" />
{stopped
? copy.ready(snapshot.eventCount)
: copy.recording(elapsedSeconds, snapshot.eventCount)}
</span>
{!stopped && (
<button
type="button"
className="scroll-diagnostics__icon-button"
onClick={() => transcriptScrollDiagnostics.mark()}
aria-label={copy.mark}
title={copy.markHint}
>
<Flag size={15} aria-hidden="true" />
</button>
)}
<button
type="button"
className="scroll-diagnostics__export"
onClick={() => void exportTrace()}
disabled={exporting}
title={stopped ? copy.exportLabel : copy.stopExport}
>
{stopped ? <Download size={15} aria-hidden="true" /> : <CircleStop size={15} aria-hidden="true" />}
<span>{stopped ? copy.exportLabel : copy.stopExport}</span>
</button>
<button
type="button"
className="scroll-diagnostics__icon-button"
onClick={() => transcriptScrollDiagnostics.reset()}
aria-label={t("common.cancel")}
title={t("common.cancel")}
>
<X size={15} aria-hidden="true" />
</button>
</div>
);
}
+459 -131
View File
@@ -1,6 +1,8 @@
import { lazy, memo, Suspense, startTransition, useCallback, useDeferredValue, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
import { ArrowRight, Bot as BotIcon, BrainCircuit, Check, CheckCircle2, ChevronDown, ChevronUp, CircleDollarSign, Clipboard, ExternalLink, KeyRound, Languages, ListChecks, Loader2, MessageCircle, Monitor, MoreHorizontal, PanelBottom, Play, Power, QrCode, RefreshCw, Send, ShieldCheck, SlidersHorizontal, Trash2, Volume2 } from "lucide-react";
import { ArrowRight, BrainCircuit, Check, CheckCircle2, ChevronDown, ChevronUp, CircleDollarSign, Clipboard, ExternalLink, KeyRound, Languages, ListChecks, Loader2, Monitor, MoreHorizontal, PanelBottom, Play, Power, QrCode, RefreshCw, Send, ShieldCheck, SlidersHorizontal, Trash2, Volume2 } from "lucide-react";
import { asArray } from "../lib/array";
import { CHANNEL_ICONS } from "./channelIcons";
import { botAccessEntryCount, botAccessReady, botConnectionCredentialSummary, botConnectionLabel, botConnectionScopeLabel, botConnectionSecretEnv, botConnectionSecretPatch, botInstallTargetForConnection, botInstallTargetMatchesConnection, botTargetHint, botTargetLabel, diagnosticMessage, diagnosticReportDetail, firstConnectionRemote, formatInstallTimeLeft, formatInstallUserCode, qqBotAdded, type BotInstallTarget, type BotOfficialInstallTarget } from "./botConnectionSettings";
import { useDeferredClose } from "../lib/useMountTransition";
import { app, openExternal } from "../lib/bridge";
import { normalizeLangPref, useI18n, useT, type DictKey, type LangPref } from "../lib/i18n";
@@ -1113,6 +1115,7 @@ function defaultBotSettings(): BotSettingsView {
qq: [],
feishu: [],
weixin: [],
dingtalk: [],
},
control: {
enabled: false,
@@ -1140,6 +1143,10 @@ function defaultBotSettings(): BotSettingsView {
qqGroups: [],
feishuGroups: [],
weixinGroups: [],
dingtalkUsers: [],
dingtalkApprovers: [],
dingtalkAdmins: [],
dingtalkGroups: [],
},
qq: { enabled: false, appId: "", appSecretEnv: "QQ_BOT_APP_SECRET", secretSet: false, sandbox: false, model: "", toolApprovalMode: "ask", workspaceRoot: "", access: defaultBotAccess() },
feishu: {
@@ -1160,6 +1167,18 @@ function defaultBotSettings(): BotSettingsView {
tokenSet: false,
apiBase: "https://ilinkai.weixin.qq.com",
},
dingtalk: {
enabled: false,
clientId: "",
clientSecretEnv: "DINGTALK_CLIENT_SECRET",
secretSet: false,
botName: "",
requireMention: true,
model: "",
toolApprovalMode: "",
workspaceRoot: "",
access: defaultBotAccess(),
},
connections: [],
};
}
@@ -1210,6 +1229,7 @@ function normalizeBotSettings(bot: BotSettingsView | null | undefined): BotSetti
qq: asArray(selfUserIds.qq),
feishu: asArray(selfUserIds.feishu),
weixin: asArray(selfUserIds.weixin),
dingtalk: asArray(selfUserIds.dingtalk),
},
control: {
enabled: Boolean(control.enabled),
@@ -1237,6 +1257,10 @@ function normalizeBotSettings(bot: BotSettingsView | null | undefined): BotSetti
qqGroups: asArray(allowlist.qqGroups),
feishuGroups: asArray(allowlist.feishuGroups),
weixinGroups: asArray(allowlist.weixinGroups),
dingtalkUsers: asArray(allowlist.dingtalkUsers),
dingtalkApprovers: asArray(allowlist.dingtalkApprovers),
dingtalkAdmins: asArray(allowlist.dingtalkAdmins),
dingtalkGroups: asArray(allowlist.dingtalkGroups),
},
qq: {
...fallback.qq,
@@ -1248,6 +1272,12 @@ function normalizeBotSettings(bot: BotSettingsView | null | undefined): BotSetti
},
feishu: { ...fallback.feishu, ...bot?.feishu, domain: bot?.feishu?.domain === "lark" ? "lark" : "feishu", mode },
weixin: { ...fallback.weixin, ...bot?.weixin },
dingtalk: {
...fallback.dingtalk,
...bot?.dingtalk,
toolApprovalMode: normalizeBotToolApprovalMode(bot?.dingtalk?.toolApprovalMode),
access: normalizeBotAccess(bot?.dingtalk?.access, fallback.dingtalk.access),
},
connections: asArray(bot?.connections).map(normalizeBotConnection),
};
}
@@ -2067,8 +2097,6 @@ function NetworkSection({ s, busy, apply }: SectionProps) {
);
}
type BotInstallTarget = "qq" | "feishu" | "lark" | "weixin";
type BotOfficialInstallTarget = Exclude<BotInstallTarget, "qq">;
const BOT_ALLOWLIST_TEXT_KEYS = [
"qqUsers",
"feishuUsers",
@@ -2082,6 +2110,10 @@ const BOT_ALLOWLIST_TEXT_KEYS = [
"qqGroups",
"feishuGroups",
"weixinGroups",
"dingtalkUsers",
"dingtalkApprovers",
"dingtalkAdmins",
"dingtalkGroups",
] as const;
type BotAllowlistTextKey = typeof BOT_ALLOWLIST_TEXT_KEYS[number];
type BotSelfUserTextKey = keyof BotSettingsView["selfUserIds"];
@@ -2092,12 +2124,13 @@ type BotInstallState = {
timeLeft: number;
message: string;
};
const BOT_INSTALL_TARGETS: BotInstallTarget[] = ["qq", "feishu", "lark", "weixin"];
const BOT_INSTALL_TARGETS: BotInstallTarget[] = ["qq", "feishu", "lark", "weixin", "dingtalk"];
const BOT_INSTALL_DEFAULT_TIMEOUT_SECONDS = 300;
const BOT_INSTALL_MIN_POLL_SECONDS = 3;
const DEFAULT_QQ_SECRET_ENV = "QQ_BOT_APP_SECRET";
const QQ_CONNECTION_ID = "__qq_bot__";
const BOT_PLATFORM_KEYS = ["qq", "feishu", "weixin"] as const;
const DINGTALK_CONNECTION_ID = "__dingtalk_bot__";
const BOT_PLATFORM_KEYS = ["qq", "feishu", "weixin", "dingtalk"] as const;
type BotPlatformKey = typeof BOT_PLATFORM_KEYS[number];
const BOT_ALLOWLIST_ROLES = ["Users", "Groups", "Approvers", "Admins"] as const;
type BotAllowlistRole = typeof BOT_ALLOWLIST_ROLES[number];
@@ -2110,12 +2143,14 @@ function botAllowlistKey(platform: BotPlatformKey, role: BotAllowlistRole): BotA
function botConnectionPlatform(connection: BotConnectionView): BotPlatformKey {
if (connection.provider === "weixin") return "weixin";
if (connection.provider === "qq") return "qq";
if (connection.provider === "dingtalk") return "dingtalk";
return "feishu";
}
function botPlatformLabel(platform: BotPlatformKey, t: ReturnType<typeof useT>): string {
if (platform === "qq") return "QQ";
if (platform === "weixin") return t("settings.botWeixin");
if (platform === "dingtalk") return t("settings.botDingtalk");
return t("settings.botPlatformFeishuLark");
}
@@ -2139,6 +2174,9 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
const [connectionSecrets, setConnectionSecrets] = useState<Record<string, string>>({});
const [accessText, setAccessText] = useState<Record<string, string>>({});
const [qqSecretValue, setQQSecretValue] = useState("");
const [dingtalkSecretValue, setDingtalkSecretValue] = useState("");
const [dingtalkTesting, setDingtalkTesting] = useState(false);
const [runtimePlatforms, setRuntimePlatforms] = useState<Record<string, string>>({});
const [expandedConnectionId, setExpandedConnectionId] = useState("");
const [advancedMode, setAdvancedMode] = useState(false);
const installRef = useRef(install);
@@ -2160,6 +2198,24 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
setQQSecretValue("");
setTestTargets({});
}, [s.bot]);
// 轮询 bot runtime 的真实平台连接状态,用于 channel 在线徽章(如钉钉黄/绿点)。
useEffect(() => {
let cancelled = false;
const refresh = () => {
if (typeof app.BotRuntimeStatus !== "function") return;
void app.BotRuntimeStatus()
.then((status) => {
if (!cancelled) setRuntimePlatforms(status.platforms ?? {});
})
.catch(() => {});
};
refresh();
const id = window.setInterval(refresh, 5000);
return () => {
cancelled = true;
window.clearInterval(id);
};
}, []);
const focusAccessStep = () => {
if (!expandedConnectionId && connectionItems.length > 0) {
const first = connectionItems[0];
@@ -2262,6 +2318,14 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
updateQQ({ access: normalizeBotAccess({ ...draft.qq.access, ...patch }) });
const persistQQAccess = (patch: Partial<BotAccessView>) =>
persistQQ({ access: normalizeBotAccess({ ...draft.qq.access, ...patch }) });
const updateDingtalk = (patch: Partial<BotSettingsView["dingtalk"]>) =>
setDraft((prev) => ({ ...prev, dingtalk: { ...prev.dingtalk, ...patch } }));
const persistDingtalk = (patch: Partial<BotSettingsView["dingtalk"]>) =>
persistBotDraft({ ...draft, dingtalk: { ...draft.dingtalk, ...patch } });
const updateDingtalkAccess = (patch: Partial<BotAccessView>) =>
updateDingtalk({ access: normalizeBotAccess({ ...draft.dingtalk.access, ...patch }) });
const persistDingtalkAccess = (patch: Partial<BotAccessView>) =>
persistDingtalk({ access: normalizeBotAccess({ ...draft.dingtalk.access, ...patch }) });
const updateConnectionAccess = (id: string, patch: Partial<BotAccessView>) =>
setConnections((items) => items.map((item) => item.id === id ? { ...item, access: normalizeBotAccess({ ...item.access, ...patch }) } : item));
const persistConnectionAccess = (connection: BotConnectionView, patch: Partial<BotAccessView>) =>
@@ -2294,6 +2358,7 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
const installQrURL = install.result?.url ?? "";
const installQrIsImage = installQrURL.startsWith("data:image/");
const isQQInstallTarget = installTarget === "qq";
const isDingtalkInstallTarget = installTarget === "dingtalk";
const selectedInstallLabel = botTargetLabel(installTarget, t);
const installUserCode = install.result?.userCode && installTarget !== "weixin" ? formatInstallUserCode(install.result.userCode) : "";
const qqSecretEnv = draft.qq.appSecretEnv.trim() || DEFAULT_QQ_SECRET_ENV;
@@ -2304,12 +2369,17 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
const nativeRuntimeAvailable = typeof window !== "undefined" && Boolean(window.runtime);
const browserPreviewBotConfigured = !nativeRuntimeAvailable && (qqAdded || draft.connections.length > 0);
const qqOnline = qqConfigured && nativeRuntimeAvailable;
const dingtalkSecretEnv = draft.dingtalk.clientSecretEnv.trim() || "DINGTALK_CLIENT_SECRET";
const dingtalkConfigured = Boolean(draft.dingtalk.enabled && draft.dingtalk.clientId.trim() && dingtalkSecretEnv && draft.dingtalk.secretSet);
// 保存按钮仅在用户输入了新的密钥后才可点;已保存过密钥但没有新输入时置灰。
const dingtalkCanSaveAndEnable = Boolean(draft.dingtalk.clientId.trim() && dingtalkSecretEnv && dingtalkSecretValue.trim());
const dingtalkOnline = runtimePlatforms["dingtalk"] === "running" || runtimePlatforms["dingtalk"] === "degraded";
const connectionItems: BotConnectionListItem[] = [
...(qqAdded ? [{ kind: "qq" as const }] : []),
...draft.connections.map((connection) => ({ kind: "connection" as const, connection })),
];
const selectedInstallConnection = isQQInstallTarget ? undefined : draft.connections.find((connection) => botInstallTargetMatchesConnection(installTarget, connection));
const selectedChannelConfigured = isQQInstallTarget ? qqAdded : Boolean(selectedInstallConnection);
const selectedInstallConnection = isQQInstallTarget || isDingtalkInstallTarget ? undefined : draft.connections.find((connection) => botInstallTargetMatchesConnection(installTarget, connection));
const selectedChannelConfigured = isQQInstallTarget ? qqAdded : isDingtalkInstallTarget ? dingtalkConfigured : Boolean(selectedInstallConnection);
const routeConnectionOptions = [
...(qqAdded ? [{ id: "qq", label: "QQ" }] : []),
...draft.connections.map((connection) => ({
@@ -2439,6 +2509,15 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
}));
}
};
const testDingtalkBot = async () => {
setDingtalkTesting(true);
try {
const diag = await app.TestDingtalkBot();
setDiagnostics((prev) => ({ ...prev, dingtalk: diag }));
} finally {
setDingtalkTesting(false);
}
};
const ensureReportableDiagnostic = async (connection: BotConnectionView) => {
return diagnoseConnection(connection.id);
};
@@ -2495,6 +2574,15 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
});
setQQSecretValue("");
};
const clearDingtalkSecret = async () => {
const env = draft.dingtalk.clientSecretEnv.trim() || "DINGTALK_CLIENT_SECRET";
if (!env) return;
await apply(async () => {
await saveBot();
await app.ClearBotSecret(env);
});
setDingtalkSecretValue("");
};
const focusQQAccessSettings = () => {
setDiagnostics((prev) => ({ ...prev, [QQ_CONNECTION_ID]: t("settings.botQQAccessRequired") }));
setExpandedConnectionId(QQ_CONNECTION_ID);
@@ -2524,6 +2612,38 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
setDraft(nextDraft);
setQQSecretValue("");
};
const saveDingtalkAndEnable = async () => {
const env = draft.dingtalk.clientSecretEnv.trim() || "DINGTALK_CLIENT_SECRET";
const secret = dingtalkSecretValue.trim();
const nextDraft = botDraftWithDerivedGatewayState({
...draft,
dingtalk: {
...draft.dingtalk,
enabled: true,
clientId: draft.dingtalk.clientId.trim(),
clientSecretEnv: env,
secretSet: draft.dingtalk.secretSet || Boolean(secret),
},
});
await apply(async () => {
await app.SetBotSettings(nextDraft);
if (secret) await app.SetBotSecret(env, secret);
});
setDraft(nextDraft);
setDingtalkSecretValue("");
};
const removeDingtalkBot = async () => {
const env = draft.dingtalk.clientSecretEnv.trim() || "DINGTALK_CLIENT_SECRET";
const nextDraft = botDraftWithDerivedGatewayState({
...draft,
dingtalk: { enabled: false, clientId: "", clientSecretEnv: "DINGTALK_CLIENT_SECRET", secretSet: false, botName: "", requireMention: true, model: "", toolApprovalMode: "", workspaceRoot: "", access: defaultBotAccess() },
});
await apply(async () => {
await app.SetBotSettings(nextDraft);
if (draft.dingtalk.secretSet) await app.ClearBotSecret(env);
});
setDraft(nextDraft);
};
const removeQQBot = async () => {
const env = draft.qq.appSecretEnv.trim() || DEFAULT_QQ_SECRET_ENV;
const nextDraft = botDraftWithDerivedGatewayState({
@@ -2539,7 +2659,8 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
setExpandedConnectionId("");
};
const selectedQQ = isQQInstallTarget && qqAdded;
const selectedConnection = isQQInstallTarget ? null : selectedInstallConnection ?? null;
const selectedConnection = isQQInstallTarget || isDingtalkInstallTarget ? null : selectedInstallConnection ?? null;
const selectedDingtalk = isDingtalkInstallTarget && dingtalkConfigured;
const selectedDiagnostic = selectedConnection ? diagnostics[selectedConnection.id] : undefined;
const selectedDiagnosticDetail = diagnosticReportDetail(selectedDiagnostic);
const selectedConnectionRemote = selectedConnection ? firstConnectionRemote(selectedConnection) : "";
@@ -2555,9 +2676,9 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
const platformFilterAvailable = connectedPlatforms.size > 0 &&
BOT_PLATFORM_KEYS.some((platform) => !connectedPlatforms.has(platform) && !platformHasAllowlistText(platform));
const botChannelConnectionForTarget = (target: BotInstallTarget) =>
target === "qq" ? null : draft.connections.find((connection) => botInstallTargetMatchesConnection(target, connection));
target === "qq" || target === "dingtalk" ? null : draft.connections.find((connection) => botInstallTargetMatchesConnection(target, connection));
const botChannelIsConfigured = (target: BotInstallTarget) =>
target === "qq" ? qqAdded : Boolean(botChannelConnectionForTarget(target));
target === "qq" ? qqAdded : target === "dingtalk" ? dingtalkConfigured : Boolean(botChannelConnectionForTarget(target));
const openBotChannel = (target: BotInstallTarget) => {
setInstallTarget(target);
const connection = botChannelConnectionForTarget(target);
@@ -2856,6 +2977,208 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
</article>
);
const dingtalkDetailCard = (
<article className="bot-detail-card" aria-labelledby="bot-detail-title">
<div className="bot-detail-card__head">
<div className="bot-detail-card__identity">
<div className="bot-detail-card__title" id="bot-detail-title">
DingTalk Bot
<span className="badge badge--neutral">DingTalk</span>
<span className={`badge ${dingtalkOnline ? "badge--project" : dingtalkConfigured ? "badge--feedback" : "badge--feedback"}`}>
{dingtalkOnline ? t("settings.botConnectionConnected") : dingtalkConfigured ? t("settings.botConnectionConfigured") : t("settings.botConnectionDisconnected")}
</span>
</div>
<div className="bot-detail-card__desc">{t("settings.botAutoSaveHint")}</div>
</div>
<div className="bot-detail-card__actions">
<button type="button" className="btn btn--small" disabled={busy || !dingtalkConfigured || dingtalkTesting} onClick={() => void testDingtalkBot()}>
{dingtalkTesting ? t("settings.botTesting") : t("settings.botTest")}
</button>
</div>
</div>
<section className="bot-detail-section">
<div className="bot-detail-section__head">{t("settings.botConnectionSummary")}</div>
<div className="bot-detail-summary">
<div>
<span>{t("settings.botConnectionColumnChannel")}</span>
<strong>DingTalk</strong>
</div>
<div>
<span>{t("settings.botDingtalkClientId")}</span>
<code title={draft.dingtalk.clientId.trim() || undefined}>{draft.dingtalk.clientId.trim() || "—"}</code>
</div>
<div>
<span>{t("settings.botConnectionColumnScope")}</span>
<strong>{t("settings.botScopeGlobal")}</strong>
</div>
<div>
<span>{t("settings.botConnectionColumnStatus")}</span>
<strong>{dingtalkOnline ? t("settings.botConnectionConnected") : dingtalkConfigured ? t("settings.botConnectionConfigured") : t("settings.botConnectionDisconnected")}</strong>
</div>
</div>
</section>
<section className="bot-detail-section bot-detail-section--runtime-primary">
<SettingsField label={t("settings.botEnableBot")} hint={t("settings.botGatewayEnabled")}>
<ToggleSegment
value={draft.dingtalk.enabled}
disabled={busy}
onChange={(enabled) => {
updateDingtalk({ enabled });
void persistDingtalk({ enabled });
}}
/>
</SettingsField>
<SettingsField label={t("settings.botToolApprovalMode")} hint={t("settings.botToolApprovalModeHint")}>
<div className="provider-add-segmented" role="group" aria-label={t("settings.botToolApprovalMode")}>
{TOOL_APPROVAL_MODES.map((mode) => (
<button
key={mode}
type="button"
className={normalizeBotToolApprovalMode(draft.dingtalk.toolApprovalMode, true) === mode ? "provider-add-segmented__item provider-add-segmented__item--active" : "provider-add-segmented__item"}
disabled={busy}
onClick={() => {
updateDingtalk({ toolApprovalMode: mode });
// 热更新运行中 gateway,不重启 bot runtime(避免面板跳变)。
void app.SetBotDingtalkToolApprovalMode(mode).catch((e) => {
console.warn("set dingtalk approval mode failed", e);
});
}}
>
{t(`settings.botToolApprovalMode.${mode}` as DictKey)}
</button>
))}
</div>
</SettingsField>
<SettingsField label={t("settings.botChannelModel")} hint={t("settings.botChannelModelHint")}>
<ModelPicker
s={s}
refs={refs}
value={toRef(draft.dingtalk.model, s)}
disabled={busy}
emptyOptionLabel={t("settings.botChannelModelAuto")}
emptyOptionHint={settingsModelMeta(s, t)}
onPick={(model) => {
updateDingtalk({ model });
void persistDingtalk({ model });
}}
/>
</SettingsField>
</section>
{renderBotAccessSection(DINGTALK_CONNECTION_ID, draft.dingtalk.access, updateDingtalkAccess, (patch) => void persistDingtalkAccess(patch))}
<section className="bot-detail-section">
<div className="bot-detail-section__head">{t("settings.botRuntimeSettings")}</div>
<SettingsField label={t("settings.botWorkspaceRoot")} hint={t("settings.botWorkspaceRootHint")}>
<input
className="mem-input"
value={draft.dingtalk.workspaceRoot}
disabled={busy}
placeholder={t("settings.botWorkspaceRootPlaceholder")}
spellCheck={false}
onChange={(event) => updateDingtalk({ workspaceRoot: event.target.value })}
onBlur={(event) => void persistDingtalk({ workspaceRoot: event.currentTarget.value })}
/>
</SettingsField>
<SettingsField label={t("settings.botDingtalkBotName")} hint={t("settings.botDingtalkBotNameHint")}>
<input
className="mem-input"
value={draft.dingtalk.botName}
disabled={busy}
placeholder={t("settings.botDingtalkBotName")}
spellCheck={false}
onChange={(event) => updateDingtalk({ botName: event.target.value })}
onBlur={(event) => void persistDingtalk({ botName: event.currentTarget.value })}
/>
</SettingsField>
<SettingsField label={t("settings.botDingtalkRequireMention")} hint={t("settings.botDingtalkRequireMentionHint")}>
<ToggleSegment
value={draft.dingtalk.requireMention}
disabled={busy}
onLabel={t("settings.toggleOn")}
offLabel={t("settings.toggleOff")}
onChange={(requireMention) => {
updateDingtalk({ requireMention });
void persistDingtalk({ requireMention });
}}
/>
</SettingsField>
</section>
<section className="bot-detail-section">
<div className="bot-detail-section__head">{t("settings.botCredential")}</div>
<div className="bot-credential-stack">
<div className="bot-credential-line">
<span>{draft.dingtalk.clientId.trim() ? t("settings.botCredentialClientId", { value: draft.dingtalk.clientId.trim() }) : t("settings.botCredentialConfigured")}</span>
<strong>{draft.dingtalk.secretSet ? t("settings.botSecretSet") : t("settings.botSecretMissing")}</strong>
</div>
<div className="bot-secret-row bot-secret-row--dingtalk">
<input
className="mem-input"
value={draft.dingtalk.clientId}
disabled={busy}
placeholder={t("settings.botDingtalkClientId")}
spellCheck={false}
aria-label={t("settings.botDingtalkClientId")}
onChange={(event) => updateDingtalk({ clientId: event.target.value })}
onBlur={(event) => void persistDingtalk({ clientId: event.currentTarget.value })}
/>
<input
className="mem-input"
value={draft.dingtalk.clientSecretEnv || "DINGTALK_CLIENT_SECRET"}
disabled={busy}
placeholder="DINGTALK_CLIENT_SECRET"
spellCheck={false}
aria-label={t("settings.botSecretEnv")}
onChange={(event) => updateDingtalk({ clientSecretEnv: event.target.value })}
onBlur={(event) => void persistDingtalk({ clientSecretEnv: event.currentTarget.value || "DINGTALK_CLIENT_SECRET" })}
/>
<input
className="mem-input"
type="password"
value={dingtalkSecretValue}
disabled={busy}
placeholder={draft.dingtalk.secretSet ? t("settings.botSecretReplace") : t("settings.botSecretPaste")}
aria-label={t("settings.botDingtalkClientSecret")}
onChange={(event) => setDingtalkSecretValue(event.target.value)}
/>
<div className="bot-secret-row__actions">
<button type="button" className="btn btn--secondary btn--small" disabled={busy || !dingtalkCanSaveAndEnable} onClick={() => void saveDingtalkAndEnable()}>
{draft.dingtalk.secretSet ? t("settings.saveKey") : t("settings.botSaveAndEnable")}
</button>
<button type="button" className="btn btn--secondary btn--small" disabled={busy || !draft.dingtalk.secretSet} onClick={() => void clearDingtalkSecret()}>
{t("settings.clearKey")}
</button>
</div>
</div>
</div>
</section>
{diagnosticMessage(diagnostics.dingtalk) ? (
<div className="bot-detail-notice">
<span>{diagnosticMessage(diagnostics.dingtalk)}</span>
</div>
) : null}
<section className="bot-detail-section bot-detail-section--danger">
<div>
<div className="bot-detail-section__head">{t("settings.botDangerZone")}</div>
<p>{t("settings.deleteBotHint")}</p>
</div>
<InlineConfirmButton
label={t("settings.deleteBot")}
confirmLabel={t("settings.confirmDeleteBot")}
cancelLabel={t("common.cancel")}
disabled={busy}
danger
onConfirm={() => void removeDingtalkBot()}
/>
</section>
</article>
);
const connectionDetailCard = selectedConnection ? (
<article className="bot-detail-card" aria-labelledby="bot-detail-title">
<div className="bot-detail-card__head">
@@ -3005,12 +3328,14 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
placeholder={selectedConnection.credential.secretSet ? t("settings.botSecretReplace") : t("settings.botSecretPaste")}
onChange={(event) => setConnectionSecrets((prev) => ({ ...prev, [selectedConnection.id]: event.target.value }))}
/>
<button type="button" className="btn btn--secondary btn--small" disabled={busy || !(connectionSecrets[selectedConnection.id] ?? "").trim()} onClick={() => void saveConnectionSecret(selectedConnection)}>
{t("settings.saveKey")}
</button>
<button type="button" className="btn btn--secondary btn--small" disabled={busy || !selectedConnection.credential.secretSet} onClick={() => void clearConnectionSecret(selectedConnection)}>
{t("settings.clearKey")}
</button>
<div className="bot-secret-row__actions">
<button type="button" className="btn btn--secondary btn--small" disabled={busy || !(connectionSecrets[selectedConnection.id] ?? "").trim()} onClick={() => void saveConnectionSecret(selectedConnection)}>
{t("settings.saveKey")}
</button>
<button type="button" className="btn btn--secondary btn--small" disabled={busy || !selectedConnection.credential.secretSet} onClick={() => void clearConnectionSecret(selectedConnection)}>
{t("settings.clearKey")}
</button>
</div>
</div>
) : null}
</div>
@@ -3087,6 +3412,71 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
</div>
</div>
</div>
) : isDingtalkInstallTarget ? (
<div className="bot-connect-panel bot-connect-panel--manual bot-connect-panel--dingtalk">
<div className="bot-connect-panel__body">
<div className="bot-qq-simple__head">
<div>
<strong>{selectedInstallLabel}</strong>
<p>{t("settings.botInstallManualDingtalk")}</p>
</div>
<span className={`bot-qq-simple__status${dingtalkConfigured ? " bot-qq-simple__status--ready" : ""}`}>
{dingtalkConfigured ? <CheckCircle2 aria-hidden="true" /> : <KeyRound aria-hidden="true" />}
{draft.dingtalk.secretSet ? t("settings.botSecretSet") : t("settings.botSecretMissing")}
</span>
</div>
<div className="bot-manual-form bot-manual-form--dingtalk">
<div className="bot-card-field">
<span>{t("settings.botDingtalkClientId")}</span>
<div>
<input
className="mem-input"
aria-label={t("settings.botDingtalkClientId")}
value={draft.dingtalk.clientId}
disabled={busy}
spellCheck={false}
onChange={(event) => updateDingtalk({ clientId: event.target.value })}
onBlur={(event) => void persistDingtalk({ clientId: event.currentTarget.value })}
/>
</div>
</div>
<div className="bot-card-field">
<span>{t("settings.botDingtalkClientSecret")}</span>
<div>
<input
className="mem-input"
type="password"
value={dingtalkSecretValue}
disabled={busy}
placeholder={draft.dingtalk.secretSet ? t("settings.botSecretSavedOptional") : t("settings.botSecretPaste")}
spellCheck={false}
aria-label={t("settings.botDingtalkClientSecret")}
onChange={(event) => setDingtalkSecretValue(event.target.value)}
/>
</div>
</div>
<div className="bot-card-field">
<span>{t("settings.botDingtalkBotName")}</span>
<div>
<input
className="mem-input"
aria-label={t("settings.botDingtalkBotName")}
value={draft.dingtalk.botName}
disabled={busy}
spellCheck={false}
onChange={(event) => updateDingtalk({ botName: event.target.value })}
onBlur={(event) => void persistDingtalk({ botName: event.currentTarget.value })}
/>
</div>
</div>
<div className="bot-qq-simple__actions">
<button type="button" className="btn btn--primary btn--small" disabled={busy || !dingtalkCanSaveAndEnable} onClick={() => void saveDingtalkAndEnable()}>
{t("settings.botSaveAndEnable")}
</button>
</div>
</div>
</div>
</div>
) : (
<div className="bot-connect-panel bot-connect-panel--phone">
<div className="bot-connect-panel__qr">
@@ -3179,7 +3569,10 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
onClick={() => openBotChannel(target)}
>
<span className="bot-channel-tab__icon" aria-hidden="true">
{target === "qq" || target === "weixin" ? <MessageCircle size={24} /> : <BotIcon size={24} />}
{(() => {
const Icon = CHANNEL_ICONS[target];
return <Icon className="bot-channel-tab__brand" />;
})()}
</span>
<span className="bot-channel-tab__text">
<strong>{botTargetLabel(target, t)}</strong>
@@ -3204,6 +3597,8 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
</article>
) : selectedQQ ? (
qqDetailCard
) : selectedDingtalk ? (
dingtalkDetailCard
) : selectedConnection ? (
connectionDetailCard
) : (
@@ -3650,114 +4045,6 @@ function BotsSection({ s, busy, apply, initialFocus }: BotsSectionProps) {
);
}
function diagnosticMessage(diag?: BotConnectionDiagnostic | string): string {
if (typeof diag === "string") return diag;
return diag?.message || diag?.status || "";
}
function diagnosticReportDetail(diag?: BotConnectionDiagnostic | string): string {
if (typeof diag === "string") return "";
return diag?.reportDetail || "";
}
function botTargetLabel(target: BotInstallTarget, t: ReturnType<typeof useT>): string {
switch (target) {
case "qq": return "QQ";
case "lark": return "Lark";
case "weixin": return t("settings.botWeixin");
default: return t("settings.botFeishu");
}
}
function botTargetHint(target: BotInstallTarget, t: ReturnType<typeof useT>): string {
switch (target) {
case "qq": return t("settings.botInstallQQHint");
case "lark": return t("settings.botInstallLarkHint");
case "weixin": return t("settings.botInstallWeixinHint");
default: return t("settings.botInstallFeishuHint");
}
}
function qqBotAdded(qq: BotSettingsView["qq"]): boolean {
return Boolean(qq.enabled || qq.secretSet || qq.appId.trim());
}
function botAccessEntryCount(access: BotAccessView): number {
return [
...asArray(access.users),
...asArray(access.groups),
...asArray(access.approvers),
...asArray(access.admins),
].filter((value) => value.trim()).length;
}
function botAccessReady(access: BotAccessView): boolean {
if (access.allowAll || access.pairingEnabled) return true;
if (!access.enabled) return false;
return botAccessEntryCount(access) > 0;
}
function botInstallTargetMatchesConnection(target: BotOfficialInstallTarget, connection: BotConnectionView): boolean {
if (target === "weixin") return connection.provider === "weixin";
if (target === "lark") return connection.provider === "feishu" && connection.domain === "lark";
return connection.provider === "feishu" && connection.domain !== "lark";
}
function botInstallTargetForConnection(connection: BotConnectionView): BotInstallTarget {
if (connection.provider === "weixin") return "weixin";
if (connection.provider === "feishu" && connection.domain === "lark") return "lark";
if (connection.provider === "qq") return "qq";
return "feishu";
}
function formatInstallUserCode(code: string): string {
const compact = code.replace(/[^a-z0-9]/gi, "").toUpperCase().slice(0, 8);
if (compact.length <= 4) return compact;
return `${compact.slice(0, 4)}-${compact.slice(4)}`;
}
function formatInstallTimeLeft(seconds: number): string {
const value = Math.max(0, Math.floor(seconds));
const minutes = Math.floor(value / 60);
const rest = value % 60;
return `${minutes}:${String(rest).padStart(2, "0")}`;
}
function botConnectionLabel(connection: BotConnectionView, t: ReturnType<typeof useT>): string {
if (connection.domain === "lark") return "Lark";
if (connection.provider === "weixin") return t("settings.botWeixin");
if (connection.provider === "qq") return "QQ";
return t("settings.botFeishu");
}
function firstConnectionRemote(connection: BotConnectionView): string {
return connection.sessionMappings.find((mapping) => mapping.remoteId.trim())?.remoteId ?? "";
}
function botConnectionScopeLabel(connection: BotConnectionView, t: ReturnType<typeof useT>): string {
return connection.workspaceRoot.trim() ? t("settings.botScopeProject") : t("settings.botScopeGlobal");
}
function botConnectionSecretEnv(connection: BotConnectionView): string {
return connection.provider === "weixin" ? connection.credential.tokenEnv : connection.credential.appSecretEnv;
}
function botConnectionSecretPatch(connection: BotConnectionView, value: string): Partial<BotConnectionView["credential"]> {
return connection.provider === "weixin" ? { tokenEnv: value } : { appSecretEnv: value };
}
function botConnectionCredentialSummary(connection: BotConnectionView, t: ReturnType<typeof useT>): string {
if (connection.provider === "weixin") {
return connection.credential.accountId
? t("settings.botCredentialAccount", { value: connection.credential.accountId })
: t("settings.botCredentialLocalWeixin");
}
if (connection.credential.appId) {
return t("settings.botCredentialApp", { value: connection.credential.appId });
}
return t("settings.botCredentialConfigured");
}
function ToggleSegment({
value,
disabled,
@@ -3840,6 +4127,7 @@ function sanitizeBotDraft(draft: BotSettingsView): BotSettingsView {
qq: uniqueStrings(bot.selfUserIds.qq.map((v) => v.trim())),
feishu: uniqueStrings(bot.selfUserIds.feishu.map((v) => v.trim())),
weixin: uniqueStrings(bot.selfUserIds.weixin.map((v) => v.trim())),
dingtalk: uniqueStrings(bot.selfUserIds.dingtalk.map((v) => v.trim())),
},
control: {
enabled: bot.control.enabled,
@@ -3866,6 +4154,10 @@ function sanitizeBotDraft(draft: BotSettingsView): BotSettingsView {
qqGroups: uniqueStrings(bot.allowlist.qqGroups.map((v) => v.trim())),
feishuGroups: uniqueStrings(bot.allowlist.feishuGroups.map((v) => v.trim())),
weixinGroups: uniqueStrings(bot.allowlist.weixinGroups.map((v) => v.trim())),
dingtalkUsers: uniqueStrings(bot.allowlist.dingtalkUsers.map((v) => v.trim())),
dingtalkApprovers: uniqueStrings(bot.allowlist.dingtalkApprovers.map((v) => v.trim())),
dingtalkAdmins: uniqueStrings(bot.allowlist.dingtalkAdmins.map((v) => v.trim())),
dingtalkGroups: uniqueStrings(bot.allowlist.dingtalkGroups.map((v) => v.trim())),
},
qq: {
...bot.qq,
@@ -3891,6 +4183,14 @@ function sanitizeBotDraft(draft: BotSettingsView): BotSettingsView {
tokenEnv: bot.weixin.tokenEnv.trim(),
apiBase: bot.weixin.apiBase.trim().replace(/\/+$/, ""),
},
dingtalk: {
...bot.dingtalk,
clientId: bot.dingtalk.clientId.trim(),
clientSecretEnv: bot.dingtalk.clientSecretEnv.trim(),
botName: bot.dingtalk.botName.trim(),
toolApprovalMode: normalizeBotToolApprovalMode(bot.dingtalk.toolApprovalMode),
access: sanitizeBotAccess(bot.dingtalk.access),
},
connections: bot.connections.map((conn) => ({ ...normalizeBotConnection(conn), access: sanitizeBotAccess(conn.access) })).filter((conn) => conn.id && conn.provider),
};
}
@@ -3910,7 +4210,7 @@ function botDraftWithDerivedGatewayState(draft: BotSettingsView): BotSettingsVie
const bot = sanitizeBotDraft(draft);
return {
...bot,
enabled: bot.qq.enabled || bot.connections.some((connection) => connection.enabled),
enabled: bot.qq.enabled || bot.dingtalk.enabled || bot.connections.some((connection) => connection.enabled),
};
}
@@ -4982,7 +5282,7 @@ const OFFICIAL_PROVIDER_CHOICES: Array<{ kind: OfficialProviderKind; labelKey: D
type ProviderTemplateChoice =
| { id: string; source: "official"; kind: OfficialProviderKind; label: string; description: string; keyEnv: string; added: boolean; keySet: boolean }
| { id: string; source: "preset"; presetID: string; label: string; description: string; keyEnv: string; added: boolean; status: ProviderPresetStatus; statusProviderNames: string[]; keySet: boolean };
| { id: string; source: "preset"; presetID: string; label: string; description: string; keyEnv: string; added: boolean; status: ProviderPresetStatus; statusProviderNames: string[]; keySet: boolean; recommended: boolean; billingMode: string };
function providerTemplateCanAdd(choice: ProviderTemplateChoice | undefined): boolean {
if (!choice) return false;
@@ -5068,7 +5368,7 @@ function providerPresetDescription(preset: ProviderPresetView, t: ReturnType<typ
return t("settings.addProvider.preset.zaiCodingPlanGlobalDesc");
case "zai-coding-plan-global-anthropic":
return t("settings.addProvider.preset.zaiCodingPlanGlobalAnthropicDesc");
case "opencode-go": case "opencode-go-anthropic":
case "opencode-go-recommended": case "opencode-go": case "opencode-go-anthropic": case "opencode-go-responses":
case "opencode-go-deepseek-anthropic": case "opencode-go-deepseek-responses":
return t(opencodeGoPresetDescriptionKeys[preset.id]);
case "opencode-zen-anthropic":
@@ -5187,11 +5487,16 @@ export function AddProviderPanel({
status: normalizeProviderPresetStatus(preset.status, preset.added),
statusProviderNames: asArray(preset.statusProviderNames),
keySet: preset.keySet,
recommended: Boolean(preset.recommended),
billingMode: String(preset.billingMode ?? ""),
})),
], [officialProviders, providerPresets, t]);
const [templateID, setTemplateID] = useState("official:deepseek");
const [key, setKey] = useState("");
const firstAvailableTemplateID = templateChoices.find(providerTemplateCanAdd)?.id ?? templateChoices[0]?.id ?? "";
const firstAvailableTemplateID = templateChoices.find((choice) => choice.source === "preset" && choice.recommended && providerTemplateCanAdd(choice))?.id
?? templateChoices.find(providerTemplateCanAdd)?.id
?? templateChoices[0]?.id
?? "";
const selected = templateChoices.find((choice) => choice.id === templateID) ?? templateChoices.find((choice) => choice.id === firstAvailableTemplateID) ?? templateChoices[0];
useEffect(() => {
const current = templateChoices.find((choice) => choice.id === templateID);
@@ -5246,6 +5551,7 @@ export function AddProviderPanel({
{templateChoices.map((choice) => {
const canAdd = providerTemplateCanAdd(choice);
const badge = providerTemplateStatusBadge(choice, t);
const recommendationBadge = choice.source === "preset" && choice.recommended ? t("settings.recommended") : "";
const conflictProviderName = providerTemplateConflictProviderName(choice);
if (choice.source === "preset" && (choice.status === "name_conflict" || choice.status === "installed_modified")) {
return (
@@ -5255,6 +5561,7 @@ export function AddProviderPanel({
>
<strong>
{choice.label}
{recommendationBadge ? ` · ${recommendationBadge}` : ""}
{badge ? ` · ${badge}` : ""}
</strong>
<span>{choice.description}</span>
@@ -5289,6 +5596,7 @@ export function AddProviderPanel({
>
<strong>
{choice.label}
{recommendationBadge ? ` · ${recommendationBadge}` : ""}
{badge ? ` · ${badge}` : ""}
</strong>
<span>{choice.description}</span>
@@ -6052,6 +6360,10 @@ function botAllowlistTextValues(allowlist: BotAllowlistView): Record<BotAllowlis
qqGroups: allowlist.qqGroups.join("\n"),
feishuGroups: allowlist.feishuGroups.join("\n"),
weixinGroups: allowlist.weixinGroups.join("\n"),
dingtalkUsers: allowlist.dingtalkUsers.join("\n"),
dingtalkApprovers: allowlist.dingtalkApprovers.join("\n"),
dingtalkAdmins: allowlist.dingtalkAdmins.join("\n"),
dingtalkGroups: allowlist.dingtalkGroups.join("\n"),
};
}
@@ -6060,6 +6372,7 @@ function botSelfUserTextValues(selfUserIds: BotSettingsView["selfUserIds"]): Rec
qq: selfUserIds.qq.join("\n"),
feishu: selfUserIds.feishu.join("\n"),
weixin: selfUserIds.weixin.join("\n"),
dingtalk: selfUserIds.dingtalk.join("\n"),
};
}
@@ -6225,6 +6538,8 @@ export function ProviderEditor({
initial?.requestUrl ?? "",
initial?.chatUrl ?? "",
));
const providerNameInputId = useId();
const providerNameHelpId = useId();
const providerUrlInputId = useId();
const providerUrlHelpId = useId();
const [models, setModels] = useState((initial?.models ?? []).join(", "));
@@ -6611,8 +6926,21 @@ export function ProviderEditor({
return (
<div className={`provider-editor${isNewCustomProvider ? " provider-editor--wizard" : ""}`}>
<label className="set-label">{t("settings.customProviderName")}</label>
<input className="mem-input" placeholder={t("settings.customProviderNamePlaceholder")} value={name} onChange={(e) => setName(e.target.value)} disabled={!!initial} />
<label className="set-label" htmlFor={providerNameInputId}>{t("settings.customProviderName")}</label>
<input
id={providerNameInputId}
className="mem-input provider-name-input"
aria-describedby={initial ? providerNameHelpId : undefined}
placeholder={t("settings.customProviderNamePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!!initial}
/>
{initial && (
<div id={providerNameHelpId} className="mem-hint provider-name-readonly-hint">
{t("settings.customProviderNameReadonlyHint")}
</div>
)}
<label className="set-label">{t("settings.providerProtocol")}</label>
<select className="mem-select" value={kind} onChange={(e) => setKind(e.target.value)}>
{providerKindChoices.map((choice) => (
+12 -15
View File
@@ -85,23 +85,20 @@ export function TodoPanel({
badges={<PromptBadge>{done}/{todos.length}</PromptBadge>}
meta={summary}
role="region"
cardClassName="prompt-shelf--todo"
cardCollapsible
collapsed={!open}
onToggleCollapse={() => setOpen((value) => {
const next = !value;
saveOpenState(stateKey, next);
return next;
})}
headerActions={
<>
<PromptHeaderAction
onClick={() => setOpen((value) => {
const next = !value;
saveOpenState(stateKey, next);
return next;
})}
>
{open ? t("common.collapse") : t("common.expand")}
allDone && (
<PromptHeaderAction onClick={onDismiss}>
{t("common.close")}
</PromptHeaderAction>
{allDone && (
<PromptHeaderAction onClick={onDismiss}>
{t("common.close")}
</PromptHeaderAction>
)}
</>
)
}
>
{open && (
+3 -1
View File
@@ -41,6 +41,7 @@ export function Tooltip({
block = false,
disabled = false,
className,
delay = 180,
}: {
label?: ReactNode;
children: ReactNode;
@@ -49,6 +50,7 @@ export function Tooltip({
block?: boolean;
disabled?: boolean;
className?: string;
delay?: number;
}) {
const id = useId();
const triggerRef = useRef<HTMLElement | null>(null);
@@ -148,7 +150,7 @@ export function Tooltip({
const triggerProps = {
className: triggerClass,
"aria-describedby": open ? id : undefined,
onMouseEnter: () => show(),
onMouseEnter: () => show(delay),
onMouseLeave: hide,
onPointerDownCapture: hide,
onFocus: () => show(0),
@@ -0,0 +1,97 @@
import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { MoreHorizontal } from "lucide-react";
import { Tooltip } from "./Tooltip";
import type { TopicbarMoreMenuContentProps } from "./TopicbarMoreMenuContent";
import { t } from "../lib/i18n";
const loadMenuContent = () => import("./TopicbarMoreMenuContent");
const TopicbarMoreMenuContent = lazy(async () => ({
default: (await loadMenuContent()).TopicbarMoreMenuContent,
}));
type TopicbarMoreMenuProps = Omit<TopicbarMoreMenuContentProps, "copied" | "initialFocus" | "onClose" | "onCopied">;
/** Keeps the topic bar compact while loading overflow actions only on demand. */
export function TopicbarMoreMenu(props: TopicbarMoreMenuProps) {
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [initialFocus, setInitialFocus] = useState<"first" | "last">("first");
const [copied, setCopied] = useState(false);
const copiedTimerRef = useRef<number | null>(null);
const closeMenu = (restoreFocus: boolean) => {
setMenuOpen(false);
if (restoreFocus) triggerRef.current?.focus();
};
useEffect(() => {
if (!menuOpen) return;
const onPointerDown = (event: MouseEvent) => {
if (!rootRef.current?.contains(event.target as Node)) closeMenu(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeMenu(true);
};
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [menuOpen]);
useEffect(() => () => {
if (copiedTimerRef.current != null) window.clearTimeout(copiedTimerRef.current);
}, []);
const markCopied = () => {
setCopied(true);
if (copiedTimerRef.current != null) window.clearTimeout(copiedTimerRef.current);
copiedTimerRef.current = window.setTimeout(() => setCopied(false), 1200);
};
const openFromKeyboard = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault();
setInitialFocus(event.key === "ArrowUp" ? "last" : "first");
setMenuOpen(true);
};
return (
<div ref={rootRef} className={`topicbar__more${menuOpen ? " topicbar__more--open" : ""}`}>
<Tooltip label={t("topicBar.more")}>
<button
ref={triggerRef}
className="topicbar__action-btn topicbar__action-btn--icon topicbar__action-btn--utility"
type="button"
aria-label={t("topicBar.more")}
aria-haspopup="menu"
aria-expanded={menuOpen}
onPointerEnter={() => { void loadMenuContent(); }}
onFocus={() => { void loadMenuContent(); }}
onKeyDown={openFromKeyboard}
onClick={() => {
setInitialFocus("first");
setMenuOpen((open) => !open);
}}
>
<MoreHorizontal size={15} />
</button>
</Tooltip>
{menuOpen && (
<Suspense fallback={null}>
<TopicbarMoreMenuContent
{...props}
copied={copied}
initialFocus={initialFocus}
onClose={closeMenu}
onCopied={markCopied}
/>
</Suspense>
)}
</div>
);
}
@@ -0,0 +1,83 @@
.topicbar__more-menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: var(--z-topicbar-menu);
min-width: 168px;
padding: 5px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-elev);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.36);
--wails-draggable: no-drag;
}
.topicbar__menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 9px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--fg-dim);
font: inherit;
font-size: 12.5px;
text-align: left;
}
.topicbar__menu-item:hover,
.topicbar__menu-item:focus-visible {
color: var(--fg);
background: var(--button-bg);
}
.topicbar__menu-item:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.topicbar__menu-item--active {
color: var(--accent);
}
.topicbar__more-export {
position: relative;
}
.topicbar__more-export-menu {
position: absolute;
/* The primary menu sits at the far right, so the submenu opens left. */
right: calc(100% - 8px);
top: -5px;
z-index: var(--z-topicbar-menu);
min-width: 168px;
padding: 5px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-elev);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.36);
--wails-draggable: no-drag;
}
.topicbar__more-export-menu button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 9px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--fg-dim);
font: inherit;
font-size: 12.5px;
text-align: left;
}
.topicbar__more-export-menu button:hover,
.topicbar__more-export-menu button:focus-visible {
color: var(--fg);
background: var(--button-bg);
}
@@ -0,0 +1,195 @@
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import {
Activity,
Check,
Copy,
Download,
FileDown,
FileImage,
FileJson,
FileText,
GitBranch,
TerminalSquare,
} from "lucide-react";
import "./TopicbarMoreMenuContent.css";
import { writeClipboardText } from "../lib/clipboard";
import { t } from "../lib/i18n";
export interface TopicbarMoreMenuContentProps {
sessionHasContent: boolean;
getSessionMarkdown: () => string | Promise<string>;
exportSession: (format: "markdown" | "json" | "pdf" | "image") => void;
openChangedDock: () => void;
toggleTerminal: () => void;
prefetchTerminal?: () => void;
openSessionSummary: () => void;
tasksOpen: boolean;
copied: boolean;
initialFocus: "first" | "last";
onClose: (restoreFocus: boolean) => void;
onCopied: () => void;
}
function enabledItems(menu: HTMLElement): HTMLButtonElement[] {
return Array.from(menu.querySelectorAll<HTMLButtonElement>('[role="menuitem"]'))
.filter((item) => !item.disabled && item.closest('[role="menu"]') === menu);
}
function moveMenuFocus(event: ReactKeyboardEvent<HTMLDivElement>) {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
event.preventDefault();
event.stopPropagation();
const items = enabledItems(event.currentTarget);
if (items.length === 0) return;
const current = items.indexOf(document.activeElement as HTMLButtonElement);
const next = event.key === "Home" ? 0
: event.key === "End" ? items.length - 1
: event.key === "ArrowDown" ? (current + 1 + items.length) % items.length
: (current - 1 + items.length) % items.length;
items[next]?.focus();
}
export function TopicbarMoreMenuContent({
sessionHasContent,
getSessionMarkdown,
exportSession,
openChangedDock,
toggleTerminal,
prefetchTerminal,
openSessionSummary,
tasksOpen,
copied,
initialFocus,
onClose,
onCopied,
}: TopicbarMoreMenuContentProps) {
const menuRef = useRef<HTMLDivElement>(null);
const exportTriggerRef = useRef<HTMLButtonElement>(null);
const exportMenuRef = useRef<HTMLDivElement>(null);
const [exportOpen, setExportOpen] = useState(false);
useEffect(() => {
const items = menuRef.current ? enabledItems(menuRef.current) : [];
(initialFocus === "last" ? items[items.length - 1] : items[0])?.focus();
}, [initialFocus]);
useEffect(() => {
if (!exportOpen || !exportMenuRef.current) return;
enabledItems(exportMenuRef.current)[0]?.focus();
}, [exportOpen]);
const closeAndRun = (action: () => void) => {
onClose(true);
action();
};
const copySession = async () => {
try {
await writeClipboardText(await getSessionMarkdown());
} catch {
/* clipboard unavailable */
}
onCopied();
};
const onMainMenuKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onClose(true);
return;
}
moveMenuFocus(event);
};
const onExportMenuKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft" || event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
setExportOpen(false);
exportTriggerRef.current?.focus();
return;
}
moveMenuFocus(event);
};
return (
<div ref={menuRef} className="topicbar__more-menu" role="menu" aria-label={t("topicBar.more")} onKeyDown={onMainMenuKeyDown}>
<button tabIndex={-1} className="topicbar__menu-item" type="button" role="menuitem" onClick={() => closeAndRun(() => { void copySession(); })}>
{copied ? <Check size={14} /> : <Copy size={14} />}
<span>{copied ? t("msg.copied") : t("topicBar.copyAll")}</span>
</button>
<div className={`topicbar__more-export${exportOpen ? " topicbar__more-export--open" : ""}`}>
<button
ref={exportTriggerRef}
tabIndex={-1}
className="topicbar__menu-item"
type="button"
role="menuitem"
aria-haspopup="menu"
aria-expanded={exportOpen}
disabled={!sessionHasContent}
onKeyDown={(event) => {
if (event.key !== "ArrowRight") return;
event.preventDefault();
event.stopPropagation();
setExportOpen(true);
}}
onClick={() => setExportOpen((open) => !open)}
>
<Download size={14} />
<span>{t("topicBar.export")}</span>
</button>
{exportOpen && (
<div ref={exportMenuRef} className="topicbar__more-export-menu" role="menu" aria-label={t("topicBar.export")} onKeyDown={onExportMenuKeyDown}>
<button tabIndex={-1} type="button" role="menuitem" onClick={() => closeAndRun(() => exportSession("markdown"))}>
<FileText size={13} />
<span>{t("topicBar.exportMarkdown")}</span>
</button>
<button tabIndex={-1} type="button" role="menuitem" onClick={() => closeAndRun(() => exportSession("json"))}>
<FileJson size={13} />
<span>{t("topicBar.exportJson")}</span>
</button>
<button tabIndex={-1} type="button" role="menuitem" onClick={() => closeAndRun(() => exportSession("pdf"))}>
<FileDown size={13} />
<span>{t("topicBar.exportPdf")}</span>
</button>
<button tabIndex={-1} type="button" role="menuitem" onClick={() => closeAndRun(() => exportSession("image"))}>
<FileImage size={13} />
<span>{t("topicBar.exportImage")}</span>
</button>
</div>
)}
</div>
<button tabIndex={-1} className="topicbar__menu-item" type="button" role="menuitem" onClick={() => closeAndRun(openChangedDock)}>
<GitBranch size={14} />
<span>{t("workspace.changedTab")}</span>
</button>
<button
tabIndex={-1}
className="topicbar__menu-item"
type="button"
role="menuitem"
onPointerEnter={prefetchTerminal}
onFocus={prefetchTerminal}
onClick={() => closeAndRun(toggleTerminal)}
>
<TerminalSquare size={14} />
<span>{t("rightDock.terminal")}</span>
</button>
<button
tabIndex={-1}
className={`topicbar__menu-item${tasksOpen ? " topicbar__menu-item--active" : ""}`}
type="button"
role="menuitem"
aria-pressed={tasksOpen}
onClick={() => closeAndRun(openSessionSummary)}
>
<Activity size={14} />
<span>{t("summary.session")}</span>
</button>
</div>
);
}
Loaded 100 of 375 files, more files were not shown because too many files have changed in this diff. Show more